From 42992099261be31881fcb25a53f768cdf024ac09 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 19:36:36 +0800 Subject: [PATCH 01/15] Unify symbolic reduction, registry, and solver contracts Introduce exact symbolic expressions, rule-owned size relations, strict extraction and numeric contracts, model-owned construction metadata, and deterministic solver selection as one compile-time contract. Migrate directly from the main-branch APIs without recording discarded intermediate designs. --- Cargo.toml | 28 +- problemreductions-cli/Cargo.toml | 11 +- problemreductions-expr/Cargo.toml | 17 + problemreductions-expr/src/lib.rs | 1356 +++++++++++++++++ .../tests/fixtures/sympy_oracle.json | 904 +++++++++++ problemreductions-expr/tests/sympy_fixture.rs | 224 +++ problemreductions-macros/Cargo.toml | 2 + problemreductions-macros/src/expr_codegen.rs | 153 ++ problemreductions-macros/src/lib.rs | 817 +++++++--- problemreductions-macros/src/parser.rs | 489 ------ scripts/generate_symbolic_expr_fixture.py | 298 ++++ scripts/pyproject.toml | 1 + scripts/uv.lock | 25 +- src/big_o.rs | 400 +---- src/canonical.rs | 431 ------ src/example_db/mod.rs | 2 +- src/example_db/specs.rs | 3 +- src/export.rs | 16 +- src/expr.rs | 879 +++++------ src/growth.rs | 1058 +++++++++++++ src/lib.rs | 19 +- .../algebraic/algebraic_equations_over_gf2.rs | 1 + src/models/algebraic/bmf.rs | 1 + .../algebraic/closest_vector_problem.rs | 59 +- .../consecutive_block_minimization.rs | 26 +- .../consecutive_ones_matrix_augmentation.rs | 24 +- .../algebraic/consecutive_ones_submatrix.rs | 1 + src/models/algebraic/equilibrium_point.rs | 1 + .../algebraic/feasible_basis_extension.rs | 62 +- src/models/algebraic/ilp.rs | 10 +- src/models/algebraic/minimum_matrix_cover.rs | 1 + .../algebraic/minimum_matrix_domination.rs | 1 + .../algebraic/minimum_weight_decoding.rs | 43 +- ...mum_weight_solution_to_linear_equations.rs | 43 +- src/models/algebraic/quadratic_assignment.rs | 1 + src/models/algebraic/quadratic_congruences.rs | 1 + .../quadratic_diophantine_equations.rs | 1 + src/models/algebraic/qubo.rs | 35 +- .../algebraic/simultaneous_incongruences.rs | 1 + .../algebraic/sparse_matrix_compression.rs | 32 +- src/models/decision.rs | 136 +- src/models/formula/circuit.rs | 1 + src/models/formula/ksat.rs | 70 +- .../formula/maximum_2_satisfiability.rs | 38 +- src/models/formula/nae_satisfiability.rs | 4 +- src/models/formula/non_tautology.rs | 1 + .../formula/one_in_three_satisfiability.rs | 47 +- src/models/formula/planar_3_satisfiability.rs | 47 +- src/models/formula/qbf.rs | 46 +- src/models/formula/sat.rs | 65 +- src/models/graph/acyclic_partition.rs | 75 +- .../balanced_complete_bipartite_subgraph.rs | 48 +- src/models/graph/biclique_cover.rs | 49 +- .../graph/biconnectivity_augmentation.rs | 70 +- .../graph/bottleneck_traveling_salesman.rs | 76 +- .../bounded_component_spanning_forest.rs | 50 +- .../graph/bounded_diameter_spanning_tree.rs | 84 +- .../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 | 66 +- src/models/graph/eulerian_path.rs | 1 + src/models/graph/generalized_hex.rs | 54 +- src/models/graph/graph_partitioning.rs | 1 + src/models/graph/hamiltonian_circuit.rs | 9 +- src/models/graph/hamiltonian_path.rs | 9 +- .../hamiltonian_path_between_two_vertices.rs | 41 +- src/models/graph/highly_connected_deletion.rs | 1 + src/models/graph/integral_flow_bundles.rs | 100 +- .../graph/integral_flow_homologous_arcs.rs | 78 +- .../graph/integral_flow_with_multipliers.rs | 83 +- src/models/graph/isomorphic_spanning_tree.rs | 1 + src/models/graph/kclique.rs | 68 +- src/models/graph/kcoloring.rs | 116 +- src/models/graph/kernel.rs | 1 + src/models/graph/kth_best_spanning_tree.rs | 71 +- .../graph/length_bounded_disjoint_paths.rs | 120 +- src/models/graph/longest_circuit.rs | 75 +- src/models/graph/longest_path.rs | 71 +- src/models/graph/max_cut.rs | 81 +- src/models/graph/maximal_is.rs | 36 +- src/models/graph/maximum_achromatic_number.rs | 9 +- src/models/graph/maximum_clique.rs | 41 +- src/models/graph/maximum_co_k_plex.rs | 43 +- .../graph/maximum_common_edge_subgraph.rs | 1 + .../graph/maximum_contact_map_overlap.rs | 1 + src/models/graph/maximum_domatic_number.rs | 9 +- .../graph/maximum_edge_weighted_k_clique.rs | 45 +- src/models/graph/maximum_independent_set.rs | 176 ++- .../graph/maximum_leaf_spanning_tree.rs | 14 +- src/models/graph/maximum_matching.rs | 72 +- src/models/graph/min_max_multicenter.rs | 106 +- .../minimum_capacitated_spanning_tree.rs | 62 +- src/models/graph/minimum_cost_circulation.rs | 1 + src/models/graph/minimum_cost_maximum_flow.rs | 1 + .../graph/minimum_covering_by_cliques.rs | 9 +- .../graph/minimum_cut_into_bounded_sets.rs | 58 +- src/models/graph/minimum_dominating_set.rs | 109 +- .../graph/minimum_dummy_activities_pert.rs | 40 +- src/models/graph/minimum_edge_cost_flow.rs | 1 + src/models/graph/minimum_feedback_arc_set.rs | 32 +- .../graph/minimum_feedback_vertex_set.rs | 32 +- ...imum_geometric_connected_dominating_set.rs | 1 + src/models/graph/minimum_graph_bandwidth.rs | 1 + .../graph/minimum_intersection_graph_basis.rs | 9 +- src/models/graph/minimum_maximal_matching.rs | 9 +- src/models/graph/minimum_metric_dimension.rs | 1 + src/models/graph/minimum_multiway_cut.rs | 54 +- src/models/graph/minimum_sum_multicenter.rs | 108 +- src/models/graph/minimum_vertex_cover.rs | 84 +- src/models/graph/mixed_chinese_postman.rs | 164 +- src/models/graph/monochromatic_triangle.rs | 1 + src/models/graph/multiple_choice_branching.rs | 62 +- .../graph/multiple_copy_file_allocation.rs | 63 +- .../graph/optimal_linear_arrangement.rs | 10 +- src/models/graph/partial_feedback_edge_set.rs | 28 +- 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 | 162 +- .../graph/prize_collecting_steiner_forest.rs | 98 +- src/models/graph/rooted_tree_arrangement.rs | 32 +- src/models/graph/rural_postman.rs | 76 +- .../graph/shortest_weight_constrained_path.rs | 81 +- src/models/graph/spin_glass.rs | 85 +- src/models/graph/steiner_tree.rs | 72 +- src/models/graph/steiner_tree_in_graphs.rs | 59 +- .../graph/strong_connectivity_augmentation.rs | 1 + src/models/graph/subgraph_isomorphism.rs | 1 + src/models/graph/traveling_salesman.rs | 72 +- .../graph/undirected_flow_lower_bounds.rs | 75 +- .../undirected_two_commodity_integral_flow.rs | 92 +- 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 | 56 +- src/models/misc/capacity_assignment.rs | 63 +- 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 | 95 +- .../misc/conjunctive_query_foldability.rs | 1 + ...onsistency_of_database_frequency_tables.rs | 116 +- 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 | 59 +- .../misc/integer_expression_membership.rs | 1 + src/models/misc/job_shop_scheduling.rs | 68 +- src/models/misc/knapsack.rs | 45 +- src/models/misc/kth_largest_m_tuple.rs | 122 +- src/models/misc/longest_common_subsequence.rs | 59 +- 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 | 60 +- ...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 | 71 +- .../misc/minimum_weight_and_or_graph.rs | 63 +- src/models/misc/multiprocessor_scheduling.rs | 30 +- .../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 | 35 +- .../optimum_communication_spanning_tree.rs | 54 +- src/models/misc/paintshop.rs | 1 + src/models/misc/partially_ordered_knapsack.rs | 75 +- src/models/misc/partition.rs | 1 + .../misc/precedence_constrained_scheduling.rs | 49 +- src/models/misc/preemptive_scheduling.rs | 28 +- src/models/misc/production_planning.rs | 72 +- .../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 | 39 +- .../scheduling_with_individual_deadlines.rs | 52 +- ...ing_to_minimize_maximum_cumulative_cost.rs | 34 +- ...equencing_to_minimize_tardy_task_weight.rs | 37 +- ...ng_to_minimize_weighted_completion_time.rs | 32 +- ...quencing_to_minimize_weighted_tardiness.rs | 45 +- ...uencing_with_deadlines_and_set_up_times.rs | 1 + ...encing_with_release_times_and_deadlines.rs | 1 + .../misc/sequencing_within_intervals.rs | 41 +- .../misc/shortest_common_supersequence.rs | 53 +- .../misc/shortest_common_superstring.rs | 1 + src/models/misc/square_tiling.rs | 1 + src/models/misc/stacker_crane.rs | 90 +- src/models/misc/staff_scheduling.rs | 56 +- .../misc/string_to_string_correction.rs | 65 +- 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 | 29 +- src/models/misc/timetable_design.rs | 101 +- src/models/set/comparative_containment.rs | 99 +- src/models/set/consecutive_sets.rs | 1 + src/models/set/exact_cover_by_3_sets.rs | 44 +- src/models/set/integer_knapsack.rs | 1 + src/models/set/maximum_set_packing.rs | 37 +- src/models/set/minimum_cardinality_key.rs | 1 + src/models/set/minimum_hitting_set.rs | 34 +- src/models/set/minimum_set_covering.rs | 48 +- src/models/set/prime_attribute_name.rs | 56 +- .../set/rooted_tree_storage_assignment.rs | 1 + src/models/set/set_basis.rs | 37 +- 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/random.rs | 237 +++ src/registry/dyn_problem.rs | 15 +- src/registry/info.rs | 8 +- src/registry/mod.rs | 26 +- src/registry/problem_type.rs | 7 +- src/registry/schema.rs | 94 +- src/registry/variant.rs | 196 +++ src/rules/acyclicpartition_ilp.rs | 25 +- src/rules/analysis.rs | 457 +----- .../balancedcompletebipartitesubgraph_ilp.rs | 16 +- src/rules/bicliquecover_bmf.rs | 11 +- src/rules/biconnectivityaugmentation_ilp.rs | 18 +- src/rules/binpacking_ilp.rs | 24 +- src/rules/bmf_bicliquecover.rs | 11 +- src/rules/bmf_ilp.rs | 19 +- src/rules/bottlenecktravelingsalesman_ilp.rs | 55 +- .../boundedcomponentspanningforest_ilp.rs | 27 +- src/rules/capacityassignment_ilp.rs | 25 +- src/rules/circuit_ilp.rs | 24 +- src/rules/circuit_sat.rs | 39 +- src/rules/circuit_spinglass.rs | 25 +- src/rules/closeststring_ilp.rs | 40 +- src/rules/closestsubstring_ilp.rs | 67 +- src/rules/closestvectorproblem_qubo.rs | 41 +- src/rules/clustering_ilp.rs | 36 +- src/rules/coloring_ilp.rs | 33 +- src/rules/coloring_qubo.rs | 21 +- src/rules/consecutiveblockminimization_ilp.rs | 15 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 13 +- src/rules/consecutiveonessubmatrix_ilp.rs | 22 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 50 +- src/rules/cost.rs | 74 - ...imumdominatingset_minimumsummulticenter.rs | 14 +- ...nminimumdominatingset_minmaxmulticenter.rs | 11 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 123 +- src/rules/directedhamiltonianpath_ilp.rs | 21 +- .../directedtwocommodityintegralflow_ilp.rs | 16 +- src/rules/disjointconnectingpaths_ilp.rs | 38 +- src/rules/eulerianpath_ilp.rs | 106 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 18 +- ...overby3sets_boundeddiameterspanningtree.rs | 47 +- src/rules/exactcoverby3sets_ilp.rs | 13 +- .../exactcoverby3sets_maximumsetpacking.rs | 16 +- .../exactcoverby3sets_minimumaxiomset.rs | 28 +- ...verby3sets_minimumfaultdetectiontestset.rs | 22 +- .../exactcoverby3sets_staffscheduling.rs | 19 +- src/rules/exactcoverby3sets_subsetproduct.rs | 16 +- src/rules/expectedretrievalcost_ilp.rs | 24 +- src/rules/factoring_circuit.rs | 65 +- src/rules/factoring_ilp.rs | 43 +- src/rules/feasibleregisterassignment_ilp.rs | 18 +- src/rules/flowshopscheduling_ilp.rs | 47 +- src/rules/graph.rs | 936 ++++++++---- src/rules/graph_helpers.rs | 51 +- src/rules/graphpartitioning_ilp.rs | 13 +- src/rules/graphpartitioning_maxcut.rs | 11 +- src/rules/graphpartitioning_qubo.rs | 13 +- ...oniancircuit_biconnectivityaugmentation.rs | 100 +- ...niancircuit_bottlenecktravelingsalesman.rs | 9 +- .../hamiltoniancircuit_hamiltonianpath.rs | 63 +- .../hamiltoniancircuit_longestcircuit.rs | 9 +- .../hamiltoniancircuit_quadraticassignment.rs | 17 +- src/rules/hamiltoniancircuit_ruralpostman.rs | 94 +- src/rules/hamiltoniancircuit_stackercrane.rs | 19 +- ...ncircuit_strongconnectivityaugmentation.rs | 73 +- .../hamiltoniancircuit_travelingsalesman.rs | 9 +- ...onianpath_degreeconstrainedspanningtree.rs | 39 +- src/rules/hamiltonianpath_ilp.rs | 13 +- .../hamiltonianpath_isomorphicspanningtree.rs | 14 +- ...onianpathbetweentwovertices_longestpath.rs | 76 +- src/rules/highlyconnecteddeletion_ilp.rs | 53 +- src/rules/ilp_bool_ilp_i32.rs | 18 +- src/rules/ilp_helpers.rs | 54 +- src/rules/ilp_i32_ilp_bool.rs | 42 +- src/rules/ilp_qubo.rs | 13 +- src/rules/integerknapsack_ilp.rs | 13 +- src/rules/integralflowbundles_ilp.rs | 13 +- src/rules/integralflowhomologousarcs_ilp.rs | 16 +- src/rules/integralflowwithmultipliers_ilp.rs | 13 +- src/rules/isomorphicspanningtree_ilp.rs | 23 +- ...lique_balancedcompletebipartitesubgraph.rs | 17 +- src/rules/kclique_conjunctivebooleanquery.rs | 14 +- src/rules/kclique_ilp.rs | 16 +- src/rules/kclique_subgraphisomorphism.rs | 13 +- src/rules/kcoloring_bicliquecover.rs | 84 +- src/rules/kcoloring_casts.rs | 1 + src/rules/kcoloring_clustering.rs | 16 +- src/rules/kcoloring_partitionintocliques.rs | 11 +- ...kcoloring_twodimensionalconsecutivesets.rs | 51 +- src/rules/knapsack_ilp.rs | 13 +- src/rules/knapsack_qubo.rs | 13 +- src/rules/ksatisfiability_acyclicpartition.rs | 73 +- src/rules/ksatisfiability_bicliquecover.rs | 39 +- src/rules/ksatisfiability_casts.rs | 6 +- src/rules/ksatisfiability_cyclicordering.rs | 31 +- ...tisfiability_decisionminimumvertexcover.rs | 15 +- ...bility_directedtwocommodityintegralflow.rs | 34 +- ...tisfiability_feasibleregisterassignment.rs | 40 +- src/rules/ksatisfiability_kclique.rs | 53 +- src/rules/ksatisfiability_kernel.rs | 17 +- .../ksatisfiability_minimumvertexcover.rs | 31 +- .../ksatisfiability_monochromatictriangle.rs | 26 +- ...satisfiability_oneinthreesatisfiability.rs | 66 +- .../ksatisfiability_preemptivescheduling.rs | 27 +- .../ksatisfiability_quadraticcongruences.rs | 77 +- ...fiability_quadraticdiophantineequations.rs | 55 +- src/rules/ksatisfiability_qubo.rs | 26 +- .../ksatisfiability_registersufficiency.rs | 54 +- ...atisfiability_simultaneousincongruences.rs | 26 +- src/rules/ksatisfiability_subsetsum.rs | 39 +- src/rules/ksatisfiability_timetabledesign.rs | 121 +- src/rules/lengthboundeddisjointpaths_ilp.rs | 72 +- src/rules/longestcircuit_ilp.rs | 13 +- src/rules/longestcommonsubsequence_ilp.rs | 26 +- ...commonsubsequence_maximumindependentset.rs | 51 +- src/rules/longestpath_ilp.rs | 42 +- src/rules/maxcut_minimumcutintoboundedsets.rs | 11 +- src/rules/maxcut_minimummatrixcover.rs | 11 +- src/rules/maximalis_ilp.rs | 13 +- src/rules/maximum2satisfiability_ilp.rs | 13 +- src/rules/maximum2satisfiability_maxcut.rs | 21 +- src/rules/maximumclique_ilp.rs | 16 +- .../maximumclique_maximumindependentset.rs | 13 +- src/rules/maximumcokplex_ilp.rs | 17 +- src/rules/maximumcommonedgesubgraph_ilp.rs | 30 +- src/rules/maximumcontactmapoverlap_ilp.rs | 29 +- src/rules/maximumdomaticnumber_ilp.rs | 31 +- src/rules/maximumedgeweightedkclique_ilp.rs | 17 +- src/rules/maximumindependentset_casts.rs | 8 + src/rules/maximumindependentset_gridgraph.rs | 11 +- ...ximumindependentset_integralflowbundles.rs | 25 +- .../maximumindependentset_maximumclique.rs | 13 +- ...maximumindependentset_maximumsetpacking.rs | 28 +- src/rules/maximumindependentset_triangular.rs | 15 +- src/rules/maximumleafspanningtree_ilp.rs | 17 +- src/rules/maximumlikelihoodranking_ilp.rs | 49 +- src/rules/maximummatching_ilp.rs | 13 +- .../maximummatching_maximumsetpacking.rs | 11 +- src/rules/maximumsetpacking_casts.rs | 1 + src/rules/maximumsetpacking_ilp.rs | 13 +- src/rules/maximumsetpacking_qubo.rs | 13 +- .../minimumcapacitatedspanningtree_ilp.rs | 20 +- ...mcostmaximumflow_minimumcostcirculation.rs | 11 +- src/rules/minimumcoveringbycliques_ilp.rs | 25 +- ...bycliques_minimumintersectiongraphbasis.rs | 37 +- src/rules/minimumcutintoboundedsets_ilp.rs | 13 +- ...mumdiscreteplanarinversekinematics_qubo.rs | 29 +- src/rules/minimumdominatingset_ilp.rs | 13 +- src/rules/minimumedgecostflow_ilp.rs | 13 +- ...minimumexternalmacrodatacompression_ilp.rs | 131 +- src/rules/minimumfaultdetectiontestset_ilp.rs | 18 +- src/rules/minimumfeedbackarcset_ilp.rs | 13 +- ...feedbackarcset_maximumlikelihoodranking.rs | 21 +- src/rules/minimumfeedbackvertexset_ilp.rs | 13 +- ...minimumcodegenerationunlimitedregisters.rs | 57 +- src/rules/minimumgraphbandwidth_ilp.rs | 25 +- src/rules/minimumhittingset_ilp.rs | 13 +- ...minimuminternalmacrodatacompression_ilp.rs | 110 +- src/rules/minimummatrixcover_ilp.rs | 17 +- src/rules/minimummaximalmatching_ilp.rs | 13 +- ...maximalmatching_maximumachromaticnumber.rs | 19 +- ...maximalmatching_minimummatrixdomination.rs | 220 +-- src/rules/minimummetricdimension_ilp.rs | 13 +- src/rules/minimummultiwaycut_ilp.rs | 17 +- src/rules/minimummultiwaycut_qubo.rs | 54 +- src/rules/minimumsetcovering_ilp.rs | 13 +- src/rules/minimumsummulticenter_ilp.rs | 16 +- src/rules/minimumtardinesssequencing_ilp.rs | 48 +- ...nimumvertexcover_comparativecontainment.rs | 37 +- .../minimumvertexcover_ensemblecomputation.rs | 55 +- ...mumvertexcover_longestcommonsubsequence.rs | 25 +- ...inimumvertexcover_maximumindependentset.rs | 22 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 13 +- ...mumvertexcover_minimumfeedbackvertexset.rs | 11 +- .../minimumvertexcover_minimumhittingset.rs | 11 +- ...nimumvertexcover_minimummaximalmatching.rs | 26 +- .../minimumvertexcover_minimumsetcovering.rs | 11 +- ...imumvertexcover_minimumweightandorgraph.rs | 17 +- src/rules/minimumweightdecoding_ilp.rs | 13 +- src/rules/minmaxmulticenter_ilp.rs | 13 +- src/rules/mixedchinesepostman_ilp.rs | 20 +- src/rules/mod.rs | 153 +- src/rules/monochromatictriangle_ilp.rs | 13 +- src/rules/multiplecopyfileallocation_ilp.rs | 13 +- src/rules/multiprocessorscheduling_ilp.rs | 25 +- src/rules/naesatisfiability_ilp.rs | 13 +- src/rules/naesatisfiability_maxcut.rs | 17 +- ...fiability_partitionintoperfectmatchings.rs | 21 +- src/rules/naesatisfiability_setsplitting.rs | 17 +- ...atching_numericalmatchingwithtargetsums.rs | 70 +- .../numericalmatchingwithtargetsums_ilp.rs | 28 +- src/rules/openshopscheduling_ilp.rs | 52 +- ...ement_consecutiveonesmatrixaugmentation.rs | 61 +- src/rules/optimallineararrangement_ilp.rs | 25 +- ...uencingtominimizeweightedcompletiontime.rs | 45 +- .../optimumcommunicationspanningtree_ilp.rs | 13 +- src/rules/paintshop_ilp.rs | 18 +- src/rules/paintshop_qubo.rs | 13 +- src/rules/partiallyorderedknapsack_ilp.rs | 13 +- src/rules/partition_binpacking.rs | 32 +- .../partition_cosineproductintegration.rs | 16 +- .../partition_integralflowwithmultipliers.rs | 46 +- src/rules/partition_knapsack.rs | 15 +- .../partition_multiprocessorscheduling.rs | 16 +- src/rules/partition_openshopscheduling.rs | 145 +- src/rules/partition_productionplanning.rs | 19 +- ...ion_sequencingtominimizetardytaskweight.rs | 65 +- src/rules/partition_subsetsum.rs | 28 +- src/rules/partition_sumofsquarespartition.rs | 30 +- ...ionintocliques_minimumcoveringbycliques.rs | 113 +- ...flength2_boundedcomponentspanningforest.rs | 11 +- src/rules/partitionintopathsoflength2_ilp.rs | 30 +- src/rules/partitionintotriangles_ilp.rs | 30 +- src/rules/pathconstrainednetworkflow_ilp.rs | 13 +- .../precedenceconstrainedscheduling_ilp.rs | 28 +- src/rules/preemptivescheduling_ilp.rs | 17 +- ...rizecollectingsteinerforest_steinertree.rs | 73 +- src/rules/quadraticassignment_ilp.rs | 27 +- src/rules/qubo_ilp.rs | 15 +- .../rectilinearpicturecompression_ilp.rs | 16 +- src/rules/registersufficiency_ilp.rs | 18 +- src/rules/registry.rs | 253 +-- .../resourceconstrainedscheduling_ilp.rs | 30 +- ...arrangement_rootedtreestorageassignment.rs | 25 +- src/rules/rootedtreestorageassignment_ilp.rs | 26 +- src/rules/ruralpostman_ilp.rs | 17 +- src/rules/sat_circuitsat.rs | 23 +- src/rules/sat_coloring.rs | 78 +- src/rules/sat_helpers.rs | 66 + src/rules/sat_ksat.rs | 83 +- src/rules/sat_maximumindependentset.rs | 39 +- src/rules/sat_minimumdominatingset.rs | 71 +- ...tisfiability_integralflowhomologousarcs.rs | 34 +- .../satisfiability_maximum2satisfiability.rs | 54 +- src/rules/satisfiability_naesatisfiability.rs | 45 +- src/rules/satisfiability_nontautology.rs | 18 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 20 +- .../schedulingwithindividualdeadlines_ilp.rs | 21 +- ...cingtominimizemaximumcumulativecost_ilp.rs | 25 +- ...sequencingtominimizetardytaskweight_ilp.rs | 28 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 24 +- ...quencingtominimizeweightedtardiness_ilp.rs | 29 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 25 +- src/rules/sequencingwithinintervals_ilp.rs | 32 +- ...uencingwithreleasetimesanddeadlines_ilp.rs | 44 +- src/rules/setsplitting_betweenness.rs | 24 +- src/rules/setsplitting_ilp.rs | 13 +- src/rules/shortestcommonsupersequence_ilp.rs | 31 +- .../shortestweightconstrainedpath_ilp.rs | 42 +- src/rules/sparsematrixcompression_ilp.rs | 30 +- src/rules/spinglass_maxcut.rs | 46 +- src/rules/spinglass_qubo.rs | 25 +- src/rules/stackercrane_ilp.rs | 17 +- src/rules/steinertree_ilp.rs | 13 +- src/rules/steinertreeingraphs_ilp.rs | 13 +- src/rules/stringtostringcorrection_ilp.rs | 89 +- .../strongconnectivityaugmentation_ilp.rs | 15 +- src/rules/subgraphisomorphism_ilp.rs | 30 +- src/rules/subsetsum_closestvectorproblem.rs | 11 +- .../subsetsum_integerexpressionmembership.rs | 22 +- src/rules/subsetsum_integerknapsack.rs | 39 +- src/rules/subsetsum_partition.rs | 52 +- src/rules/sumofsquarespartition_ilp.rs | 28 +- src/rules/test_helpers.rs | 49 +- src/rules/threedimensionalmatching_ilp.rs | 13 +- ...mensionalmatching_minimumweightdecoding.rs | 32 +- ...sionalmatching_threematroidintersection.rs | 20 +- ...threedimensionalmatching_threepartition.rs | 118 +- ...partition_resourceconstrainedscheduling.rs | 16 +- ..._sequencingwithreleasetimesanddeadlines.rs | 62 +- src/rules/timetabledesign_ilp.rs | 18 +- src/rules/traits.rs | 73 +- src/rules/travelingsalesman_ilp.rs | 63 +- src/rules/travelingsalesman_qubo.rs | 48 +- src/rules/undirectedflowlowerbounds_ilp.rs | 25 +- .../undirectedtwocommodityintegralflow_ilp.rs | 15 +- src/size.rs | 625 ++++++++ src/solvers/brute_force.rs | 11 +- src/solvers/customized/mod.rs | 11 - src/solvers/decision_search.rs | 22 +- src/solvers/ilp/mod.rs | 3 +- src/solvers/ilp/solver.rs | 245 +-- src/solvers/mod.rs | 18 +- .../fd_subset_search.rs | 0 src/solvers/native/mod.rs | 9 + .../partial_feedback_edge_set.rs | 0 .../rooted_tree_arrangement.rs | 0 src/solvers/{customized => native}/solver.rs | 116 +- src/solvers/pipelines.rs | 824 ++++++++++ src/solvers/registry.rs | 385 +++++ src/solvers/resolver.rs | 149 ++ src/types.rs | 22 +- src/unit_tests/big_o.rs | 47 +- src/unit_tests/canonical.rs | 165 -- src/unit_tests/example_db.rs | 94 +- src/unit_tests/export.rs | 23 +- src/unit_tests/expr.rs | 538 ++++--- src/unit_tests/growth.rs | 1131 ++++++++++++++ .../algebraic/closest_vector_problem.rs | 11 + .../consecutive_block_minimization.rs | 14 + .../consecutive_ones_matrix_augmentation.rs | 15 + .../algebraic/feasible_basis_extension.rs | 19 + .../algebraic/minimum_weight_decoding.rs | 10 + ...mum_weight_solution_to_linear_equations.rs | 11 + src/unit_tests/models/algebraic/qubo.rs | 12 + .../algebraic/sparse_matrix_compression.rs | 10 + src/unit_tests/models/decision.rs | 44 + .../formula/one_in_three_satisfiability.rs | 2 +- .../models/formula/planar_3_satisfiability.rs | 2 +- src/unit_tests/models/formula/qbf.rs | 4 +- src/unit_tests/models/formula/sat.rs | 2 +- .../models/graph/acyclic_partition.rs | 28 + .../balanced_complete_bipartite_subgraph.rs | 23 + src/unit_tests/models/graph/biclique_cover.rs | 71 + .../graph/biconnectivity_augmentation.rs | 12 + .../graph/bottleneck_traveling_salesman.rs | 14 + .../bounded_component_spanning_forest.rs | 19 + .../graph/bounded_diameter_spanning_tree.rs | 16 + .../models/graph/disjoint_connecting_paths.rs | 11 + .../models/graph/generalized_hex.rs | 12 + .../models/graph/integral_flow_bundles.rs | 15 + .../graph/integral_flow_homologous_arcs.rs | 14 + .../graph/integral_flow_with_multipliers.rs | 15 + src/unit_tests/models/graph/kclique.rs | 9 + src/unit_tests/models/graph/kcoloring.rs | 19 + .../models/graph/kth_best_spanning_tree.rs | 16 + .../graph/length_bounded_disjoint_paths.rs | 13 + .../models/graph/longest_circuit.rs | 11 + src/unit_tests/models/graph/longest_path.rs | 11 + src/unit_tests/models/graph/max_cut.rs | 20 +- src/unit_tests/models/graph/maximal_is.rs | 12 +- src/unit_tests/models/graph/maximum_clique.rs | 10 + .../models/graph/maximum_co_k_plex.rs | 13 + .../graph/maximum_edge_weighted_k_clique.rs | 11 + .../models/graph/maximum_independent_set.rs | 12 +- .../models/graph/maximum_matching.rs | 13 +- .../models/graph/min_max_multicenter.rs | 27 + .../minimum_capacitated_spanning_tree.rs | 13 + .../graph/minimum_cut_into_bounded_sets.rs | 13 + .../models/graph/minimum_dominating_set.rs | 15 +- .../graph/minimum_dummy_activities_pert.rs | 12 + .../models/graph/minimum_feedback_arc_set.rs | 10 + .../graph/minimum_feedback_vertex_set.rs | 12 +- .../models/graph/minimum_multiway_cut.rs | 11 + .../models/graph/minimum_sum_multicenter.rs | 18 + .../models/graph/minimum_vertex_cover.rs | 15 +- .../models/graph/mixed_chinese_postman.rs | 15 + .../models/graph/multiple_choice_branching.rs | 16 + .../graph/multiple_copy_file_allocation.rs | 12 + .../models/graph/partial_feedback_edge_set.rs | 15 + .../graph/path_constrained_network_flow.rs | 15 + .../graph/prize_collecting_steiner_forest.rs | 29 + src/unit_tests/models/graph/rural_postman.rs | 12 + .../graph/shortest_weight_constrained_path.rs | 17 + src/unit_tests/models/graph/spin_glass.rs | 15 +- src/unit_tests/models/graph/steiner_tree.rs | 11 + .../models/graph/steiner_tree_in_graphs.rs | 11 + .../models/graph/traveling_salesman.rs | 11 + .../graph/undirected_flow_lower_bounds.rs | 19 + .../undirected_two_commodity_integral_flow.rs | 19 + .../misc/boyce_codd_normal_form_violation.rs | 16 + .../models/misc/capacity_assignment.rs | 12 + .../models/misc/conjunctive_boolean_query.rs | 33 + ...onsistency_of_database_frequency_tables.rs | 14 + .../models/misc/grouping_by_swapping.rs | 31 + .../models/misc/job_shop_scheduling.rs | 33 + src/unit_tests/models/misc/knapsack.rs | 11 + .../models/misc/kth_largest_m_tuple.rs | 113 +- .../models/misc/longest_common_subsequence.rs | 29 + .../models/misc/minimum_decision_tree.rs | 12 + .../misc/minimum_tardiness_sequencing.rs | 18 + .../misc/minimum_weight_and_or_graph.rs | 13 + .../models/misc/multiprocessor_scheduling.rs | 16 + .../models/misc/open_shop_scheduling.rs | 14 + .../optimum_communication_spanning_tree.rs | 12 + .../models/misc/partially_ordered_knapsack.rs | 12 + .../misc/precedence_constrained_scheduling.rs | 13 + .../models/misc/preemptive_scheduling.rs | 11 + .../models/misc/production_planning.rs | 15 + ...ng_to_minimize_weighted_completion_time.rs | 13 + .../scheduling_with_individual_deadlines.rs | 30 + ...ing_to_minimize_maximum_cumulative_cost.rs | 11 + ...equencing_to_minimize_tardy_task_weight.rs | 13 + ...ng_to_minimize_weighted_completion_time.rs | 13 + ...quencing_to_minimize_weighted_tardiness.rs | 17 + .../misc/sequencing_within_intervals.rs | 16 + .../misc/shortest_common_supersequence.rs | 51 + src/unit_tests/models/misc/stacker_crane.rs | 15 + .../models/misc/staff_scheduling.rs | 13 + .../misc/string_to_string_correction.rs | 34 + src/unit_tests/models/misc/three_partition.rs | 14 + .../models/misc/timetable_design.rs | 34 +- .../models/set/comparative_containment.rs | 23 + .../models/set/exact_cover_by_3_sets.rs | 9 + .../models/set/maximum_set_packing.rs | 17 +- .../models/set/minimum_hitting_set.rs | 11 + .../models/set/minimum_set_covering.rs | 15 +- .../models/set/prime_attribute_name.rs | 15 + src/unit_tests/models/set/set_basis.rs | 12 + src/unit_tests/reduction_graph.rs | 401 ++--- src/unit_tests/registry/problem_type.rs | 49 +- src/unit_tests/registry/schema.rs | 18 +- src/unit_tests/registry/variant.rs | 244 ++- src/unit_tests/rules/acyclicpartition_ilp.rs | 6 +- src/unit_tests/rules/analysis.rs | 345 +---- .../balancedcompletebipartitesubgraph_ilp.rs | 4 +- src/unit_tests/rules/bicliquecover_bmf.rs | 19 +- .../rules/biconnectivityaugmentation_ilp.rs | 8 +- src/unit_tests/rules/binpacking_ilp.rs | 12 +- src/unit_tests/rules/bmf_bicliquecover.rs | 4 +- .../rules/bottlenecktravelingsalesman_ilp.rs | 8 +- .../boundedcomponentspanningforest_ilp.rs | 8 +- .../rules/capacityassignment_ilp.rs | 6 +- src/unit_tests/rules/circuit_ilp.rs | 2 +- src/unit_tests/rules/circuit_sat.rs | 2 +- src/unit_tests/rules/circuit_spinglass.rs | 6 +- src/unit_tests/rules/closeststring_ilp.rs | 21 +- src/unit_tests/rules/closestsubstring_ilp.rs | 21 +- .../rules/closestvectorproblem_qubo.rs | 10 +- src/unit_tests/rules/clustering_ilp.rs | 6 +- src/unit_tests/rules/coloring_ilp.rs | 22 +- src/unit_tests/rules/coloring_qubo.rs | 6 +- .../rules/consecutiveblockminimization_ilp.rs | 2 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 4 +- .../rules/consecutiveonessubmatrix_ilp.rs | 6 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 12 +- src/unit_tests/rules/cost.rs | 86 -- ...imumdominatingset_minimumsummulticenter.rs | 9 +- ...nminimumdominatingset_minmaxmulticenter.rs | 4 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 8 +- .../rules/directedhamiltonianpath_ilp.rs | 6 +- .../directedtwocommodityintegralflow_ilp.rs | 8 +- src/unit_tests/rules/eulerianpath_ilp.rs | 8 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 5 +- ...overby3sets_boundeddiameterspanningtree.rs | 6 +- src/unit_tests/rules/exactcoverby3sets_ilp.rs | 4 +- .../exactcoverby3sets_maximumsetpacking.rs | 4 +- .../exactcoverby3sets_minimumaxiomset.rs | 6 +- ...verby3sets_minimumfaultdetectiontestset.rs | 7 +- .../exactcoverby3sets_staffscheduling.rs | 8 +- .../rules/exactcoverby3sets_subsetproduct.rs | 5 +- .../rules/expectedretrievalcost_ilp.rs | 6 +- src/unit_tests/rules/factoring_circuit.rs | 2 +- src/unit_tests/rules/factoring_ilp.rs | 22 +- .../rules/feasibleregisterassignment_ilp.rs | 4 +- .../rules/flowshopscheduling_ilp.rs | 8 +- src/unit_tests/rules/graph.rs | 993 ++++++------ src/unit_tests/rules/graphpartitioning_ilp.rs | 11 +- .../rules/graphpartitioning_maxcut.rs | 2 +- ...oniancircuit_biconnectivityaugmentation.rs | 2 +- ...niancircuit_bottlenecktravelingsalesman.rs | 2 +- .../hamiltoniancircuit_hamiltonianpath.rs | 4 +- .../hamiltoniancircuit_longestcircuit.rs | 2 +- .../hamiltoniancircuit_quadraticassignment.rs | 7 +- .../rules/hamiltoniancircuit_ruralpostman.rs | 4 +- .../rules/hamiltoniancircuit_stackercrane.rs | 2 +- ...ncircuit_strongconnectivityaugmentation.rs | 2 +- .../hamiltoniancircuit_travelingsalesman.rs | 2 +- ...onianpath_degreeconstrainedspanningtree.rs | 2 +- src/unit_tests/rules/hamiltonianpath_ilp.rs | 8 +- .../hamiltonianpath_isomorphicspanningtree.rs | 2 +- .../rules/highlyconnecteddeletion_ilp.rs | 17 +- src/unit_tests/rules/ilp_bool_ilp_i32.rs | 2 +- src/unit_tests/rules/ilp_helpers.rs | 21 +- src/unit_tests/rules/ilp_i32_ilp_bool.rs | 2 +- src/unit_tests/rules/ilp_qubo.rs | 18 +- src/unit_tests/rules/integerknapsack_ilp.rs | 4 +- .../rules/integralflowbundles_ilp.rs | 6 +- .../rules/integralflowhomologousarcs_ilp.rs | 2 +- .../rules/integralflowwithmultipliers_ilp.rs | 2 +- .../rules/isomorphicspanningtree_ilp.rs | 4 +- ...lique_balancedcompletebipartitesubgraph.rs | 6 +- .../rules/kclique_conjunctivebooleanquery.rs | 4 +- src/unit_tests/rules/kclique_ilp.rs | 4 +- .../rules/kclique_subgraphisomorphism.rs | 6 +- .../rules/kcoloring_bicliquecover.rs | 10 +- src/unit_tests/rules/kcoloring_clustering.rs | 7 +- .../rules/kcoloring_partitionintocliques.rs | 2 +- ...kcoloring_twodimensionalconsecutivesets.rs | 2 +- src/unit_tests/rules/knapsack_ilp.rs | 8 +- src/unit_tests/rules/knapsack_qubo.rs | 6 +- .../rules/ksatisfiability_acyclicpartition.rs | 13 +- .../rules/ksatisfiability_bicliquecover.rs | 22 +- .../rules/ksatisfiability_cyclicordering.rs | 9 +- ...tisfiability_decisionminimumvertexcover.rs | 2 +- ...bility_directedtwocommodityintegralflow.rs | 13 +- ...tisfiability_feasibleregisterassignment.rs | 10 +- .../rules/ksatisfiability_kclique.rs | 8 +- .../rules/ksatisfiability_kernel.rs | 2 +- .../ksatisfiability_minimumvertexcover.rs | 2 +- .../ksatisfiability_monochromatictriangle.rs | 9 +- ...satisfiability_oneinthreesatisfiability.rs | 2 +- .../ksatisfiability_preemptivescheduling.rs | 14 +- .../ksatisfiability_quadraticcongruences.rs | 15 +- ...fiability_quadraticdiophantineequations.rs | 4 +- src/unit_tests/rules/ksatisfiability_qubo.rs | 12 +- .../ksatisfiability_registersufficiency.rs | 7 +- ...atisfiability_simultaneousincongruences.rs | 4 +- .../rules/ksatisfiability_subsetsum.rs | 8 +- .../rules/ksatisfiability_timetabledesign.rs | 15 +- src/unit_tests/rules/longestcircuit_ilp.rs | 4 +- .../rules/longestcommonsubsequence_ilp.rs | 10 +- ...commonsubsequence_maximumindependentset.rs | 2 +- src/unit_tests/rules/longestpath_ilp.rs | 6 +- .../rules/maxcut_minimumcutintoboundedsets.rs | 2 +- .../rules/maxcut_minimummatrixcover.rs | 6 +- src/unit_tests/rules/maximalis_ilp.rs | 4 +- .../rules/maximum2satisfiability_ilp.rs | 6 +- .../rules/maximum2satisfiability_maxcut.rs | 16 +- src/unit_tests/rules/maximumclique_ilp.rs | 16 +- .../maximumclique_maximumindependentset.rs | 4 +- src/unit_tests/rules/maximumcokplex_ilp.rs | 4 +- .../rules/maximumcommonedgesubgraph_ilp.rs | 8 +- .../rules/maximumcontactmapoverlap_ilp.rs | 8 +- .../rules/maximumdomaticnumber_ilp.rs | 8 +- .../rules/maximumedgeweightedkclique_ilp.rs | 2 +- .../rules/maximumindependentset_gridgraph.rs | 2 +- .../rules/maximumindependentset_ilp.rs | 23 +- ...ximumindependentset_integralflowbundles.rs | 10 +- .../maximumindependentset_maximumclique.rs | 2 +- ...maximumindependentset_maximumsetpacking.rs | 4 +- .../rules/maximumindependentset_qubo.rs | 28 +- .../rules/maximumindependentset_triangular.rs | 2 +- .../rules/maximumleafspanningtree_ilp.rs | 14 +- .../rules/maximumlikelihoodranking_ilp.rs | 8 +- src/unit_tests/rules/maximummatching_ilp.rs | 16 +- .../maximummatching_maximumsetpacking.rs | 2 +- .../rules/maximumsetpacking_casts.rs | 4 +- src/unit_tests/rules/maximumsetpacking_ilp.rs | 10 +- .../rules/maximumsetpacking_qubo.rs | 6 +- .../minimumcapacitatedspanningtree_ilp.rs | 10 +- ...mcostmaximumflow_minimumcostcirculation.rs | 10 +- .../rules/minimumcoveringbycliques_ilp.rs | 7 +- ...bycliques_minimumintersectiongraphbasis.rs | 17 +- .../rules/minimumcutintoboundedsets_ilp.rs | 2 +- ...mumdiscreteplanarinversekinematics_qubo.rs | 9 +- .../rules/minimumdominatingset_ilp.rs | 16 +- .../rules/minimumedgecostflow_ilp.rs | 8 +- ...minimumexternalmacrodatacompression_ilp.rs | 10 +- .../rules/minimumfaultdetectiontestset_ilp.rs | 6 +- .../rules/minimumfeedbackarcset_ilp.rs | 6 +- ...feedbackarcset_maximumlikelihoodranking.rs | 2 +- .../rules/minimumfeedbackvertexset_ilp.rs | 14 +- .../rules/minimumgraphbandwidth_ilp.rs | 4 +- src/unit_tests/rules/minimumhittingset_ilp.rs | 4 +- ...minimuminternalmacrodatacompression_ilp.rs | 12 +- .../rules/minimummatrixcover_ilp.rs | 12 +- .../rules/minimummaximalmatching_ilp.rs | 6 +- ...maximalmatching_maximumachromaticnumber.rs | 8 +- ...maximalmatching_minimummatrixdomination.rs | 6 +- .../rules/minimummetricdimension_ilp.rs | 10 +- .../rules/minimummultiwaycut_ilp.rs | 10 +- .../rules/minimummultiwaycut_qubo.rs | 4 +- .../rules/minimumsetcovering_ilp.rs | 12 +- .../rules/minimumsummulticenter_ilp.rs | 8 +- .../rules/minimumtardinesssequencing_ilp.rs | 8 +- ...nimumvertexcover_comparativecontainment.rs | 8 +- .../minimumvertexcover_ensemblecomputation.rs | 6 +- .../rules/minimumvertexcover_ilp.rs | 23 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 2 +- ...mumvertexcover_minimumfeedbackvertexset.rs | 2 +- .../minimumvertexcover_minimumhittingset.rs | 2 +- ...imumvertexcover_minimumweightandorgraph.rs | 5 +- .../rules/minimumvertexcover_qubo.rs | 36 +- .../rules/minimumweightdecoding_ilp.rs | 8 +- src/unit_tests/rules/minmaxmulticenter_ilp.rs | 8 +- .../rules/mixedchinesepostman_ilp.rs | 6 +- .../rules/monochromatictriangle_ilp.rs | 6 +- .../rules/multiplecopyfileallocation_ilp.rs | 6 +- .../rules/multiprocessorscheduling_ilp.rs | 6 +- src/unit_tests/rules/naesatisfiability_ilp.rs | 6 +- .../rules/naesatisfiability_maxcut.rs | 2 +- ...fiability_partitionintoperfectmatchings.rs | 4 +- .../rules/naesatisfiability_setsplitting.rs | 4 +- ...atching_numericalmatchingwithtargetsums.rs | 4 +- .../numericalmatchingwithtargetsums_ilp.rs | 8 +- .../rules/openshopscheduling_ilp.rs | 10 +- ...ement_consecutiveonesmatrixaugmentation.rs | 23 +- .../rules/optimallineararrangement_ilp.rs | 6 +- ...uencingtominimizeweightedcompletiontime.rs | 2 +- .../optimumcommunicationspanningtree_ilp.rs | 6 +- src/unit_tests/rules/paintshop_ilp.rs | 4 +- src/unit_tests/rules/paintshop_qubo.rs | 2 +- .../rules/partiallyorderedknapsack_ilp.rs | 4 +- src/unit_tests/rules/partition_binpacking.rs | 2 +- .../partition_cosineproductintegration.rs | 2 +- .../partition_integralflowwithmultipliers.rs | 9 +- src/unit_tests/rules/partition_knapsack.rs | 2 +- .../partition_multiprocessorscheduling.rs | 2 +- .../rules/partition_openshopscheduling.rs | 4 +- .../rules/partition_productionplanning.rs | 2 +- ...ion_sequencingtominimizetardytaskweight.rs | 4 +- src/unit_tests/rules/partition_subsetsum.rs | 19 +- .../rules/partition_sumofsquarespartition.rs | 12 +- ...ionintocliques_minimumcoveringbycliques.rs | 17 +- ...flength2_boundedcomponentspanningforest.rs | 2 +- .../rules/partitionintopathsoflength2_ilp.rs | 6 +- .../rules/partitionintotriangles_ilp.rs | 6 +- .../rules/pathconstrainednetworkflow_ilp.rs | 2 +- .../precedenceconstrainedscheduling_ilp.rs | 6 +- .../rules/preemptivescheduling_ilp.rs | 18 +- ...rizecollectingsteinerforest_steinertree.rs | 6 +- .../rules/quadraticassignment_ilp.rs | 8 +- src/unit_tests/rules/qubo_ilp.rs | 6 +- .../rectilinearpicturecompression_ilp.rs | 4 +- src/unit_tests/rules/reduction_path_parity.rs | 94 +- .../rules/registersufficiency_ilp.rs | 6 +- src/unit_tests/rules/registry.rs | 577 ++----- .../resourceconstrainedscheduling_ilp.rs | 4 +- ...arrangement_rootedtreestorageassignment.rs | 2 +- .../rules/rootedtreestorageassignment_ilp.rs | 13 +- src/unit_tests/rules/ruralpostman_ilp.rs | 4 +- src/unit_tests/rules/sat_circuitsat.rs | 2 +- src/unit_tests/rules/sat_coloring.rs | 14 +- src/unit_tests/rules/sat_helpers.rs | 28 + src/unit_tests/rules/sat_ksat.rs | 8 +- .../rules/sat_maximumindependentset.rs | 8 +- .../rules/sat_minimumdominatingset.rs | 52 +- ...tisfiability_integralflowhomologousarcs.rs | 2 +- .../satisfiability_maximum2satisfiability.rs | 2 +- .../rules/satisfiability_naesatisfiability.rs | 19 +- .../rules/satisfiability_nontautology.rs | 2 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 10 +- .../schedulingwithindividualdeadlines_ilp.rs | 6 +- ...cingtominimizemaximumcumulativecost_ilp.rs | 6 +- ...sequencingtominimizetardytaskweight_ilp.rs | 6 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 10 +- ...quencingtominimizeweightedtardiness_ilp.rs | 8 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 14 +- .../rules/sequencingwithinintervals_ilp.rs | 6 +- ...uencingwithreleasetimesanddeadlines_ilp.rs | 6 +- .../rules/setsplitting_betweenness.rs | 4 +- src/unit_tests/rules/setsplitting_ilp.rs | 6 +- .../rules/shortestcommonsupersequence_ilp.rs | 6 +- .../shortestweightconstrainedpath_ilp.rs | 10 +- .../rules/sparsematrixcompression_ilp.rs | 2 +- src/unit_tests/rules/spinglass_maxcut.rs | 6 +- src/unit_tests/rules/steinertree_ilp.rs | 6 +- .../rules/stringtostringcorrection_ilp.rs | 8 +- .../strongconnectivityaugmentation_ilp.rs | 8 +- .../rules/subgraphisomorphism_ilp.rs | 8 +- .../subsetsum_integerexpressionmembership.rs | 9 +- src/unit_tests/rules/subsetsum_partition.rs | 15 +- .../rules/sumofsquarespartition_ilp.rs | 6 +- .../rules/threedimensionalmatching_ilp.rs | 28 +- ...mensionalmatching_minimumweightdecoding.rs | 8 +- ...sionalmatching_threematroidintersection.rs | 15 +- ...threedimensionalmatching_threepartition.rs | 6 +- ...partition_resourceconstrainedscheduling.rs | 2 +- ..._sequencingwithreleasetimesanddeadlines.rs | 2 +- src/unit_tests/rules/timetabledesign_ilp.rs | 6 +- src/unit_tests/rules/traits.rs | 23 +- src/unit_tests/rules/travelingsalesman_ilp.rs | 12 +- .../rules/travelingsalesman_qubo.rs | 4 +- .../rules/undirectedflowlowerbounds_ilp.rs | 6 +- .../undirectedtwocommodityintegralflow_ilp.rs | 25 +- src/unit_tests/size.rs | 104 ++ src/unit_tests/solvers/brute_force.rs | 38 + src/unit_tests/solvers/ilp/solver.rs | 129 +- .../solvers/{customized => native}/solver.rs | 126 +- src/unit_tests/solvers/registry.rs | 356 +++++ src/unit_tests/solvers/resolver.rs | 238 +++ src/unit_tests/symbolic_size_contracts.rs | 82 + tests/main.rs | 3 +- tests/suites/examples.rs | 86 +- tests/suites/numeric_boundaries.rs | 104 ++ 889 files changed, 26313 insertions(+), 10956 deletions(-) create mode 100644 problemreductions-expr/Cargo.toml create mode 100644 problemreductions-expr/src/lib.rs create mode 100644 problemreductions-expr/tests/fixtures/sympy_oracle.json create mode 100644 problemreductions-expr/tests/sympy_fixture.rs create mode 100644 problemreductions-macros/src/expr_codegen.rs delete mode 100644 problemreductions-macros/src/parser.rs create mode 100644 scripts/generate_symbolic_expr_fixture.py delete mode 100644 src/canonical.rs create mode 100644 src/growth.rs create mode 100644 src/random.rs delete mode 100644 src/rules/cost.rs create mode 100644 src/rules/sat_helpers.rs create mode 100644 src/size.rs delete mode 100644 src/solvers/customized/mod.rs rename src/solvers/{customized => native}/fd_subset_search.rs (100%) create mode 100644 src/solvers/native/mod.rs rename src/solvers/{customized => native}/partial_feedback_edge_set.rs (100%) rename src/solvers/{customized => native}/rooted_tree_arrangement.rs (100%) rename src/solvers/{customized => native}/solver.rs (72%) create mode 100644 src/solvers/pipelines.rs create mode 100644 src/solvers/registry.rs create mode 100644 src/solvers/resolver.rs delete mode 100644 src/unit_tests/canonical.rs create mode 100644 src/unit_tests/growth.rs delete mode 100644 src/unit_tests/rules/cost.rs create mode 100644 src/unit_tests/rules/sat_helpers.rs create mode 100644 src/unit_tests/size.rs rename src/unit_tests/solvers/{customized => native}/solver.rs (76%) create mode 100644 src/unit_tests/solvers/registry.rs create mode 100644 src/unit_tests/solvers/resolver.rs create mode 100644 src/unit_tests/symbolic_size_contracts.rs create mode 100644 tests/suites/numeric_boundaries.rs diff --git a/Cargo.toml b/Cargo.toml index 3b0066232..781b9385e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = [".", "problemreductions-macros", "problemreductions-cli"] +members = [ + ".", + "problemreductions-expr", + "problemreductions-macros", + "problemreductions-cli", +] [package] name = "problemreductions" @@ -12,13 +17,8 @@ keywords = ["np-hard", "optimization", "reduction", "sat", "graph"] categories = ["algorithms", "science"] [features] -default = ["ilp-highs"] example-db = [] -ilp = ["ilp-highs"] # backward compat shorthand -ilp-solver = [] # marker: enables ILP solver code -ilp-highs = ["ilp-solver", "dep:good_lp", "good_lp/highs"] -ilp-cplex = ["ilp-solver", "dep:good_lp", "good_lp/cplex-rs"] -ilp-lp-solvers = ["ilp-solver", "dep:good_lp", "good_lp/lp-solvers"] +benchmarks = ["dep:criterion"] [dependencies] petgraph = { version = "0.8", features = ["serde-1"] } @@ -27,26 +27,36 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" num-bigint = "0.4" +num-rational = "0.4" num-traits = "0.2" -good_lp = { version = "=1.14.2", default-features = false, optional = true } +good_lp = { version = "=1.14.2", default-features = false, features = ["highs"] } inventory = "0.3" ordered-float = "5.0" rand = "0.10" +criterion = { version = "0.8", optional = true } problemreductions-macros = { version = "0.6.0", path = "problemreductions-macros" } +problemreductions-expr = { version = "0.6.0", path = "problemreductions-expr" } [dev-dependencies] proptest = "1.0" -criterion = "0.8" [[bench]] name = "solver_benchmarks" harness = false +required-features = ["benchmarks"] [[example]] name = "export_examples" path = "examples/export_examples.rs" required-features = ["example-db"] +[profile.dev] +debug = "line-tables-only" + +[profile.debug-dev] +inherits = "dev" +debug = "full" + [profile.release] lto = true codegen-units = 1 diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index 234f607fd..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" @@ -15,16 +16,12 @@ name = "pred-sym" path = "src/bin/pred_sym.rs" [features] -default = ["highs"] -all = ["highs", "mcp"] -highs = ["problemreductions/ilp-highs"] +all = ["mcp"] mcp = ["dep:rmcp", "dep:tokio", "dep:schemars", "dep:tracing", "dep:tracing-subscriber"] -cplex = ["problemreductions/ilp-cplex"] -lp-solvers = ["problemreductions/ilp-lp-solvers"] [dependencies] -problemreductions = { version = "0.6.0", path = "..", default-features = false, features = ["example-db"] } -clap = { version = "4", features = ["derive"] } +problemreductions = { version = "0.6.0", path = "..", features = ["example-db"] } +clap = { version = "4", features = ["derive", "string"] } anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/problemreductions-expr/Cargo.toml b/problemreductions-expr/Cargo.toml new file mode 100644 index 000000000..d19a1b770 --- /dev/null +++ b/problemreductions-expr/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "problemreductions-expr" +version = "0.6.0" +edition = "2021" +description = "Lossless symbolic expressions for problemreductions" +license = "MIT" +repository = "https://github.com/CodingThrust/problem-reductions" + +[dependencies] +num-bigint = { version = "0.4", features = ["serde"] } +num-rational = { version = "0.4", features = ["serde"] } +num-traits = "0.2" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" + +[dev-dependencies] +serde_json = "1.0" diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs new file mode 100644 index 000000000..34a3d13de --- /dev/null +++ b/problemreductions-expr/src/lib.rs @@ -0,0 +1,1356 @@ +//! Lossless symbolic expressions shared by the runtime library and proc macros. + +use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{One, Signed, Zero}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; + +/// A validated problem-size variable name. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(transparent)] +pub struct Symbol(Box); + +impl Symbol { + pub fn new(name: impl Into>) -> Result { + let name = name.into(); + if is_valid_symbol(&name) { + Ok(Self(name)) + } else { + Err(InvalidSymbol(name)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for Symbol { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Symbol { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Symbol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let name = Box::::deserialize(deserializer)?; + Self::new(name).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("invalid expression variable name {0:?}")] +pub struct InvalidSymbol(Box); + +fn is_valid_symbol(name: &str) -> bool { + let mut bytes = name.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == b'_') + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || name == "_" + { + return false; + } + !matches!( + name, + "abstract" + | "as" + | "async" + | "await" + | "become" + | "box" + | "break" + | "const" + | "continue" + | "crate" + | "do" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "final" + | "fn" + | "for" + | "gen" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "macro" + | "match" + | "mod" + | "move" + | "mut" + | "override" + | "priv" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "try" + | "type" + | "typeof" + | "union" + | "unsafe" + | "unsized" + | "use" + | "virtual" + | "where" + | "while" + | "yield" + ) +} + +/// One immutable node in a symbolic expression DAG. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExprNode { + Const(BigRational), + Var(Symbol), + Add(Box<[Expr]>), + Mul(Box<[Expr]>), + Pow(Expr, Expr), + Exp(Expr), + Log(Expr), + Factorial(Expr), +} + +/// A cheap, immutable handle to a shared symbolic expression node. +#[derive(Clone, Debug)] +pub struct Expr(Arc); + +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) || self.node() == other.node() + } +} + +impl Eq for Expr {} + +impl std::hash::Hash for Expr { + fn hash(&self, state: &mut H) { + std::hash::Hash::hash(self.node(), state); + } +} + +impl PartialOrd for Expr { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Expr { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if Arc::ptr_eq(&self.0, &other.0) { + std::cmp::Ordering::Equal + } else { + self.node().cmp(other.node()) + } + } +} + +/// Opaque identity used to memoize one traversal of an expression DAG. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExprNodeId(usize); + +impl serde::Serialize for Expr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + ExprDocument::from_expression(self).serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Expr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ExprDocument::deserialize(deserializer)? + .into_expression() + .map_err(serde::de::Error::custom) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct ExprDocument { + nodes: Vec, + root: usize, +} + +#[derive(serde::Serialize, serde::Deserialize)] +enum SerializedNode { + Const(BigRational), + Var(Symbol), + Add(Vec), + Mul(Vec), + Pow(usize, usize), + Exp(usize), + Log(usize), + Factorial(usize), +} + +impl ExprDocument { + fn from_expression(root: &Expr) -> Self { + let mut ids = HashMap::new(); + let mut nodes = Vec::new(); + let mut pending = vec![(root, false)]; + while let Some((expression, expanded)) = pending.pop() { + if ids.contains_key(&expression.node_identity()) { + continue; + } + if !expanded { + pending.push((expression, true)); + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => { + pending.extend(values.iter().rev().map(|value| (value, false))); + } + ExprNode::Pow(base, exponent) => { + pending.push((exponent, false)); + pending.push((base, false)); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + pending.push((value, false)) + } + ExprNode::Const(_) | ExprNode::Var(_) => {} + } + continue; + } + + let child_id = |child: &Expr| ids[&child.node_identity()]; + let node = match expression.node() { + ExprNode::Const(value) => SerializedNode::Const(value.clone()), + ExprNode::Var(symbol) => SerializedNode::Var(symbol.clone()), + ExprNode::Add(values) => SerializedNode::Add(values.iter().map(child_id).collect()), + ExprNode::Mul(values) => SerializedNode::Mul(values.iter().map(child_id).collect()), + ExprNode::Pow(base, exponent) => { + SerializedNode::Pow(child_id(base), child_id(exponent)) + } + ExprNode::Exp(value) => SerializedNode::Exp(child_id(value)), + ExprNode::Log(value) => SerializedNode::Log(child_id(value)), + ExprNode::Factorial(value) => SerializedNode::Factorial(child_id(value)), + }; + let id = nodes.len(); + nodes.push(node); + ids.insert(expression.node_identity(), id); + } + Self { + nodes, + root: ids[&root.node_identity()], + } + } + + fn into_expression(self) -> Result { + let mut expressions = Vec::with_capacity(self.nodes.len()); + for (node_id, node) in self.nodes.into_iter().enumerate() { + let child = |id: usize| { + expressions + .get(id) + .cloned() + .ok_or(InvalidExpressionDocument::UnavailableChild { node_id, id }) + }; + let expression = match node { + SerializedNode::Const(value) => Expr::constant(value), + SerializedNode::Var(symbol) => Expr::from_node(ExprNode::Var(symbol)), + SerializedNode::Add(ids) => { + Expr::add_all(ids.into_iter().map(child).collect::>()?) + } + SerializedNode::Mul(ids) => { + Expr::mul_all(ids.into_iter().map(child).collect::>()?) + } + SerializedNode::Pow(base, exponent) => Expr::pow(child(base)?, child(exponent)?), + SerializedNode::Exp(value) => Expr::exp(child(value)?), + SerializedNode::Log(value) => Expr::log(child(value)?), + SerializedNode::Factorial(value) => Expr::factorial(child(value)?), + }; + expressions.push(expression); + } + expressions + .get(self.root) + .cloned() + .ok_or(InvalidExpressionDocument::UnavailableRoot(self.root)) + } +} + +#[derive(Debug, thiserror::Error)] +enum InvalidExpressionDocument { + #[error("expression node {node_id} references unavailable child node {id}")] + UnavailableChild { node_id: usize, id: usize }, + #[error("expression root references unavailable node {0}")] + UnavailableRoot(usize), +} + +impl Expr { + fn from_node(node: ExprNode) -> Self { + Self(Arc::new(node)) + } + + pub fn node(&self) -> &ExprNode { + &self.0 + } + + /// Identity of this allocation for operation-local DAG memoization. + /// The value is process-local and remains valid while any clone of the node lives. + pub fn node_identity(&self) -> ExprNodeId { + ExprNodeId(Arc::as_ptr(&self.0) as usize) + } + + pub fn integer(value: impl Into) -> Self { + Self::constant(BigRational::from_integer(value.into())) + } + + pub fn rational(numerator: impl Into, denominator: impl Into) -> Self { + Self::constant(BigRational::new(numerator.into(), denominator.into())) + } + + pub fn constant(value: BigRational) -> Self { + Self::from_node(ExprNode::Const(value)) + } + + pub fn variable(name: impl Into>) -> Self { + Self::try_variable(name).unwrap_or_else(|error| panic!("{error}")) + } + + pub fn try_variable(name: impl Into>) -> Result { + Symbol::new(name).map(|symbol| Self::from_node(ExprNode::Var(symbol))) + } + + pub fn pow(base: Expr, exponent: Expr) -> Self { + if exponent.is_exact_integer(0) || base.is_exact_integer(1) { + return Self::integer(1); + } + if exponent.is_exact_integer(1) { + return base; + } + Self::from_node(ExprNode::Pow(base, exponent)) + } + + pub fn exp(value: Expr) -> Self { + Self::from_node(ExprNode::Exp(value)) + } + + pub fn log(value: Expr) -> Self { + Self::from_node(ExprNode::Log(value)) + } + + pub fn sqrt(value: Expr) -> Self { + Self::pow(value, Self::rational(1, 2)) + } + + pub fn factorial(value: Expr) -> Self { + Self::from_node(ExprNode::Factorial(value)) + } + + pub fn parse(input: &str) -> Self { + Self::try_parse(input) + .unwrap_or_else(|error| panic!("failed to parse expression {input:?}: {error}")) + } + + pub fn try_parse(input: &str) -> Result { + Parser::new(tokenize(input)?).parse() + } + + pub fn variables(&self) -> BTreeSet<&str> { + let mut variables = BTreeSet::new(); + let mut visited = HashSet::new(); + self.collect_variables(&mut variables, &mut visited); + variables + } + + fn collect_variables<'a>( + &'a self, + variables: &mut BTreeSet<&'a str>, + visited: &mut HashSet, + ) { + if !visited.insert(self.node_identity()) { + return; + } + match self.node() { + ExprNode::Const(_) => {} + ExprNode::Var(name) => { + variables.insert(name.as_str()); + } + ExprNode::Add(values) | ExprNode::Mul(values) => { + for value in values { + value.collect_variables(variables, visited); + } + } + ExprNode::Pow(base, exponent) => { + base.collect_variables(variables, visited); + exponent.collect_variables(variables, visited); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.collect_variables(variables, visited); + } + } + } + + /// Replace every variable or report the complete set of missing replacements. + pub fn substitute_complete( + &self, + replacements: &HashMap<&str, &Expr>, + ) -> Result { + self.substitute_inner(replacements, &mut HashMap::new()) + .map_err(SubstitutionError::new) + } + + fn substitute_inner( + &self, + replacements: &HashMap<&str, &Expr>, + memo: &mut HashMap>>>, + ) -> Result>> { + let identity = self.node_identity(); + if let Some(result) = memo.get(&identity) { + return result.clone(); + } + let result = match self.node() { + ExprNode::Const(_) => Ok(self.clone()), + ExprNode::Var(name) => match replacements.get(name.as_ref()) { + Some(replacement) => Ok((*replacement).clone()), + None => Err(BTreeSet::from([name.as_str().into()])), + }, + ExprNode::Add(values) => { + Self::substitute_values(values, replacements, memo).map(Self::add_all) + } + ExprNode::Mul(values) => { + Self::substitute_values(values, replacements, memo).map(Self::mul_all) + } + ExprNode::Pow(base, exponent) => { + let base = base.substitute_inner(replacements, memo); + let exponent = exponent.substitute_inner(replacements, memo); + match (base, exponent) { + (Ok(base), Ok(exponent)) => Ok(Self::pow(base, exponent)), + (Err(mut left), Err(right)) => { + left.extend(right); + Err(left) + } + (Err(missing), _) | (_, Err(missing)) => Err(missing), + } + } + ExprNode::Exp(value) => value.substitute_inner(replacements, memo).map(Self::exp), + ExprNode::Log(value) => value.substitute_inner(replacements, memo).map(Self::log), + ExprNode::Factorial(value) => value + .substitute_inner(replacements, memo) + .map(Self::factorial), + }; + memo.insert(identity, result.clone()); + result + } + + fn substitute_values( + values: &[Expr], + replacements: &HashMap<&str, &Expr>, + memo: &mut HashMap>>>, + ) -> Result, BTreeSet>> { + let mut substituted = Vec::with_capacity(values.len()); + let mut missing = BTreeSet::new(); + for value in values { + match value.substitute_inner(replacements, memo) { + Ok(value) => substituted.push(value), + Err(variables) => missing.extend(variables), + } + } + if missing.is_empty() { + Ok(substituted) + } else { + Err(missing) + } + } + + pub fn is_constant(&self) -> bool { + self.is_constant_inner(&mut HashMap::new()) + } + + fn is_constant_inner(&self, memo: &mut HashMap) -> bool { + if let Some(result) = memo.get(&self.node_identity()) { + return *result; + } + let result = match self.node() { + ExprNode::Const(_) => true, + ExprNode::Var(_) => false, + ExprNode::Add(values) | ExprNode::Mul(values) => { + values.iter().all(|value| value.is_constant_inner(memo)) + } + ExprNode::Pow(base, exponent) => { + base.is_constant_inner(memo) && exponent.is_constant_inner(memo) + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.is_constant_inner(memo) + } + }; + memo.insert(self.node_identity(), result); + result + } + + pub fn is_polynomial(&self) -> bool { + self.is_polynomial_inner(&mut HashMap::new()) + } + + fn is_polynomial_inner(&self, polynomial_memo: &mut HashMap) -> bool { + if let Some(result) = polynomial_memo.get(&self.node_identity()) { + return *result; + } + let result = match self.node() { + ExprNode::Const(_) | ExprNode::Var(_) => true, + ExprNode::Add(values) | ExprNode::Mul(values) => values + .iter() + .all(|value| value.is_polynomial_inner(polynomial_memo)), + ExprNode::Pow(base, exponent) => { + (matches!((base.node(), exponent.node()), + (ExprNode::Const(base), ExprNode::Const(exponent)) + if exponent.is_integer() + && (!exponent.is_negative() || !base.is_zero()))) + || (base.is_polynomial_inner(polynomial_memo) + && matches!(exponent.node(), ExprNode::Const(value) if value.is_integer() && !value.is_negative())) + } + ExprNode::Exp(_) | ExprNode::Log(_) | ExprNode::Factorial(_) => false, + }; + polynomial_memo.insert(self.node_identity(), result); + result + } + + pub fn is_valid_complexity_notation(&self) -> bool { + self.complexity_notation_analysis(&mut HashMap::new()).1 + } + + fn complexity_notation_analysis( + &self, + memo: &mut HashMap, + ) -> (bool, bool) { + if let Some(analysis) = memo.get(&self.node_identity()) { + return *analysis; + } + let analysis = match self.node() { + ExprNode::Const(value) => (true, value.is_one()), + ExprNode::Var(_) => (false, true), + ExprNode::Add(values) | ExprNode::Mul(values) => { + let mut all_constant = true; + let mut all_valid_nonconstant = true; + for value in values { + let (constant, valid) = value.complexity_notation_analysis(memo); + all_constant &= constant; + all_valid_nonconstant &= !constant && valid; + } + (all_constant, all_valid_nonconstant) + } + ExprNode::Pow(base, exponent) => { + let base_analysis = base.complexity_notation_analysis(memo); + let exponent_analysis = exponent.complexity_notation_analysis(memo); + let base_valid = match base.node() { + ExprNode::Const(value) => value.is_positive(), + _ => base_analysis.1, + }; + ( + base_analysis.0 && exponent_analysis.0, + base_valid && (exponent_analysis.0 || exponent_analysis.1), + ) + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.complexity_notation_analysis(memo) + } + }; + memo.insert(self.node_identity(), analysis); + analysis + } + + pub fn unique_node_count(&self) -> usize { + let mut visited = HashSet::new(); + let mut pending = vec![self]; + while let Some(expression) = pending.pop() { + if !visited.insert(expression.node_identity()) { + continue; + } + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => pending.extend(values), + ExprNode::Pow(base, exponent) => { + pending.push(base); + pending.push(exponent); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + pending.push(value); + } + ExprNode::Const(_) | ExprNode::Var(_) => {} + } + } + visited.len() + } + + fn is_exact_integer(&self, expected: i64) -> bool { + matches!(self.node(), ExprNode::Const(value) if *value == BigRational::from_integer(expected.into())) + } + + fn add_all(values: Vec) -> Expr { + let mut constant = BigRational::zero(); + let mut coefficients: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut pending = values; + while let Some(value) = pending.pop() { + match value.node() { + ExprNode::Add(nested) => pending.extend(nested.iter().cloned()), + ExprNode::Const(value) => constant += value, + ExprNode::Mul(factors) + if matches!(factors.first().map(Expr::node), Some(ExprNode::Const(_))) => + { + let ExprNode::Const(coefficient) = factors[0].node() else { + unreachable!() + }; + let base = Self::mul_all(factors[1..].to_vec()); + *coefficients.entry(base).or_insert_with(BigRational::zero) += coefficient; + } + _ => { + *coefficients.entry(value).or_insert_with(BigRational::zero) += + BigRational::one(); + } + } + } + let mut terms = Vec::with_capacity(coefficients.len() + usize::from(!constant.is_zero())); + for (base, coefficient) in coefficients { + if coefficient.is_zero() { + continue; + } + if coefficient.is_one() { + terms.push(base); + } else { + terms.push(Self::mul_all(vec![Self::constant(coefficient), base])); + } + } + if !constant.is_zero() { + terms.push(Self::constant(constant)); + } + terms.sort(); + match terms.len() { + 0 => Self::integer(0), + 1 => terms.pop().expect("single normalized sum term"), + _ => Self::from_node(ExprNode::Add(terms.into_boxed_slice())), + } + } + + fn mul_all(values: Vec) -> Expr { + let mut constant = BigRational::one(); + let mut powers: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let mut pending = values; + while let Some(value) = pending.pop() { + match value.node() { + ExprNode::Mul(nested) => pending.extend(nested.iter().cloned()), + ExprNode::Const(value) => constant *= value, + ExprNode::Pow(base, exponent) => { + powers + .entry(base.clone()) + .or_default() + .push(exponent.clone()); + } + _ => powers.entry(value).or_default().push(Self::integer(1)), + } + } + let mut factors = Vec::with_capacity(powers.len() + usize::from(!constant.is_one())); + for (base, exponents) in powers { + factors.push(Self::pow(base, Self::add_all(exponents))); + } + if !constant.is_one() { + factors.push(Self::constant(constant)); + } + factors.sort(); + match factors.len() { + 0 => Self::integer(1), + 1 => factors.pop().expect("single normalized product factor"), + _ => Self::from_node(ExprNode::Mul(factors.into_boxed_slice())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubstitutionError { + missing: BTreeSet>, +} + +impl SubstitutionError { + fn new(missing: BTreeSet>) -> Self { + Self { missing } + } + + pub fn missing_variables(&self) -> impl Iterator { + self.missing.iter().map(AsRef::as_ref) + } +} + +impl fmt::Display for SubstitutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "missing substitutions for {}", + self.missing_variables().collect::>().join(", ") + ) + } +} + +impl std::error::Error for SubstitutionError {} + +impl std::ops::Add for Expr { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Self::add_all(vec![self, rhs]) + } +} + +impl std::ops::Sub for Expr { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + Self::add_all(vec![self, -rhs]) + } +} + +impl std::ops::Mul for Expr { + type Output = Self; + fn mul(self, rhs: Self) -> Self::Output { + Self::mul_all(vec![self, rhs]) + } +} + +impl std::ops::Div for Expr { + type Output = Self; + fn div(self, rhs: Self) -> Self::Output { + Self::mul_all(vec![self, Self::pow(rhs, Self::integer(-1))]) + } +} + +impl std::ops::Neg for Expr { + type Output = Self; + fn neg(self) -> Self::Output { + Self::mul_all(vec![Self::integer(-1), self]) + } +} + +impl fmt::Display for Expr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.fmt_with_precedence(formatter, 0, false) + } +} + +impl Expr { + fn precedence(&self) -> u8 { + match self.node() { + ExprNode::Add(_) => 1, + ExprNode::Mul(_) => 2, + ExprNode::Pow(_, _) => 4, + _ => 5, + } + } + + fn fmt_with_precedence( + &self, + formatter: &mut fmt::Formatter<'_>, + parent_precedence: u8, + right_child: bool, + ) -> fmt::Result { + let precedence = self.precedence(); + let needs_parentheses = precedence < parent_precedence + || (right_child + && precedence == parent_precedence + && matches!(self.node(), ExprNode::Add(_) | ExprNode::Mul(_))) + || (!right_child + && precedence == parent_precedence + && matches!(self.node(), ExprNode::Pow(_, _))); + if needs_parentheses { + write!(formatter, "(")?; + } + match self.node() { + ExprNode::Const(value) => fmt_rational(value, formatter)?, + ExprNode::Var(name) => write!(formatter, "{name}")?, + ExprNode::Add(values) => { + for (index, value) in values.iter().enumerate() { + if index > 0 { + write!(formatter, " + ")?; + } + value.fmt_with_precedence(formatter, precedence, index > 0)?; + } + } + ExprNode::Mul(values) => { + for (index, value) in values.iter().enumerate() { + if index > 0 { + write!(formatter, " * ")?; + } + value.fmt_with_precedence(formatter, precedence, index > 0)?; + } + } + ExprNode::Pow(base, exponent) => { + base.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, "^")?; + exponent.fmt_with_precedence(formatter, precedence, true)?; + } + ExprNode::Exp(value) => write!(formatter, "exp({value})")?, + ExprNode::Log(value) => write!(formatter, "log({value})")?, + ExprNode::Factorial(value) => write!(formatter, "factorial({value})")?, + } + if needs_parentheses { + write!(formatter, ")")?; + } + Ok(()) + } +} + +fn fmt_rational(value: &BigRational, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if value.is_integer() { + return write!(formatter, "{}", value.to_integer()); + } + let negative = value.is_negative(); + let numerator = value.numer().abs(); + let mut denominator = value.denom().clone(); + let mut twos = 0usize; + let mut fives = 0usize; + while (&denominator % 2u8).is_zero() { + denominator /= 2u8; + twos += 1; + } + while (&denominator % 5u8).is_zero() { + denominator /= 5u8; + fives += 1; + } + if !denominator.is_one() { + return write!(formatter, "{}/{}", value.numer(), value.denom()); + } + let scale = twos.max(fives); + let scaled = numerator + * BigInt::from(2u8).pow((scale - twos) as u32) + * BigInt::from(5u8).pow((scale - fives) as u32); + let digits = scaled.to_string(); + let sign = if negative { "-" } else { "" }; + if digits.len() <= scale { + write!(formatter, "{sign}0.{:0>width$}", digits, width = scale) + } else { + let split = digits.len() - scale; + write!(formatter, "{sign}{}.{}", &digits[..split], &digits[split..]) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("{message} at byte {position}")] +pub struct ParseError { + position: usize, + message: String, +} + +impl ParseError { + fn new(position: usize, message: impl Into) -> Self { + Self { + position, + message: message.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Token { + position: usize, + kind: TokenKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TokenKind { + Number(BigRational), + Ident(Box), + Plus, + Minus, + Star, + Slash, + Caret, + LeftParen, + RightParen, +} + +fn tokenize(input: &str) -> Result, ParseError> { + let bytes = input.as_bytes(); + let mut tokens = Vec::new(); + let mut position = 0; + while position < bytes.len() { + match bytes[position] { + b' ' | b'\t' | b'\n' | b'\r' => position += 1, + b'+' => push_token(&mut tokens, &mut position, TokenKind::Plus), + b'-' => push_token(&mut tokens, &mut position, TokenKind::Minus), + b'*' => push_token(&mut tokens, &mut position, TokenKind::Star), + b'/' => push_token(&mut tokens, &mut position, TokenKind::Slash), + b'^' => push_token(&mut tokens, &mut position, TokenKind::Caret), + b'(' => push_token(&mut tokens, &mut position, TokenKind::LeftParen), + b')' => push_token(&mut tokens, &mut position, TokenKind::RightParen), + byte if byte.is_ascii_digit() || byte == b'.' => { + let start = position; + while position < bytes.len() + && (bytes[position].is_ascii_digit() || bytes[position] == b'.') + { + position += 1; + } + let spelling = &input[start..position]; + let value = parse_decimal(spelling).ok_or_else(|| { + ParseError::new(start, format!("invalid number {spelling:?}")) + })?; + tokens.push(Token { + position: start, + kind: TokenKind::Number(value), + }); + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = position; + while position < bytes.len() + && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_') + { + position += 1; + } + tokens.push(Token { + position: start, + kind: TokenKind::Ident(input[start..position].into()), + }); + } + _ => { + let character = input[position..].chars().next().unwrap(); + return Err(ParseError::new( + position, + format!("unexpected character {character:?}"), + )); + } + } + } + Ok(tokens) +} + +fn push_token(tokens: &mut Vec, position: &mut usize, kind: TokenKind) { + tokens.push(Token { + position: *position, + kind, + }); + *position += 1; +} + +fn parse_decimal(spelling: &str) -> Option { + let mut parts = spelling.split('.'); + let integer = parts.next()?; + let fractional = parts.next(); + if parts.next().is_some() || (integer.is_empty() && fractional.is_none()) { + return None; + } + match fractional { + None => BigInt::from_str(integer) + .ok() + .map(BigRational::from_integer), + Some(fractional) if !integer.is_empty() || !fractional.is_empty() => { + let combined = format!("{integer}{fractional}"); + let numerator = BigInt::from_str(&combined).ok()?; + let denominator = BigInt::from(10u8).pow(fractional.len() as u32); + Some(BigRational::new(numerator, denominator)) + } + Some(_) => None, + } +} + +struct Parser { + tokens: std::iter::Peekable>, + end_position: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + let end_position = tokens.last().map_or(0, |token| token.position + 1); + Self { + tokens: tokens.into_iter().peekable(), + end_position, + } + } + + fn parse(mut self) -> Result { + if self.tokens.peek().is_none() { + return Err(ParseError::new(0, "expected expression")); + } + let expression = self.parse_additive()?; + if let Some(token) = self.peek() { + return Err(ParseError::new(token.position, "unexpected trailing token")); + } + Ok(expression) + } + + fn peek(&mut self) -> Option<&Token> { + self.tokens.peek() + } + + fn advance(&mut self) -> Option { + self.tokens.next() + } + + fn consume(&mut self, kind: &TokenKind) -> bool { + if self.peek().is_some_and(|token| &token.kind == kind) { + self.tokens.next(); + true + } else { + false + } + } + + fn parse_additive(&mut self) -> Result { + let mut expression = self.parse_multiplicative()?; + loop { + if self.consume(&TokenKind::Plus) { + expression = expression + self.parse_multiplicative()?; + } else if self.consume(&TokenKind::Minus) { + expression = expression - self.parse_multiplicative()?; + } else { + return Ok(expression); + } + } + } + + fn parse_multiplicative(&mut self) -> Result { + let mut expression = self.parse_unary()?; + loop { + if self.consume(&TokenKind::Star) { + expression = expression * self.parse_unary()?; + } else if self + .peek() + .is_some_and(|token| token.kind == TokenKind::Slash) + { + let position = self.advance().expect("peeked division token").position; + let denominator = self.parse_unary()?; + if denominator.is_exact_integer(0) { + return Err(ParseError::new(position, "division by zero")); + } + expression = expression / denominator; + } else { + return Ok(expression); + } + } + } + + fn parse_unary(&mut self) -> Result { + if self.consume(&TokenKind::Minus) { + Ok(-self.parse_unary()?) + } else { + self.parse_power() + } + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_primary()?; + if self + .peek() + .is_some_and(|token| token.kind == TokenKind::Caret) + { + let position = self.advance().expect("peeked power token").position; + let exponent = self.parse_unary()?; + if matches!((base.node(), exponent.node()), + (ExprNode::Const(base), ExprNode::Const(exponent)) + if base.is_zero() && exponent.is_negative()) + { + return Err(ParseError::new( + position, + "zero cannot have a negative power", + )); + } + Ok(Expr::pow(base, exponent)) + } else { + Ok(base) + } + } + + fn parse_primary(&mut self) -> Result { + let token = self + .advance() + .ok_or_else(|| ParseError::new(self.end_position(), "expected expression"))?; + match token.kind { + TokenKind::Number(value) => Ok(Expr::constant(value)), + TokenKind::Ident(name) => { + if !self.consume(&TokenKind::LeftParen) { + return Expr::try_variable(name) + .map_err(|error| ParseError::new(token.position, error.to_string())); + } + let argument = self.parse_additive()?; + self.expect_right_paren()?; + match name.as_ref() { + "exp" => Ok(Expr::exp(argument)), + "log" => { + if matches!(argument.node(), ExprNode::Const(value) if !value.is_positive()) + { + Err(ParseError::new( + token.position, + "logarithm argument must be positive", + )) + } else { + Ok(Expr::log(argument)) + } + } + "sqrt" => { + if matches!(argument.node(), ExprNode::Const(value) if value.is_negative()) + { + Err(ParseError::new( + token.position, + "square-root argument must be non-negative", + )) + } else { + Ok(Expr::sqrt(argument)) + } + } + "factorial" => { + if matches!(argument.node(), ExprNode::Const(value) + if !value.is_integer() || value.is_negative()) + { + Err(ParseError::new( + token.position, + "factorial argument must be a non-negative integer", + )) + } else { + Ok(Expr::factorial(argument)) + } + } + _ => Err(ParseError::new( + token.position, + format!("unknown function {name:?}"), + )), + } + } + TokenKind::LeftParen => { + let expression = self.parse_additive()?; + self.expect_right_paren()?; + Ok(expression) + } + _ => Err(ParseError::new(token.position, "expected expression")), + } + } + + fn expect_right_paren(&mut self) -> Result<(), ParseError> { + if self.consume(&TokenKind::RightParen) { + Ok(()) + } else { + Err(ParseError::new( + self.end_position(), + "expected closing parenthesis", + )) + } + } + + fn end_position(&self) -> usize { + self.end_position + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_literals_are_exact() { + assert_eq!(Expr::parse("2.372"), Expr::rational(593, 250)); + } + + #[test] + fn parser_normalizes_source_operators() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert!(matches!(expression.node(), ExprNode::Add(_))); + assert_eq!(expression.variables(), BTreeSet::from(["m", "n"])); + } + + #[test] + fn parser_rejects_statically_undefined_expressions() { + for source in [ + "0 / 0", + "0^-1", + "log(0)", + "log(-1)", + "sqrt(-1)", + "factorial(-1)", + "factorial(3.5)", + ] { + assert!(Expr::try_parse(source).is_err(), "accepted {source}"); + } + } + + #[test] + fn variables_are_owned() { + let name = String::from("dynamic_size"); + let expression = Expr::parse(&name); + drop(name); + assert_eq!(expression.variables(), BTreeSet::from(["dynamic_size"])); + } + + #[test] + fn variables_enforce_one_identifier_grammar() { + for invalid in ["", "_", "1n", "n-m", "type"] { + assert!(Expr::try_variable(invalid).is_err(), "accepted {invalid:?}"); + } + for invalid_expression in ["", "_", "1n", "type"] { + assert!( + Expr::try_parse(invalid_expression).is_err(), + "parsed {invalid_expression:?}" + ); + } + assert!(matches!(Expr::parse("n-m").node(), ExprNode::Add(_))); + for valid in ["n", "_n", "n_1", "num_vertices"] { + let expression = Expr::try_variable(valid).unwrap(); + assert_eq!( + Expr::try_parse(&expression.to_string()).unwrap(), + expression + ); + } + } + + #[test] + fn deserialization_rejects_invalid_variable_names() { + assert!(serde_json::from_str::(r#"{"nodes":[{"Var":"n-m"}],"root":0}"#).is_err()); + } + + #[test] + fn serialization_preserves_shared_nodes() { + let shared = Expr::variable("a") + Expr::variable("b"); + let expression = Expr::pow(shared.clone(), shared); + let encoded = serde_json::to_value(&expression).unwrap(); + assert_eq!(encoded["nodes"].as_array().unwrap().len(), 4); + + let decoded: Expr = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, expression); + assert_eq!(decoded.unique_node_count(), 4); + } + + #[test] + fn deserialization_rejects_forward_node_references() { + let error = + serde_json::from_str::(r#"{"nodes":[{"Pow":[1,1]},{"Var":"n"}],"root":0}"#) + .unwrap_err(); + assert!(error.to_string().contains("unavailable child node 1")); + } + + #[test] + fn complete_substitution_rejects_missing_variables() { + let expression = Expr::parse("n + m"); + let n = Expr::integer(3); + let replacements = HashMap::from([("n", &n)]); + let error = expression.substitute_complete(&replacements).unwrap_err(); + assert_eq!(error.missing_variables().collect::>(), ["m"]); + + let m = Expr::integer(4); + let replacements = HashMap::from([("n", &n), ("m", &m)]); + assert_eq!( + expression.substitute_complete(&replacements), + Ok(Expr::integer(3) + Expr::integer(4)) + ); + } + + #[test] + fn polynomial_accepts_exact_rational_coefficients() { + assert!(Expr::parse("-n / 2").is_polynomial()); + assert!( + !(Expr::variable("n") * Expr::pow(Expr::integer(0), Expr::integer(-1))).is_polynomial() + ); + assert!(!Expr::parse("n / m").is_polynomial()); + } + + #[test] + fn exponentiation_precedes_unary_minus() { + assert_eq!( + Expr::parse("-n^2"), + -Expr::pow(Expr::variable("n"), Expr::integer(2)) + ); + assert_eq!( + Expr::parse("2^-3"), + Expr::pow(Expr::integer(2), -Expr::integer(3)) + ); + } + + #[test] + fn display_preserves_grouping() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert_eq!(expression.to_string(), "-1 * m + n * (-1 + n) * 2^-1"); + assert_eq!(Expr::parse(&expression.to_string()), expression); + } + + #[test] + fn repeated_substitution_keeps_a_constant_number_of_nodes() { + let template = Expr::parse("x + x"); + let mut expression = Expr::variable("n"); + for _ in 0..100 { + let replacements = HashMap::from([("x", &expression)]); + expression = template + .substitute_complete(&replacements) + .expect("x has an exact replacement"); + } + + assert_eq!(expression.unique_node_count(), 3); + assert_eq!(expression.variables(), BTreeSet::from(["n"])); + } + + #[test] + fn constructors_combine_coefficients_and_exponents() { + assert_eq!(Expr::parse("2*x + 3*x"), Expr::parse("5*x")); + assert_eq!(Expr::parse("x^2 * x^3"), Expr::parse("x^5")); + assert_eq!(Expr::parse("x * x^-1"), Expr::integer(1)); + } + + #[test] + fn canonicalization_preserves_deep_shared_subexpressions() { + let mut expression = Expr::variable("n"); + for _ in 0..100 { + expression = Expr::pow(expression.clone(), Expr::integer(2)) + expression; + } + + assert_eq!(expression.unique_node_count(), 301); + } + + #[test] + fn serialization_preserves_every_operator() { + let expression = Expr::parse("-factorial(n - 1) + exp(m) / log(sqrt(k))^2"); + let encoded = serde_json::to_string(&expression).unwrap(); + let decoded: Expr = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, expression); + } + + #[test] + fn display_does_not_normalize_half_power_to_sqrt() { + let power = Expr::pow(Expr::variable("n"), Expr::rational(1, 2)); + assert_eq!(power.to_string(), "n^0.5"); + assert_eq!(Expr::parse(&power.to_string()), power); + } + + #[test] + fn shared_dag_queries_reuse_nodes_without_losing_errors() { + let shared = Expr::variable("n") + Expr::variable("m"); + let expression = Expr::pow(shared.clone(), shared); + + assert_eq!(expression.variables(), BTreeSet::from(["m", "n"])); + assert!(!expression.is_constant()); + assert!(!expression.is_polynomial()); + assert!(expression.is_valid_complexity_notation()); + assert_eq!(expression.unique_node_count(), 4); + + let error = expression.substitute_complete(&HashMap::new()).unwrap_err(); + assert_eq!( + error.missing_variables().collect::>(), + vec!["m", "n"] + ); + + let mut expressions = HashSet::new(); + assert!(expressions.insert(expression.clone())); + assert!(!expressions.insert(expression)); + } + + #[test] + fn display_and_parser_cover_non_decimal_rationals() { + assert_eq!(Expr::rational(1, 3).to_string(), "1/3"); + assert!(Expr::try_parse(".").is_err()); + } +} diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json new file mode 100644 index 000000000..999eaf1a4 --- /dev/null +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -0,0 +1,904 @@ +{ + "oracle": { + "engine": "SymPy", + "version": "1.14.0", + "parse_evaluate": false, + "polynomial_mode": "simplify before classification", + "decimal_mode": "rationalize base-10 spelling", + "documentation": { + "parser": "https://docs.sympy.org/latest/modules/parsing.html", + "expression_core": "https://docs.sympy.org/latest/modules/core.html" + } + }, + "cases": [ + { + "name": "zero", + "source": "0", + "variables": [], + "bindings": {}, + "exact_result": "0/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "integer", + "source": "42", + "variables": [], + "bindings": {}, + "exact_result": "42/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "exact_decimal", + "source": "2.372", + "variables": [], + "bindings": {}, + "exact_result": "593/250", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "leading_decimal_point", + "source": ".125", + "variables": [], + "bindings": {}, + "exact_result": "1/8", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "arbitrary_precision_integer", + "source": "100000000000000000000000000000000000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "100000000000000000000000000000000000000000000000001/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "variable", + "source": "n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "negation", + "source": "-n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "-7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "addition", + "source": "n + m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "subtraction", + "source": "n - m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 7 + }, + "exact_result": "-4/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "multiplication", + "source": "n * m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 6, + "m": 7 + }, + "exact_result": "42/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "rational_coefficient", + "source": "n / 2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "3/2", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "variable_divisor", + "source": "n / m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 5 + }, + "exact_result": "12/5", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "nested_divisor", + "source": "n / (m + 1)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 4 + }, + "exact_result": "2/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "exact_size_formula", + "source": "n * (n - 1) / 2 - m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 5, + "m": 4 + }, + "exact_result": "6/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "zero_power", + "source": "n^0", + "variables": [], + "bindings": { + "n": 9 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "integer_power", + "source": "n^3", + "variables": [ + "n" + ], + "bindings": { + "n": 4 + }, + "exact_result": "64/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "negative_power", + "source": "2^-3", + "variables": [], + "bindings": {}, + "exact_result": "1/8", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "symbolic_exponent", + "source": "2^n", + "variables": [ + "n" + ], + "bindings": { + "n": 10 + }, + "exact_result": "1024/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "unary_precedence", + "source": "-n^2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "-9/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "parenthesized_negative_base", + "source": "(-n)^2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "fractional_power", + "source": "n^0.5", + "variables": [ + "n" + ], + "bindings": { + "n": 81 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "square_root", + "source": "sqrt(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 81 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "pythagorean_root", + "source": "sqrt(n^2 + m^2)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "5/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "exponential_identity", + "source": "exp(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 0 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "logarithm_identity", + "source": "log(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 1 + }, + "exact_result": "0/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "factorial", + "source": "factorial(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 6 + }, + "exact_result": "720/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "factorial_subexpression", + "source": "factorial(n - 1)", + "variables": [ + "n" + ], + "bindings": { + "n": 6 + }, + "exact_result": "120/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "decimal_scaling", + "source": "2.372 * n", + "variables": [ + "n" + ], + "bindings": { + "n": 1000 + }, + "exact_result": "2372/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "difference_of_squares", + "source": "(n + m) * (n - m)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 3 + }, + "exact_result": "91/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "multivariate_polynomial", + "source": "n^2 + 2 * n * m + m^2", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "49/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_rational", + "source": "n / (2 * m)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 3 + }, + "exact_result": "2/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "long_decimal", + "source": "1.0000000000000000000000000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "10000000000000000000000000000000000000001/10000000000000000000000000000000000000000", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_subtraction", + "source": "n - (m - k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 7, + "k": 2 + }, + "exact_result": "5/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "left_subtraction", + "source": "(n - m) - k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 7, + "k": 2 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_division", + "source": "n / (m / k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 6, + "k": 3 + }, + "exact_result": "6/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "left_division", + "source": "(n / m) / k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 6, + "k": 2 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "right_associative_power", + "source": "n^(m^k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 2, + "m": 3, + "k": 2 + }, + "exact_result": "512/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "parenthesized_power", + "source": "(n^m)^k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 2, + "m": 3, + "k": 2 + }, + "exact_result": "64/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "double_negation", + "source": "--n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "zero_factorial", + "source": "factorial(0)", + "variables": [], + "bindings": {}, + "exact_result": "1/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "zero_square_root", + "source": "sqrt(0)", + "variables": [], + "bindings": {}, + "exact_result": "0/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "constant_functions", + "source": "exp(0) + log(1) + factorial(5)", + "variables": [], + "bindings": {}, + "exact_result": "121/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "zero_product", + "source": "n * 0 + 7", + "variables": [], + "bindings": { + "n": 999 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "self_division", + "source": "n / n", + "variables": [], + "bindings": { + "n": 5 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "identity_power", + "source": "n^1", + "variables": [ + "n" + ], + "bindings": { + "n": 13 + }, + "exact_result": "13/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "decimal_integer_power", + "source": "n^2.0", + "variables": [ + "n" + ], + "bindings": { + "n": 9 + }, + "exact_result": "81/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "decimal_sum", + "source": "0.1 + 0.2", + "variables": [], + "bindings": {}, + "exact_result": "3/10", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "large_mixed_decimal", + "source": "99999999999999999999.00000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "9999999999999999999900000000000000000001/100000000000000000000", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "identifier_shapes", + "source": "n_1 + size2", + "variables": [ + "n_1", + "size2" + ], + "bindings": { + "n_1": 8, + "size2": 9 + }, + "exact_result": "17/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "mixed_precedence", + "source": "n + m * k^2", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 1, + "m": 2, + "k": 3 + }, + "exact_result": "19/1", + "compare_polynomial": true, + "is_polynomial": true + } + ], + "approximate_cases": [ + { + "name": "exp_one", + "source": "exp(1)", + "bindings": {}, + "decimal_result": "2.7182818284590452353602874713526624977572470936999595749669676277240766303535476", + "finite_f64": true + }, + { + "name": "exp_fraction", + "source": "exp(n / 3)", + "bindings": { + "n": 5 + }, + "decimal_result": "5.2944900504700293668273720041970084836945003393922853071798661344646724034457350", + "finite_f64": true + }, + { + "name": "log_two", + "source": "log(2)", + "bindings": {}, + "decimal_result": "0.69314718055994530941723212145817656807550013436025525412068000949339362196969472", + "finite_f64": true + }, + { + "name": "log_large", + "source": "log(1000000)", + "bindings": {}, + "decimal_result": "13.815510557964274104107948728106185245606608931772637856199967405805435658064115", + "finite_f64": true + }, + { + "name": "sqrt_two", + "source": "sqrt(2)", + "bindings": {}, + "decimal_result": "1.4142135623730950488016887242096980785696718753769480731766797379907324784621070", + "finite_f64": true + }, + { + "name": "sqrt_large", + "source": "sqrt(1234567)", + "bindings": {}, + "decimal_result": "1111.1107055554815416396515848965904897963992832303701857506256489602822366577437", + "finite_f64": true + }, + { + "name": "fractional_power", + "source": "7^2.372", + "bindings": {}, + "decimal_result": "101.05843092384223958212718059829945761475621729782192940886273940327749873565595", + "finite_f64": true + }, + { + "name": "mixed_transcendental", + "source": "exp(log(n)) + sqrt(m)", + "bindings": { + "n": 13, + "m": 2 + }, + "decimal_result": "14.414213562373095048801688724209698078569671875376948073176679737990732478462107", + "finite_f64": true + }, + { + "name": "complexity_formula", + "source": "2^(2.372 * n / 3)", + "bindings": { + "n": 19 + }, + "decimal_result": "33286.894651335198492304106719929283764371367866374006692959786883373657361699408", + "finite_f64": true + }, + { + "name": "factorial_ten", + "source": "factorial(10)", + "bindings": {}, + "decimal_result": "3628800.0000000000000000000000000000000000000000000000000000000000000000000000000", + "finite_f64": true + }, + { + "name": "factorial_f64_boundary", + "source": "factorial(170)", + "bindings": {}, + "decimal_result": "7.2574156153079989673967282111292631147169916812964513765435777989005618434017062e+306", + "finite_f64": true + }, + { + "name": "factorial_f64_overflow", + "source": "factorial(171)", + "bindings": {}, + "decimal_result": "1.2410180702176678234248405241031039926166055775016931853889518036119960752216918e+309", + "finite_f64": false + } + ], + "growth_cases": [ + { + "name": "constant_factor", + "left": "3 * n^2", + "right": "n^2", + "ratio_limit": "3", + "relation": "equivalent" + }, + { + "name": "lower_order_sum", + "left": "n^2 + n", + "right": "n^2", + "ratio_limit": "1", + "relation": "equivalent" + }, + { + "name": "shifted_power", + "left": "(n + 1)^2", + "right": "n^2", + "ratio_limit": "1", + "relation": "equivalent" + }, + { + "name": "log_constant_power", + "left": "log(n^3)", + "right": "log(n)", + "ratio_limit": "3", + "relation": "equivalent" + }, + { + "name": "higher_polynomial_degree", + "left": "n^3", + "right": "n^2", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "polynomial_over_log", + "left": "n", + "right": "log(n)^5", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "polylog_tie_break", + "left": "n^3 * log(n)", + "right": "n^3", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "small_base_exponential", + "left": "1.001^n", + "right": "n^100", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_base", + "left": "3^n", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_rate", + "left": "2^(2 * n)", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "natural_exponential", + "left": "exp(n)", + "right": "n^100", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_poly_tie_break", + "left": "2^n * n", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "reverse_polynomial_degree", + "left": "n", + "right": "n^2", + "ratio_limit": "0", + "relation": "right_dominates" + }, + { + "name": "reverse_exponential", + "left": "n^100", + "right": "exp(n)", + "ratio_limit": "0", + "relation": "right_dominates" + } + ], + "factorial_domain_cases": [ + { + "source": "0", + "exact_argument": "0", + "accepted": true, + "finite_f64": true + }, + { + "source": "1", + "exact_argument": "1", + "accepted": true, + "finite_f64": true + }, + { + "source": "10", + "exact_argument": "10", + "accepted": true, + "finite_f64": true + }, + { + "source": "170", + "exact_argument": "170", + "accepted": true, + "finite_f64": true + }, + { + "source": "171", + "exact_argument": "171", + "accepted": true, + "finite_f64": false + }, + { + "source": "-1", + "exact_argument": "-1", + "accepted": false, + "finite_f64": false + }, + { + "source": "3.5", + "exact_argument": "7/2", + "accepted": false, + "finite_f64": false + }, + { + "source": "1 / 2", + "exact_argument": "1/2", + "accepted": false, + "finite_f64": false + } + ] +} diff --git a/problemreductions-expr/tests/sympy_fixture.rs b/problemreductions-expr/tests/sympy_fixture.rs new file mode 100644 index 000000000..3ed02deb8 --- /dev/null +++ b/problemreductions-expr/tests/sympy_fixture.rs @@ -0,0 +1,224 @@ +use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{One, Signed, ToPrimitive, Zero}; +use problemreductions_expr::{Expr, ExprNode}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::str::FromStr; + +#[derive(Deserialize)] +struct Fixture { + oracle: Oracle, + cases: Vec, +} + +#[derive(Deserialize)] +struct Oracle { + engine: String, + version: String, + parse_evaluate: bool, + decimal_mode: String, +} + +#[derive(Deserialize)] +struct Case { + name: String, + source: String, + variables: Vec, + bindings: BTreeMap, + exact_result: String, + compare_polynomial: bool, + is_polynomial: bool, +} + +#[test] +fn sympy_fixture_matches_expression_semantics() { + let fixture: Fixture = + serde_json::from_str(include_str!("fixtures/sympy_oracle.json")).unwrap(); + assert_eq!(fixture.oracle.engine, "SymPy"); + assert_eq!(fixture.oracle.version, "1.14.0"); + assert!(!fixture.oracle.parse_evaluate); + assert_eq!(fixture.oracle.decimal_mode, "rationalize base-10 spelling"); + assert_eq!(fixture.cases.len(), 50); + + let mut names = std::collections::BTreeSet::new(); + let mut operators = std::collections::BTreeSet::new(); + for case in fixture.cases { + assert!( + names.insert(case.name.clone()), + "duplicate case {}", + case.name + ); + let expression = Expr::try_parse(&case.source) + .unwrap_or_else(|error| panic!("{} failed to parse: {error}", case.name)); + assert_eq!( + expression.variables(), + case.variables.iter().map(String::as_str).collect(), + "{} free variables", + case.name + ); + collect_operators(&expression, &mut operators); + + let bindings: BTreeMap<_, _> = case + .bindings + .iter() + .map(|(name, value)| { + ( + name.as_str(), + BigRational::from_integer(BigInt::from(*value)), + ) + }) + .collect(); + let actual = evaluate_exact(&expression, &bindings) + .unwrap_or_else(|| panic!("{} left the exact fixture domain", case.name)); + assert_eq!( + actual, + parse_rational(&case.exact_result), + "{} value", + case.name + ); + + if case.compare_polynomial { + assert_eq!( + expression.is_polynomial(), + case.is_polynomial, + "{} polynomial classification", + case.name + ); + } + } + assert_eq!( + operators, + std::collections::BTreeSet::from([ + "Add", + "Const", + "Exp", + "Factorial", + "Log", + "Mul", + "Pow", + "Var", + ]) + ); +} + +fn collect_operators(expression: &Expr, operators: &mut std::collections::BTreeSet<&'static str>) { + let operator = match expression.node() { + ExprNode::Const(_) => "Const", + ExprNode::Var(_) => "Var", + ExprNode::Add(_) => "Add", + ExprNode::Mul(_) => "Mul", + ExprNode::Pow(_, _) => "Pow", + ExprNode::Exp(_) => "Exp", + ExprNode::Log(_) => "Log", + ExprNode::Factorial(_) => "Factorial", + }; + operators.insert(operator); + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => { + for value in values { + collect_operators(value, operators); + } + } + ExprNode::Pow(left, right) => { + collect_operators(left, operators); + collect_operators(right, operators); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + collect_operators(value, operators) + } + ExprNode::Const(_) | ExprNode::Var(_) => {} + } +} + +fn evaluate_exact( + expression: &Expr, + bindings: &BTreeMap<&str, BigRational>, +) -> Option { + match expression.node() { + ExprNode::Const(value) => Some(value.clone()), + ExprNode::Var(name) => bindings.get(name.as_ref()).cloned(), + ExprNode::Add(values) => values.iter().try_fold(BigRational::zero(), |sum, value| { + Some(sum + evaluate_exact(value, bindings)?) + }), + ExprNode::Mul(values) => values + .iter() + .try_fold(BigRational::one(), |product, value| { + Some(product * evaluate_exact(value, bindings)?) + }), + ExprNode::Pow(base, exponent) => { + let base = evaluate_exact(base, bindings)?; + let exponent = evaluate_exact(exponent, bindings)?; + if exponent == BigRational::new(BigInt::one(), BigInt::from(2)) { + exact_square_root(&base) + } else if exponent.is_integer() { + rational_power(base, exponent.to_integer().to_i32()?) + } else { + None + } + } + ExprNode::Exp(value) => evaluate_exact(value, bindings)? + .is_zero() + .then(BigRational::one), + ExprNode::Log(value) => { + (evaluate_exact(value, bindings)? == BigRational::one()).then(BigRational::zero) + } + ExprNode::Factorial(value) => { + let value = evaluate_exact(value, bindings)?; + if !value.is_integer() || value.is_negative() { + return None; + } + let value = value.to_integer().to_u32()?; + Some(BigRational::from_integer( + (2..=value).fold(BigInt::one(), |product, factor| product * factor), + )) + } + } +} + +fn rational_power(base: BigRational, exponent: i32) -> Option { + let reciprocal = exponent.is_negative(); + if reciprocal && base.is_zero() { + return None; + } + let mut remaining = exponent.unsigned_abs(); + let mut factor = base; + let mut result = BigRational::one(); + while remaining > 0 { + if remaining % 2 == 1 { + result *= &factor; + } + remaining /= 2; + if remaining > 0 { + factor = &factor * &factor; + } + } + if reciprocal { + Some(result.recip()) + } else { + Some(result) + } +} + +fn exact_square_root(value: &BigRational) -> Option { + if value.is_negative() { + return None; + } + Some(BigRational::new( + perfect_square_root(value.numer())?, + perfect_square_root(value.denom())?, + )) +} + +fn perfect_square_root(value: &BigInt) -> Option { + let root = value.sqrt(); + (&root * &root == *value).then_some(root) +} + +fn parse_rational(source: &str) -> BigRational { + let (numerator, denominator) = source.split_once('/').unwrap(); + BigRational::new( + BigInt::from_str(numerator).unwrap(), + BigInt::from_str(denominator).unwrap(), + ) +} diff --git a/problemreductions-macros/Cargo.toml b/problemreductions-macros/Cargo.toml index 9db71743c..16b94ead5 100644 --- a/problemreductions-macros/Cargo.toml +++ b/problemreductions-macros/Cargo.toml @@ -13,3 +13,5 @@ proc-macro = true syn = { version = "2.0", features = ["full", "parsing"] } quote = "1.0" proc-macro2 = "1.0" +problemreductions-expr = { version = "0.6.0", path = "../problemreductions-expr" } +num-traits = "0.2" diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs new file mode 100644 index 000000000..87488da9b --- /dev/null +++ b/problemreductions-macros/src/expr_codegen.rs @@ -0,0 +1,153 @@ +use num_traits::ToPrimitive; +use problemreductions_expr::{Expr, ExprNode}; +use proc_macro2::TokenStream; +use quote::quote; + +pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { + match expression.node() { + ExprNode::Const(value) => { + let numerator = value.numer().to_string(); + let denominator = value.denom().to_string(); + quote! { + crate::expr::Expr::rational( + #numerator.parse::().expect("macro-generated numerator must be valid"), + #denominator.parse::().expect("macro-generated denominator must be valid"), + ) + } + } + ExprNode::Var(name) => { + let name = name.as_str(); + quote! { crate::expr::Expr::variable(#name) } + } + ExprNode::Add(values) => { + nary_expr_tokens(values, |left, right| quote! { (#left) + (#right) }) + } + ExprNode::Mul(values) => { + nary_expr_tokens(values, |left, right| quote! { (#left) * (#right) }) + } + ExprNode::Pow(base, exponent) => { + let base = expr_tokens(base); + let exponent = expr_tokens(exponent); + quote! { crate::expr::Expr::pow(#base, #exponent) } + } + ExprNode::Exp(value) => { + unary_expr_tokens(value, |value| quote! { crate::expr::Expr::exp(#value) }) + } + ExprNode::Log(value) => { + unary_expr_tokens(value, |value| quote! { crate::expr::Expr::log(#value) }) + } + ExprNode::Factorial(value) => unary_expr_tokens( + value, + |value| quote! { crate::expr::Expr::factorial(#value) }, + ), + } +} + +pub(crate) fn complexity_estimate_tokens( + expression: &Expr, + source: &syn::Ident, +) -> syn::Result { + Ok(match expression.node() { + ExprNode::Const(value) => { + let value = + value + .to_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("exact expression constant {value} is outside complexity estimation"), + ) + })?; + quote! { #value } + } + ExprNode::Var(name) => { + let getter = syn::Ident::new(name.as_str(), proc_macro2::Span::call_site()); + quote! { (#source.#getter() as f64) } + } + ExprNode::Add(values) => { + nary_estimate_tokens(values, source, |left, right| quote! { (#left + #right) })? + } + ExprNode::Mul(values) => { + nary_estimate_tokens(values, source, |left, right| quote! { (#left * #right) })? + } + ExprNode::Pow(base, exponent) => { + let base = complexity_estimate_tokens(base, source)?; + let exponent = complexity_estimate_tokens(exponent, source)?; + quote! { f64::powf(#base, #exponent) } + } + ExprNode::Exp(value) => { + let value = complexity_estimate_tokens(value, source)?; + quote! { f64::exp(#value) } + } + ExprNode::Log(value) => { + let value = complexity_estimate_tokens(value, source)?; + quote! { f64::ln(#value) } + } + ExprNode::Factorial(value) => { + let value = complexity_estimate_tokens(value, source)?; + quote! { + crate::expr::approximate_factorial(#value) + .expect("complexity factorial requires a non-negative integer") + } + } + }) +} + +fn nary_estimate_tokens( + values: &[Expr], + source: &syn::Ident, + build: impl Fn(TokenStream, TokenStream) -> TokenStream, +) -> syn::Result { + let mut values = values.iter(); + let first = complexity_estimate_tokens( + values + .next() + .expect("canonical n-ary expression has operands"), + source, + )?; + values.try_fold(first, |left, value| { + Ok(build(left, complexity_estimate_tokens(value, source)?)) + }) +} + +fn nary_expr_tokens( + values: &[Expr], + build: impl Fn(TokenStream, TokenStream) -> TokenStream, +) -> TokenStream { + let mut values = values.iter().map(expr_tokens); + let first = values + .next() + .expect("normalized n-ary expression has at least two operands"); + values.fold(first, build) +} + +fn unary_expr_tokens(value: &Expr, build: impl FnOnce(TokenStream) -> TokenStream) -> TokenStream { + build(expr_tokens(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_parser_drives_codegen() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert!(matches!(expression.node(), ExprNode::Add(_))); + assert_eq!( + expression.variables().into_iter().collect::>(), + vec!["m", "n"] + ); + assert!(!expr_tokens(&expression).is_empty()); + } + + #[test] + fn codegen_covers_every_semantic_operator() { + let expression = Expr::parse("exp(n) + log(n) + factorial(n) + n^2"); + let constructed = expr_tokens(&expression).to_string(); + assert!(constructed.contains("Expr :: exp")); + assert!(constructed.contains("Expr :: log")); + assert!(constructed.contains("Expr :: factorial")); + assert!(constructed.contains("Expr :: pow")); + } +} diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index ac141e1bc..245bf3ea3 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -5,13 +5,190 @@ //! and the `declare_variants!` proc macro for compile-time validated variant //! registration. -pub(crate) mod parser; +mod expr_codegen; +use expr_codegen::{complexity_estimate_tokens, expr_tokens}; 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. /// @@ -24,20 +201,21 @@ use syn::{parse_macro_input, GenericArgument, ItemImpl, Path, PathArguments, Typ /// /// # Attributes /// -/// - `overhead = { expr }` — overhead specification +/// - `size = exact { field = expression, ... }` — exact target-size equalities +/// - `size = upper_bound { field = expression, ... }` — one rule-level upper bound +/// - `size = unavailable { field = "reason", ... }` — no symbolic size transform +/// - `unavailable = { field = "reason", ... }` — fields that cannot be propagated +/// - `aggregate = identity` — explicitly register an aggregate executor; compilation +/// requires the reduction result to prove source/target value-type equality /// -/// ## New syntax (preferred): +/// ## Syntax /// ```ignore -/// #[reduction(overhead = { +/// #[reduction(size = exact { /// num_vars = "num_vertices^2", -/// num_constraints = "num_edges", +/// num_constraints = num_edges, /// })] /// ``` /// -/// ## Legacy syntax (still supported): -/// ```ignore -/// #[reduction(overhead = { ReductionOverhead::new(vec![...]) })] -/// ``` #[proc_macro_attribute] pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { let attrs = parse_macro_input!(attr as ReductionAttrs); @@ -49,32 +227,90 @@ pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { } } -/// Overhead specification: either new parsed syntax or legacy raw tokens. -enum OverheadSpec { - /// Legacy syntax: raw token stream (e.g., `ReductionOverhead::new(...)`) - Legacy(TokenStream2), - /// New syntax: list of (field_name, expression_string) pairs - Parsed(Vec<(String, String)>), +#[derive(Clone)] +struct ParsedExpressionField { + name: String, + expression: problemreductions_expr::Expr, } /// Parsed attributes from #[reduction(...)] struct ReductionAttrs { - overhead: Option, + size_declared: bool, + relation: Option, + fields: Option>, + unavailable: Option>, + identity_aggregate: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SizeRelationAttr { + Exact, + UpperBound, } impl syn::parse::Parse for ReductionAttrs { fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut attrs = ReductionAttrs { overhead: None }; + let mut attrs = ReductionAttrs { + size_declared: false, + relation: None, + fields: None, + unavailable: None, + identity_aggregate: false, + }; while !input.is_empty() { let ident: syn::Ident = input.parse()?; input.parse::()?; match ident.to_string().as_str() { - "overhead" => { + "size" => { + if attrs.size_declared { + return Err(syn::Error::new( + ident.span(), + "duplicate `size` declaration", + )); + } + attrs.size_declared = true; + let relation: syn::Ident = input.parse()?; + let content; + syn::braced!(content in input); + match relation.to_string().as_str() { + "exact" => { + attrs.relation = Some(SizeRelationAttr::Exact); + attrs.fields = Some(parse_expression_fields(&content)?); + } + "upper_bound" => { + attrs.relation = Some(SizeRelationAttr::UpperBound); + attrs.fields = Some(parse_expression_fields(&content)?); + } + "unavailable" => { + attrs.unavailable = Some(parse_unavailable_fields(&content)?); + } + _ => { + return Err(syn::Error::new( + relation.span(), + "expected `exact`, `upper_bound`, or `unavailable`", + )); + } + } + } + "unavailable" => { + if attrs.unavailable.is_some() { + return Err(syn::Error::new( + ident.span(), + "duplicate `unavailable` declaration", + )); + } let content; syn::braced!(content in input); - attrs.overhead = Some(parse_overhead_content(&content)?); + attrs.unavailable = Some(parse_unavailable_fields(&content)?); + } + "aggregate" => { + let value: syn::Ident = input.parse()?; + if value != "identity" { + return Err(syn::Error::new(value.span(), "expected `identity`")); + } + attrs.identity_aggregate = true; } _ => { return Err(syn::Error::new( @@ -89,42 +325,56 @@ impl syn::parse::Parse for ReductionAttrs { } } + if !attrs.size_declared { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "missing `size` declaration", + )); + } + Ok(attrs) } } -/// Detect and parse the overhead content as either new or legacy syntax. -/// -/// New syntax detection: the first tokens are `ident = "string_literal"`. -/// Legacy syntax: everything else (starts with a path like `ReductionOverhead::...`). -fn parse_overhead_content(content: syn::parse::ParseStream) -> syn::Result { - // Fork to peek ahead without consuming - let fork = content.fork(); - - // Try to detect new syntax: ident = "string" - let is_new_syntax = fork.parse::().is_ok() - && fork.parse::().is_ok() - && fork.parse::().is_ok(); - - if is_new_syntax { - // Parse new syntax: field_name = "expression", ... - let mut fields = Vec::new(); - while !content.is_empty() { - let field_name: syn::Ident = content.parse()?; - content.parse::()?; - let expr_str: syn::LitStr = content.parse()?; - fields.push((field_name.to_string(), expr_str.value())); - - if content.peek(syn::Token![,]) { - content.parse::()?; - } +fn parse_expression_fields(content: syn::parse::ParseStream) -> syn::Result> { + let mut fields = Vec::new(); + while !content.is_empty() { + let field_name: syn::Ident = content.parse()?; + content.parse::()?; + let expression = if content.peek(syn::LitStr) { + content.parse::()?.value() + } else { + content.parse::()?.to_string() + }; + fields.push((field_name.to_string(), expression)); + + if content.peek(syn::Token![,]) { + content.parse::()?; + } + } + Ok(fields) +} + +fn parse_unavailable_fields( + content: syn::parse::ParseStream, +) -> syn::Result> { + let mut fields = Vec::new(); + while !content.is_empty() { + let field_name: syn::Ident = content.parse()?; + content.parse::()?; + let reason = content.parse::()?.value(); + if reason.trim().is_empty() { + return Err(syn::Error::new( + field_name.span(), + "unavailable size field requires a non-empty reason", + )); + } + fields.push((field_name.to_string(), reason)); + if content.peek(syn::Token![,]) { + content.parse::()?; } - Ok(OverheadSpec::Parsed(fields)) - } else { - // Legacy syntax: parse as raw token stream - let tokens: TokenStream2 = content.parse()?; - Ok(OverheadSpec::Legacy(tokens)) } + Ok(fields) } /// Extract the base type name from a Type (e.g., "IndependentSet" from "IndependentSet"). @@ -210,101 +460,67 @@ fn make_variant_fn_body(ty: &Type, type_generics: &HashSet) -> syn::Resu Ok(quote! { <#ty as crate::traits::Problem>::variant() }) } -/// Generate overhead code from the new parsed syntax. -/// -/// Produces a `ReductionOverhead` constructor that uses `Expr` AST values. -fn generate_parsed_overhead(fields: &[(String, String)]) -> syn::Result { - let mut field_tokens = Vec::new(); - - for (field_name, expr_str) in fields { - let parsed = parser::parse_expr(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; - - let expr_ast = parsed.to_expr_tokens(); - let name_lit = field_name.as_str(); - field_tokens.push(quote! { (#name_lit, #expr_ast) }); - } - - Ok(quote! { - crate::rules::registry::ReductionOverhead::new(vec![#(#field_tokens),*]) - }) -} - -/// Generate a compiled overhead evaluation function from parsed overhead fields. -/// -/// Produces a closure that downcasts `&dyn Any` to `&SourceType`, calls getter methods -/// for each variable in the expressions, and returns a `ProblemSize`. -fn generate_overhead_eval_fn( +/// Parse one explicit exact or bound field declaration into the canonical expression DAG. +fn parse_expression_fields_to_expr( fields: &[(String, String)], - source_type: &Type, -) -> syn::Result { - let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); - - let mut field_eval_tokens = Vec::new(); - for (field_name, expr_str) in fields { - let parsed = parser::parse_expr(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; - - let eval_tokens = parsed.to_eval_tokens(&src_ident); - let name_lit = field_name.as_str(); - field_eval_tokens.push(quote! { (#name_lit, (#eval_tokens).round() as usize) }); - } - - Ok(quote! { - |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { - let #src_ident = __any_src.downcast_ref::<#source_type>().unwrap(); - crate::types::ProblemSize::new(vec![#(#field_eval_tokens),*]) - } - }) +) -> syn::Result> { + fields + .iter() + .map(|(name, source)| { + let expression = problemreductions_expr::Expr::try_parse(source).map_err(|error| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("error parsing size expression \"{source}\": {error}"), + ) + })?; + Ok(ParsedExpressionField { + name: name.clone(), + expression, + }) + }) + .collect() } -/// Generate a function that extracts the source problem's size fields from `&dyn Any`. -/// -/// Collects all variable names referenced in the overhead expressions, generates -/// getter calls for each, and returns a `ProblemSize`. -fn generate_source_size_fn( - fields: &[(String, String)], - source_type: &Type, -) -> syn::Result { - let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); +fn generate_expression_fields(fields: &[ParsedExpressionField]) -> TokenStream2 { + let field_tokens = fields.iter().map(|field| { + let expression = expr_tokens(&field.expression); + let name = field.name.as_str(); + quote! { (#name, #expression) } + }); - // Collect all unique variable names from overhead expressions - let mut var_names = std::collections::BTreeSet::new(); - for (_, expr_str) in fields { - let parsed = parser::parse_expr(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; - for v in parsed.variables() { - var_names.insert(v.to_string()); - } - } + quote! { vec![#(#field_tokens),*] } +} - let getter_tokens: Vec<_> = var_names - .iter() - .map(|var| { - let getter = syn::Ident::new(var, proc_macro2::Span::call_site()); - let name_lit = var.as_str(); - quote! { (#name_lit, #src_ident.#getter() as usize) } +/// Generate a function that measures named size fields on one endpoint. +fn generate_size_measure_fn<'a>( + names: impl IntoIterator, + problem_type: &Type, +) -> TokenStream2 { + let problem_ident = syn::Ident::new("__problem", proc_macro2::Span::call_site()); + let getter_tokens = names + .into_iter() + .collect::>() + .into_iter() + .map(|name| { + let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); + quote! { + ( + #name, + #problem_ident.#getter().to_usize().expect(concat!( + "size getter `", #name, "` returned a value outside usize" + )), + ) + } }) - .collect(); + .collect::>(); - Ok(quote! { - |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { - let #src_ident = __any_src.downcast_ref::<#source_type>().unwrap(); + quote! { + |__any_problem: &dyn std::any::Any| -> crate::types::ProblemSize { + use num_traits::ToPrimitive as _; + let #problem_ident = __any_problem.downcast_ref::<#problem_type>().unwrap(); crate::types::ProblemSize::new(vec![#(#getter_tokens),*]) } - }) + } } /// Generate the reduction entry code @@ -330,10 +546,21 @@ fn generate_reduction_entry( .ok_or_else(|| syn::Error::new_spanned(source_type, "Cannot extract source type name"))?; let target_name = extract_type_name(&target_type) .ok_or_else(|| syn::Error::new_spanned(&target_type, "Cannot extract target type name"))?; - let capabilities = if source_name == target_name { - quote! { crate::rules::EdgeCapabilities::both() } + let reduce_aggregate_fn = if attrs.identity_aggregate { + quote! { + Some(|src: &dyn std::any::Any| -> Box { + let src = src.downcast_ref::<#source_type>().unwrap_or_else(|| { + panic!( + "DynAggregateReductionResult: source type mismatch: expected `{}`, got `{}`", + std::any::type_name::<#source_type>(), + std::any::type_name_of_val(src), + ) + }); + Box::new(<#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)) + }) + } } else { - quote! { crate::rules::EdgeCapabilities::witness_only() } + quote! { None } }; // Collect generic parameter info from the impl block @@ -343,35 +570,38 @@ fn generate_reduction_entry( let source_variant_body = make_variant_fn_body(source_type, &type_generics)?; let target_variant_body = make_variant_fn_body(&target_type, &type_generics)?; - // Generate overhead, eval fn, and source size fn - let (overhead, overhead_eval_fn, source_size_fn) = match &attrs.overhead { - Some(OverheadSpec::Legacy(tokens)) => { - let eval_fn = quote! { - |_: &dyn std::any::Any| -> crate::types::ProblemSize { - panic!("overhead_eval_fn not available for legacy overhead syntax; \ - migrate to parsed syntax: field = \"expression\"") - } - }; - let size_fn = quote! { - |_: &dyn std::any::Any| -> crate::types::ProblemSize { - crate::types::ProblemSize::new(vec![]) - } - }; - (tokens.clone(), eval_fn, size_fn) + let fields = parse_expression_fields_to_expr(attrs.fields.as_deref().unwrap_or_default())?; + let field_tokens = generate_expression_fields(&fields); + let relation_tokens = match attrs.relation { + Some(SizeRelationAttr::Exact) => { + quote! { Some(crate::size::SizeRelation::Exact) } } - Some(OverheadSpec::Parsed(fields)) => { - let overhead_tokens = generate_parsed_overhead(fields)?; - let eval_fn = generate_overhead_eval_fn(fields, source_type)?; - let size_fn = generate_source_size_fn(fields, source_type)?; - (overhead_tokens, eval_fn, size_fn) - } - None => { - return Err(syn::Error::new( - proc_macro2::Span::call_site(), - "Missing overhead specification. Use #[reduction(overhead = { ... })] and specify overhead expressions for all target problem size fields.", - )); + Some(SizeRelationAttr::UpperBound) => { + quote! { Some(crate::size::SizeRelation::UpperBound) } } + None => quote! { None }, }; + let unavailable_tokens = attrs + .unavailable + .as_deref() + .unwrap_or_default() + .iter() + .map(|(field, reason)| quote! { crate::rules::registry::UnavailableSizeField { field: #field, reason: #reason } }); + let source_size_measure_fn = generate_size_measure_fn( + fields.iter().flat_map(|field| field.expression.variables()), + source_type, + ); + let target_size_measure_fn = generate_size_measure_fn( + fields.iter().map(|field| field.name.as_str()).chain( + attrs + .unavailable + .as_deref() + .unwrap_or_default() + .iter() + .map(|(field, _)| field.as_str()), + ), + &target_type, + ); // Generate the combined output let output = quote! { @@ -383,7 +613,11 @@ fn generate_reduction_entry( target_name: #target_name, source_variant_fn: || { #source_variant_body }, target_variant_fn: || { #target_variant_body }, - overhead_fn: || { #overhead }, + size_declarations_fn: || crate::rules::registry::ReductionSizeDeclarations { + relation: #relation_tokens, + fields: #field_tokens, + unavailable: vec![#(#unavailable_tokens),*], + }, module_path: module_path!(), reduce_fn: Some(|src: &dyn std::any::Any| -> Box { let src = src.downcast_ref::<#source_type>().unwrap_or_else(|| { @@ -395,10 +629,10 @@ fn generate_reduction_entry( }); Box::new(<#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)) }), - reduce_aggregate_fn: None, - capabilities: #capabilities, - overhead_eval_fn: #overhead_eval_fn, - source_size_fn: #source_size_fn, + reduce_aggregate_fn: #reduce_aggregate_fn, + turing: false, + source_size_measure_fn: #source_size_measure_fn, + target_size_measure_fn: #target_size_measure_fn, } } @@ -450,6 +684,8 @@ struct DeclareVariantEntry { ty: Type, complexity: syn::LitStr, aliases: Vec, + create_spec: Option, + random: bool, } impl syn::parse::Parse for DeclareVariantsInput { @@ -466,15 +702,14 @@ 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; + let mut random = false; + 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() { @@ -483,29 +718,36 @@ 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 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', found '{ident}'"), + format!("expected `aliases`, `create`, or `random`, found `{ident}`"), )); - } else { - Vec::new() } - } else { - Vec::new() - }; + } entries.push(DeclareVariantEntry { is_default, ty, complexity, aliases, + create_spec, + random, }); if input.peek(syn::Token![,]) { @@ -588,12 +830,14 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result = entry.aliases.iter().map(|s| s.value()).collect(); // Parse the complexity expression to validate syntax - let parsed = parser::parse_expr(&complexity_str).map_err(|e| { + let parsed = problemreductions_expr::Expr::try_parse(&complexity_str).map_err(|e| { syn::Error::new( entry.complexity.span(), format!("invalid complexity expression \"{complexity_str}\": {e}"), @@ -637,7 +881,50 @@ 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 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 } + }; + let dispatch_fields = quote! { + #construction_fields + random: #random_registration, factory: |data: serde_json::Value| -> Result, serde_json::Error> { let p: #ty = serde_json::from_value(data)?; Ok(Box::new(p)) @@ -689,11 +976,11 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result syn::Result { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); - let eval_tokens = parsed.to_eval_tokens(&src_ident); + let eval_tokens = complexity_estimate_tokens(parsed, &src_ident)?; Ok(quote! { |__any_src: &dyn std::any::Any| -> f64 { @@ -708,6 +995,15 @@ mod tests { use super::*; use syn::{parse_str, Type}; + #[test] + fn size_fields_report_expression_domain_errors() { + let fields = vec![("num_vertices".to_string(), "0 / 0".to_string())]; + let Err(error) = parse_expression_fields_to_expr(&fields) else { + panic!("invalid size expression was accepted"); + }; + assert!(error.to_string().contains("division by zero")); + } + #[test] fn extract_type_name_strips_non_decision_generics() { let ty: Type = parse_str("MinimumVertexCover").unwrap(); @@ -842,7 +1138,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`, `create`, or `random`, 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] @@ -902,7 +1286,7 @@ mod tests { fn reduction_rejects_unexpected_attribute() { let extra_attr = syn::Ident::new("extra", proc_macro2::Span::call_site()); let parse_result = syn::parse2::(quote! { - #extra_attr = "unexpected", overhead = { num_vertices = "num_vertices" } + #extra_attr = "unexpected", size = exact { num_vertices = "num_vertices" } }); let err = match parse_result { Ok(_) => panic!("unexpected reduction attribute should be rejected"), @@ -912,11 +1296,58 @@ mod tests { } #[test] - fn reduction_accepts_overhead_attribute() { + fn reduction_accepts_explicit_size_attributes() { let attrs: ReductionAttrs = syn::parse_quote! { - overhead = { n = "n" } + size = upper_bound { n = n, squared = "n^2" }, + unavailable = { encoding_bits = "coefficient magnitudes are not tracked" } }; - assert!(attrs.overhead.is_some()); + assert_eq!( + attrs.fields, + Some(vec![ + ("n".to_string(), "n".to_string()), + ("squared".to_string(), "n^2".to_string()), + ]) + ); + assert_eq!(attrs.relation, Some(SizeRelationAttr::UpperBound)); + assert_eq!( + attrs.unavailable, + Some(vec![( + "encoding_bits".into(), + "coefficient magnitudes are not tracked".into() + )]) + ); + } + + #[test] + fn reduction_rejects_legacy_overhead_attribute() { + let result = syn::parse2::(quote! { + overhead = { ReductionOverhead::default() } + }); + assert!(result.is_err()); + } + + #[test] + fn reduction_rejects_legacy_exact_and_bound_attributes() { + assert!(syn::parse2::(quote! { + exact = { n = "n" } + }) + .is_err()); + assert!(syn::parse2::(quote! { + bound = { n = "n" } + }) + .is_err()); + } + + #[test] + fn reduction_requires_unavailable_to_be_the_primary_size_declaration() { + assert!(syn::parse2::(quote! { + unavailable = { n = "not represented" } + }) + .is_err()); + assert!(syn::parse2::(quote! { + size = unavailable { n = "not represented" } + }) + .is_ok()); } #[test] diff --git a/problemreductions-macros/src/parser.rs b/problemreductions-macros/src/parser.rs deleted file mode 100644 index 36e9505cd..000000000 --- a/problemreductions-macros/src/parser.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Pratt parser for overhead expression strings. -//! -//! Parses expressions like: -//! - `"num_vertices"` -//! - `"num_vertices^2"` -//! - `"num_edges + num_vertices^2"` -//! - `"3 * num_vertices"` -//! - `"exp(num_vertices^2)"` -//! - `"sqrt(num_edges)"` -//! -//! Grammar: -//! expr = term (('+' | '-') term)* -//! term = factor (('*' | '/') factor)* -//! factor = unary ('^' factor)? // right-associative -//! unary = '-' unary | primary -//! primary = NUMBER | IDENT | func_call | '(' expr ')' -//! func_call = ('exp' | 'log' | 'sqrt' | 'factorial') '(' expr ')' - -use proc_macro2::TokenStream; -use quote::quote; - -/// Parsed expression node (intermediate representation before codegen). -#[derive(Debug, Clone, PartialEq)] -pub enum ParsedExpr { - Const(f64), - Var(String), - Add(Box, Box), - Sub(Box, Box), - Mul(Box, Box), - Div(Box, Box), - Pow(Box, Box), - Neg(Box), - Exp(Box), - Log(Box), - Sqrt(Box), - Factorial(Box), -} - -#[derive(Debug, Clone, PartialEq)] -enum Token { - Number(f64), - Ident(String), - Plus, - Minus, - Star, - Slash, - Caret, - LParen, - RParen, -} - -fn tokenize(input: &str) -> Result, String> { - let mut tokens = Vec::new(); - let mut chars = input.chars().peekable(); - while let Some(&ch) = chars.peek() { - match ch { - ' ' | '\t' | '\n' => { - chars.next(); - } - '+' => { - chars.next(); - tokens.push(Token::Plus); - } - '-' => { - chars.next(); - tokens.push(Token::Minus); - } - '*' => { - chars.next(); - tokens.push(Token::Star); - } - '/' => { - chars.next(); - tokens.push(Token::Slash); - } - '^' => { - chars.next(); - tokens.push(Token::Caret); - } - '(' => { - chars.next(); - tokens.push(Token::LParen); - } - ')' => { - chars.next(); - tokens.push(Token::RParen); - } - c if c.is_ascii_digit() || c == '.' => { - let mut num = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() || c == '.' { - num.push(c); - chars.next(); - } else { - break; - } - } - let val: f64 = num.parse().map_err(|_| format!("invalid number: {num}"))?; - tokens.push(Token::Number(val)); - } - c if c.is_ascii_alphabetic() || c == '_' => { - let mut ident = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_alphanumeric() || c == '_' { - ident.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(Token::Ident(ident)); - } - _ => return Err(format!("unexpected character: '{ch}'")), - } - } - Ok(tokens) -} - -struct Parser { - tokens: Vec, - pos: usize, -} - -impl Parser { - fn new(tokens: Vec) -> Self { - Self { tokens, pos: 0 } - } - - fn peek(&self) -> Option<&Token> { - self.tokens.get(self.pos) - } - - fn advance(&mut self) -> Option { - let tok = self.tokens.get(self.pos).cloned(); - self.pos += 1; - tok - } - - fn expect(&mut self, expected: &Token) -> Result<(), String> { - match self.advance() { - Some(ref tok) if tok == expected => Ok(()), - Some(tok) => Err(format!("expected {expected:?}, got {tok:?}")), - None => Err(format!("expected {expected:?}, got end of input")), - } - } - - fn parse_expr(&mut self) -> Result { - let mut left = self.parse_term()?; - while matches!(self.peek(), Some(Token::Plus) | Some(Token::Minus)) { - let op = self.advance().unwrap(); - let right = self.parse_term()?; - left = match op { - Token::Plus => ParsedExpr::Add(Box::new(left), Box::new(right)), - Token::Minus => ParsedExpr::Sub(Box::new(left), Box::new(right)), - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_term(&mut self) -> Result { - let mut left = self.parse_factor()?; - while matches!(self.peek(), Some(Token::Star) | Some(Token::Slash)) { - let op = self.advance().unwrap(); - let right = self.parse_factor()?; - left = match op { - Token::Star => ParsedExpr::Mul(Box::new(left), Box::new(right)), - Token::Slash => ParsedExpr::Div(Box::new(left), Box::new(right)), - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_factor(&mut self) -> Result { - let base = self.parse_unary()?; - if matches!(self.peek(), Some(Token::Caret)) { - self.advance(); - let exp = self.parse_factor()?; // right-associative - Ok(ParsedExpr::Pow(Box::new(base), Box::new(exp))) - } else { - Ok(base) - } - } - - fn parse_unary(&mut self) -> Result { - if matches!(self.peek(), Some(Token::Minus)) { - self.advance(); - let expr = self.parse_unary()?; - Ok(ParsedExpr::Neg(Box::new(expr))) - } else { - self.parse_primary() - } - } - - fn parse_primary(&mut self) -> Result { - match self.advance() { - Some(Token::Number(n)) => Ok(ParsedExpr::Const(n)), - Some(Token::Ident(name)) => { - // Check for function call: exp(...), log(...), sqrt(...) - if matches!(self.peek(), Some(Token::LParen)) { - self.advance(); // consume '(' - let arg = self.parse_expr()?; - self.expect(&Token::RParen)?; - match name.as_str() { - "exp" => Ok(ParsedExpr::Exp(Box::new(arg))), - "log" => Ok(ParsedExpr::Log(Box::new(arg))), - "sqrt" => Ok(ParsedExpr::Sqrt(Box::new(arg))), - "factorial" => Ok(ParsedExpr::Factorial(Box::new(arg))), - _ => Err(format!("unknown function: {name}")), - } - } else { - Ok(ParsedExpr::Var(name)) - } - } - Some(Token::LParen) => { - let expr = self.parse_expr()?; - self.expect(&Token::RParen)?; - Ok(expr) - } - Some(tok) => Err(format!("unexpected token: {tok:?}")), - None => Err("unexpected end of input".to_string()), - } - } -} - -/// Parse an expression string into a ParsedExpr. -pub fn parse_expr(input: &str) -> Result { - let tokens = tokenize(input)?; - let mut parser = Parser::new(tokens); - let expr = parser.parse_expr()?; - if parser.pos != parser.tokens.len() { - return Err(format!( - "unexpected trailing tokens at position {}", - parser.pos - )); - } - Ok(expr) -} - -impl ParsedExpr { - /// Generate TokenStream that constructs an `Expr` value. - pub fn to_expr_tokens(&self) -> TokenStream { - match self { - ParsedExpr::Const(c) => quote! { crate::expr::Expr::Const(#c) }, - ParsedExpr::Var(name) => quote! { crate::expr::Expr::Var(#name) }, - ParsedExpr::Add(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) + (#b) } - } - ParsedExpr::Sub(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) - (#b) } - } - ParsedExpr::Mul(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) * (#b) } - } - ParsedExpr::Div(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) / (#b) } - } - ParsedExpr::Pow(base, exp) => { - let base = base.to_expr_tokens(); - let exp = exp.to_expr_tokens(); - quote! { crate::expr::Expr::pow(#base, #exp) } - } - ParsedExpr::Neg(a) => { - let a = a.to_expr_tokens(); - quote! { -(#a) } - } - ParsedExpr::Exp(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Exp(Box::new(#a)) } - } - ParsedExpr::Log(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Log(Box::new(#a)) } - } - ParsedExpr::Sqrt(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Sqrt(Box::new(#a)) } - } - ParsedExpr::Factorial(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Factorial(Box::new(#a)) } - } - } - } - - /// Generate TokenStream that evaluates the expression by calling getter methods - /// on a source variable `src`. - pub fn to_eval_tokens(&self, src_ident: &syn::Ident) -> TokenStream { - match self { - ParsedExpr::Const(c) => quote! { (#c as f64) }, - ParsedExpr::Var(name) => { - let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); - quote! { (#src_ident.#getter() as f64) } - } - ParsedExpr::Add(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a + #b) } - } - ParsedExpr::Sub(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a - #b) } - } - ParsedExpr::Mul(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a * #b) } - } - ParsedExpr::Div(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a / #b) } - } - ParsedExpr::Pow(base, exp) => { - let base = base.to_eval_tokens(src_ident); - let exp = exp.to_eval_tokens(src_ident); - quote! { f64::powf(#base, #exp) } - } - ParsedExpr::Neg(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { (-(#a)) } - } - ParsedExpr::Exp(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::exp(#a) } - } - ParsedExpr::Log(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::ln(#a) } - } - ParsedExpr::Sqrt(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::sqrt(#a) } - } - ParsedExpr::Factorial(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { { - let __n = #a; - let __r = __n.round(); - if (__n - __r).abs() < 1e-10 && __r >= 0.0 { - let mut __f = 1u64; - let __k = __r as u64; - let mut __i = 2u64; - while __i <= __k { __f = __f.saturating_mul(__i); __i += 1; } - __f as f64 - } else { - (2.0 * ::std::f64::consts::PI * __n).sqrt() * (__n / ::std::f64::consts::E).powf(__n) - } - } } - } - } - } - - /// Collect all variable names in the expression. - pub fn variables(&self) -> Vec { - let mut vars = Vec::new(); - self.collect_vars(&mut vars); - vars.sort(); - vars.dedup(); - vars - } - - fn collect_vars(&self, vars: &mut Vec) { - match self { - ParsedExpr::Const(_) => {} - ParsedExpr::Var(name) => vars.push(name.clone()), - ParsedExpr::Add(a, b) - | ParsedExpr::Sub(a, b) - | ParsedExpr::Mul(a, b) - | ParsedExpr::Div(a, b) - | ParsedExpr::Pow(a, b) => { - a.collect_vars(vars); - b.collect_vars(vars); - } - ParsedExpr::Neg(a) - | ParsedExpr::Exp(a) - | ParsedExpr::Log(a) - | ParsedExpr::Sqrt(a) - | ParsedExpr::Factorial(a) => { - a.collect_vars(vars); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_var() { - assert_eq!( - parse_expr("num_vertices").unwrap(), - ParsedExpr::Var("num_vertices".into()) - ); - } - - #[test] - fn test_parse_const() { - assert_eq!(parse_expr("42").unwrap(), ParsedExpr::Const(42.0)); - } - - #[test] - fn test_parse_pow() { - let e = parse_expr("n^2").unwrap(); - assert_eq!( - e, - ParsedExpr::Pow( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Const(2.0)), - ) - ); - } - - #[test] - fn test_parse_add_mul() { - // n + 3 * m → n + (3*m) - let e = parse_expr("n + 3 * m").unwrap(); - assert_eq!( - e, - ParsedExpr::Add( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Mul( - Box::new(ParsedExpr::Const(3.0)), - Box::new(ParsedExpr::Var("m".into())), - )), - ) - ); - } - - #[test] - fn test_parse_exp() { - let e = parse_expr("exp(n^2)").unwrap(); - assert_eq!( - e, - ParsedExpr::Exp(Box::new(ParsedExpr::Pow( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Const(2.0)), - ))) - ); - } - - #[test] - fn test_parse_complex() { - // 3 * n^2 + exp(m) — should parse correctly - let e = parse_expr("3 * n^2 + exp(m)").unwrap(); - assert!(matches!(e, ParsedExpr::Add(_, _))); - } - - #[test] - fn test_parse_parens() { - let e = parse_expr("(n + m)^2").unwrap(); - assert!(matches!(e, ParsedExpr::Pow(_, _))); - } - - #[test] - fn test_variables() { - let e = parse_expr("n^2 + 3 * m + exp(k)").unwrap(); - assert_eq!(e.variables(), vec!["k", "m", "n"]); - } - - #[test] - fn test_parse_neg() { - let e = parse_expr("-n").unwrap(); - assert_eq!(e, ParsedExpr::Neg(Box::new(ParsedExpr::Var("n".into())))); - } - - #[test] - fn test_parse_sub() { - let e = parse_expr("n - m").unwrap(); - assert_eq!( - e, - ParsedExpr::Sub( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Var("m".into())), - ) - ); - } -} diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py new file mode 100644 index 000000000..082136b82 --- /dev/null +++ b/scripts/generate_symbolic_expr_fixture.py @@ -0,0 +1,298 @@ +"""Generate the symbolic-expression conformance fixture with SymPy. + +The committed fixture lets Rust tests use SymPy as an independent semantic +oracle without adding Python to the Rust build or test environment. + +Usage: + uv run --project scripts python scripts/generate_symbolic_expr_fixture.py +""" + +import json +import math +from pathlib import Path + +import sympy +from sympy.parsing.sympy_parser import ( + convert_xor, + parse_expr, + rationalize, + standard_transformations, +) + + +OUTPUT = ( + Path(__file__).resolve().parents[1] + / "problemreductions-expr" + / "tests" + / "fixtures" + / "sympy_oracle.json" +) +TRANSFORMATIONS = standard_transformations + (convert_xor, rationalize) + + +# The final boolean selects cases where SymPy's mathematical polynomial +# predicate and this crate's deliberately syntactic predicate have the same +# contract. Every case still participates in variable and exact-value checks. +CASES = [ + ("zero", "0", {}, True), + ("integer", "42", {}, True), + ("exact_decimal", "2.372", {}, True), + ("leading_decimal_point", ".125", {}, True), + ( + "arbitrary_precision_integer", + "100000000000000000000000000000000000000000000000001", + {}, + True, + ), + ("variable", "n", {"n": 7}, True), + ("negation", "-n", {"n": 7}, True), + ("addition", "n + m", {"n": 3, "m": 4}, True), + ("subtraction", "n - m", {"n": 3, "m": 7}, True), + ("multiplication", "n * m", {"n": 6, "m": 7}, True), + ("rational_coefficient", "n / 2", {"n": 3}, True), + ("variable_divisor", "n / m", {"n": 12, "m": 5}, True), + ("nested_divisor", "n / (m + 1)", {"n": 10, "m": 4}, True), + ("exact_size_formula", "n * (n - 1) / 2 - m", {"n": 5, "m": 4}, True), + ("zero_power", "n^0", {"n": 9}, True), + ("integer_power", "n^3", {"n": 4}, True), + ("negative_power", "2^-3", {}, False), + ("symbolic_exponent", "2^n", {"n": 10}, True), + ("unary_precedence", "-n^2", {"n": 3}, True), + ("parenthesized_negative_base", "(-n)^2", {"n": 3}, True), + ("fractional_power", "n^0.5", {"n": 81}, True), + ("square_root", "sqrt(n)", {"n": 81}, True), + ("pythagorean_root", "sqrt(n^2 + m^2)", {"n": 3, "m": 4}, True), + ("exponential_identity", "exp(n)", {"n": 0}, True), + ("logarithm_identity", "log(n)", {"n": 1}, True), + ("factorial", "factorial(n)", {"n": 6}, True), + ("factorial_subexpression", "factorial(n - 1)", {"n": 6}, True), + ("decimal_scaling", "2.372 * n", {"n": 1000}, True), + ("difference_of_squares", "(n + m) * (n - m)", {"n": 10, "m": 3}, True), + ( + "multivariate_polynomial", + "n^2 + 2 * n * m + m^2", + {"n": 3, "m": 4}, + True, + ), + ("nested_rational", "n / (2 * m)", {"n": 12, "m": 3}, True), + ( + "long_decimal", + "1.0000000000000000000000000000000000000001", + {}, + True, + ), + ("nested_subtraction", "n - (m - k)", {"n": 10, "m": 7, "k": 2}, True), + ("left_subtraction", "(n - m) - k", {"n": 10, "m": 7, "k": 2}, True), + ("nested_division", "n / (m / k)", {"n": 12, "m": 6, "k": 3}, True), + ("left_division", "(n / m) / k", {"n": 12, "m": 6, "k": 2}, True), + ("right_associative_power", "n^(m^k)", {"n": 2, "m": 3, "k": 2}, True), + ("parenthesized_power", "(n^m)^k", {"n": 2, "m": 3, "k": 2}, True), + ("double_negation", "--n", {"n": 7}, True), + ("zero_factorial", "factorial(0)", {}, False), + ("zero_square_root", "sqrt(0)", {}, False), + ( + "constant_functions", + "exp(0) + log(1) + factorial(5)", + {}, + False, + ), + ("zero_product", "n * 0 + 7", {"n": 999}, True), + ("self_division", "n / n", {"n": 5}, True), + ("identity_power", "n^1", {"n": 13}, True), + ("decimal_integer_power", "n^2.0", {"n": 9}, True), + ("decimal_sum", "0.1 + 0.2", {}, True), + ( + "large_mixed_decimal", + "99999999999999999999.00000000000000000001", + {}, + True, + ), + ("identifier_shapes", "n_1 + size2", {"n_1": 8, "size2": 9}, True), + ("mixed_precedence", "n + m * k^2", {"n": 1, "m": 2, "k": 3}, True), +] + + +# These cases exercise the production f64 boundary. Expected values are emitted +# at 80 decimal digits so the Rust test, rather than Python's float conversion, +# performs the final rounding to f64. +APPROXIMATE_CASES = [ + ("exp_one", "exp(1)", {}), + ("exp_fraction", "exp(n / 3)", {"n": 5}), + ("log_two", "log(2)", {}), + ("log_large", "log(1000000)", {}), + ("sqrt_two", "sqrt(2)", {}), + ("sqrt_large", "sqrt(1234567)", {}), + ("fractional_power", "7^2.372", {}), + ("mixed_transcendental", "exp(log(n)) + sqrt(m)", {"n": 13, "m": 2}), + ("complexity_formula", "2^(2.372 * n / 3)", {"n": 19}), + ("factorial_ten", "factorial(10)", {}), + ("factorial_f64_boundary", "factorial(170)", {}), + ("factorial_f64_overflow", "factorial(171)", {}), +] + + +# Univariate, eventually positive cases where asymptotic order is decided by +# the exact limit of left / right as n tends to positive infinity. +GROWTH_CASES = [ + ("constant_factor", "3 * n^2", "n^2"), + ("lower_order_sum", "n^2 + n", "n^2"), + ("shifted_power", "(n + 1)^2", "n^2"), + ("log_constant_power", "log(n^3)", "log(n)"), + ("higher_polynomial_degree", "n^3", "n^2"), + ("polynomial_over_log", "n", "log(n)^5"), + ("polylog_tie_break", "n^3 * log(n)", "n^3"), + ("small_base_exponential", "1.001^n", "n^100"), + ("exponential_base", "3^n", "2^n"), + ("exponential_rate", "2^(2 * n)", "2^n"), + ("natural_exponential", "exp(n)", "n^100"), + ("exponential_poly_tie_break", "2^n * n", "2^n"), + ("reverse_polynomial_degree", "n", "n^2"), + ("reverse_exponential", "n^100", "exp(n)"), +] + + +FACTORIAL_ARGUMENTS = ["0", "1", "10", "170", "171", "-1", "3.5", "1 / 2"] + + +def parse(source: str) -> sympy.Expr: + return parse_expr(source, transformations=TRANSFORMATIONS, evaluate=False) + + +def exact_fraction(value: sympy.Expr) -> str: + value = value.doit() + if value.is_Rational is not True: + raise ValueError(f"fixture result is not exact rational: {value!r}") + numerator, denominator = value.as_numer_denom() + return f"{numerator}/{denominator}" + + +def generate_case( + name: str, + source: str, + bindings: dict[str, int], + compare_polynomial: bool, +) -> dict: + expression = parse(source) + source_symbols = sorted(str(symbol) for symbol in expression.free_symbols) + if set(source_symbols) != set(bindings): + raise ValueError(f"{name} bindings do not match free symbols") + canonical = sympy.simplify(expression) + symbols = sorted(str(symbol) for symbol in canonical.free_symbols) + substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} + result = expression.subs(substitutions) + polynomial = canonical.is_polynomial( + *(sympy.Symbol(name) for name in symbols) + ) + return { + "name": name, + "source": source, + "variables": symbols, + "bindings": bindings, + "exact_result": exact_fraction(result), + "compare_polynomial": compare_polynomial, + "is_polynomial": polynomial is True, + } + + +def generate_approximate_case( + name: str, + source: str, + bindings: dict[str, int], +) -> dict: + expression = parse(source) + symbols = sorted(str(symbol) for symbol in expression.free_symbols) + if set(symbols) != set(bindings): + raise ValueError(f"{name} bindings do not match free symbols") + substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} + result = expression.subs(substitutions).doit() + if result.is_real is not True or result.is_finite is not True: + raise ValueError(f"{name} result is not a finite real number: {result!r}") + return { + "name": name, + "source": source, + "bindings": bindings, + "decimal_result": str(sympy.N(result, 80)), + "finite_f64": math.isfinite(float(result)), + } + + +def generate_growth_case(name: str, left: str, right: str) -> dict: + variable = sympy.Symbol("n", positive=True) + local_dict = {"n": variable} + left_expression = parse_expr( + left, + local_dict=local_dict, + transformations=TRANSFORMATIONS, + evaluate=False, + ) + right_expression = parse_expr( + right, + local_dict=local_dict, + transformations=TRANSFORMATIONS, + evaluate=False, + ) + ratio_limit = sympy.limit(left_expression / right_expression, variable, sympy.oo) + if ratio_limit == 0: + relation = "right_dominates" + elif ratio_limit == sympy.oo: + relation = "left_dominates" + elif ratio_limit.is_positive is True and ratio_limit.is_finite is True: + relation = "equivalent" + else: + raise ValueError(f"{name} has unsupported ratio limit {ratio_limit!r}") + return { + "name": name, + "left": left, + "right": right, + "ratio_limit": str(ratio_limit), + "relation": relation, + } + + +def generate_factorial_domain_case(source: str) -> dict: + argument = parse(source).doit() + accepted = argument.is_integer is True and argument.is_nonnegative is True + return { + "source": source, + "exact_argument": str(argument), + "accepted": accepted, + "finite_f64": bool(accepted and argument <= 170), + } + + +def main() -> None: + if sympy.__version__ != "1.14.0": + raise RuntimeError(f"expected SymPy 1.14.0, found {sympy.__version__}") + fixture = { + "oracle": { + "engine": "SymPy", + "version": sympy.__version__, + "parse_evaluate": False, + "polynomial_mode": "simplify before classification", + "decimal_mode": "rationalize base-10 spelling", + "documentation": { + "parser": "https://docs.sympy.org/latest/modules/parsing.html", + "expression_core": "https://docs.sympy.org/latest/modules/core.html", + }, + }, + "cases": [generate_case(*case) for case in CASES], + "approximate_cases": [ + generate_approximate_case(*case) for case in APPROXIMATE_CASES + ], + "growth_cases": [generate_growth_case(*case) for case in GROWTH_CASES], + "factorial_domain_cases": [ + generate_factorial_domain_case(source) for source in FACTORIAL_ARGUMENTS + ], + } + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(fixture, indent=2) + "\n", encoding="utf-8") + print( + f"wrote {len(fixture['cases'])} exact and " + f"{len(fixture['approximate_cases'])} approximate and " + f"{len(fixture['growth_cases'])} growth cases plus " + f"{len(fixture['factorial_domain_cases'])} factorial domain cases to {OUTPUT}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index a61d0e94f..d35725258 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -6,4 +6,5 @@ requires-python = ">=3.12" dependencies = [ "numpy>=1.26,<2", "qubogen>=0.1.1", + "sympy==1.14.0", ] diff --git a/scripts/uv.lock b/scripts/uv.lock index 58b3004f7..952679aca 100644 --- a/scripts/uv.lock +++ b/scripts/uv.lock @@ -1,7 +1,16 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -46,10 +55,24 @@ source = { virtual = "." } dependencies = [ { name = "numpy" }, { name = "qubogen" }, + { name = "sympy" }, ] [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=1.26,<2" }, { name = "qubogen", specifier = ">=0.1.1" }, + { name = "sympy", specifier = "==1.14.0" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] diff --git a/src/big_o.rs b/src/big_o.rs index 1b782d862..d88959319 100644 --- a/src/big_o.rs +++ b/src/big_o.rs @@ -1,387 +1,33 @@ -//! Big-O asymptotic projection for canonical expressions. +//! Big-O asymptotic normal form. //! -//! Takes the output of `canonical_form()` and projects it into an -//! asymptotic growth class by dropping dominated terms and constant factors. +//! Thin wrapper over the [growth domain](crate::growth): compute the growth +//! class of an expression bottom-up (without fully distributing the source AST) and +//! render it back to a display [`Expr`]. Content the growth domain cannot bound +//! symbolically ([`Growth::Unknown`] — nonlinear exponents, factorials, negative +//! exponents) maps to the [`AsymptoticAnalysisError::Unsupported`] error. -use crate::canonical::canonical_form; -use crate::expr::{AsymptoticAnalysisError, CanonicalizationError, Expr}; - -#[derive(Clone, Debug)] -struct ProjectedTerm { - expr: Expr, - negative: bool, -} +use crate::expr::{AsymptoticAnalysisError, Expr}; +use crate::growth::Growth; /// Compute the Big-O normal form of an expression. /// -/// This is a two-phase pipeline: -/// 1. `canonical_form()` — exact symbolic simplification -/// 2. Asymptotic projection — drop dominated terms and constant factors -/// -/// Returns an expression representing the asymptotic growth class. +/// Returns an expression representing the asymptotic growth class, or +/// [`AsymptoticAnalysisError::Unsupported`] when the growth domain widens the +/// input to [`Growth::Unknown`]. pub fn big_o_normal_form(expr: &Expr) -> Result { - let canonical = canonical_form(expr).map_err(|e| match e { - CanonicalizationError::Unsupported(s) => AsymptoticAnalysisError::Unsupported(s), - })?; - - project_big_o(&canonical) -} - -/// Project a canonicalized expression into its Big-O growth class. -fn project_big_o(expr: &Expr) -> Result { - // Decompose into additive terms - let mut terms = Vec::new(); - collect_additive_terms(expr, &mut terms); - - // Project each term: drop constant multiplicative factors - let mut projected: Vec = Vec::new(); - for term in &terms { - if let Some(projected_term) = project_term(term)? { - projected.push(projected_term); - } - // Pure constants are dropped (asymptotically irrelevant) - } - - // Remove dominated terms - let survivors = remove_dominated_terms(projected); - - if survivors.is_empty() { - // All terms were constants → O(1) - return Ok(Expr::Const(1.0)); - } - - if let Some(negative) = survivors.iter().find(|term| term.negative) { - return Err(AsymptoticAnalysisError::Unsupported(format!( - "-1 * {}", - negative.expr - ))); - } - - // Deduplicate - let mut seen = std::collections::BTreeSet::new(); - let mut deduped = Vec::new(); - for term in survivors { - let key = term.expr.to_string(); - if seen.insert(key) { - deduped.push(term); - } - } - - // Rebuild sum - let mut result = deduped[0].expr.clone(); - for term in &deduped[1..] { - result = result + term.expr.clone(); - } - - Ok(result) -} - -fn collect_additive_terms(expr: &Expr, out: &mut Vec) { - match expr { - Expr::Add(a, b) => { - collect_additive_terms(a, out); - collect_additive_terms(b, out); - } - other => out.push(other.clone()), - } -} - -/// Project a single multiplicative term: strip constant factors. -/// Returns None if the term is a pure constant. -fn project_term(term: &Expr) -> Result, AsymptoticAnalysisError> { - if term.constant_value().is_some() { - return Ok(None); // Pure constant → dropped - } - - // Collect multiplicative factors - let mut factors = Vec::new(); - collect_multiplicative_factors(term, &mut factors); - - let mut coeff = 1.0; - let mut symbolic = Vec::new(); - for factor in &factors { - if let Some(c) = factor.constant_value() { - coeff *= c; - continue; - } - if contains_negative_exponent(factor) { - return Err(AsymptoticAnalysisError::Unsupported(term.to_string())); - } - symbolic.push(factor.clone()); - } - - if symbolic.is_empty() { - return Ok(None); - } - - let mut result = symbolic[0].clone(); - for f in &symbolic[1..] { - result = result * f.clone(); - } - - Ok(Some(ProjectedTerm { - expr: result, - negative: coeff < 0.0, - })) -} - -fn collect_multiplicative_factors(expr: &Expr, out: &mut Vec) { - match expr { - Expr::Mul(a, b) => { - collect_multiplicative_factors(a, out); - collect_multiplicative_factors(b, out); - } - other => out.push(other.clone()), - } -} - -/// Remove terms dominated by other terms using monomial comparison. -/// -/// A term `t` is dominated if there exists another term `s` such that -/// `t` grows no faster than `s` asymptotically. -fn remove_dominated_terms(terms: Vec) -> Vec { - if terms.len() <= 1 { - return terms; - } - - let mut survivors = Vec::new(); - for (i, term) in terms.iter().enumerate() { - let is_dominated = terms - .iter() - .enumerate() - .any(|(j, other)| i != j && term_dominated_by(&term.expr, &other.expr)); - if !is_dominated { - survivors.push(term.clone()); - } - } - survivors -} - -/// Check if `small` is asymptotically dominated by `big`. -/// -/// Supports three comparison strategies: -/// 1. Polynomial monomial exponent comparison (exact) -/// 2. Exponential vs subexponential / base comparison (structural) -/// 3. Numerical evaluation at two scales (for subexponential cross-class) -fn term_dominated_by(small: &Expr, big: &Expr) -> bool { - // Case 1: Both pure polynomial monomials — use exponent comparison - let small_exps = extract_var_exponents(small); - let big_exps = extract_var_exponents(big); - if let (Some(ref se), Some(ref be)) = (small_exps, big_exps) { - return polynomial_dominated(se, be); + let growth = Growth::from_expr(expr); + match growth.to_expr() { + Some(expression) => Ok(expression), + None => Err(AsymptoticAnalysisError::Unsupported( + growth + .failures() + .expect("growth without an expression must contain failure reasons") + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "), + )), } - - // Cross-class comparison: small's variables must be a subset of big's - let small_vars = small.variables(); - let big_vars = big.variables(); - if small_vars.is_empty() || big_vars.is_empty() || !small_vars.is_subset(&big_vars) { - return false; - } - - // Case 2: Exponential comparison - let small_has_exp = has_exponential_growth(small); - let big_has_exp = has_exponential_growth(big); - match (small_has_exp, big_has_exp) { - (false, true) => return true, // exponential dominates subexponential - (true, false) => return false, // subexponential can't dominate exponential - (true, true) => { - // Compare effective exponential bases - if let (Some(sb), Some(bb)) = (effective_exp_base(small), effective_exp_base(big)) { - if bb > sb * (1.0 + 1e-10) { - return true; - } - } - return false; - } - (false, false) => {} // both subexponential, fall through - } - - // Case 3: Both subexponential, same variables — numerical comparison - // Handles: poly vs poly*log, log vs log(log), poly vs log, etc. - if small_vars == big_vars { - return numerical_dominance_check(small, big, &small_vars); - } - - false -} - -/// Check polynomial dominance: small ≤ big component-wise with at least one strict inequality. -fn polynomial_dominated( - se: &std::collections::BTreeMap<&'static str, f64>, - be: &std::collections::BTreeMap<&'static str, f64>, -) -> bool { - let mut all_leq = true; - let mut any_strictly_less = false; - - for (var, small_exp) in se { - let big_exp = be.get(var).copied().unwrap_or(0.0); - if *small_exp > big_exp + 1e-15 { - all_leq = false; - break; - } - if *small_exp < big_exp - 1e-15 { - any_strictly_less = true; - } - } - - if all_leq { - for (var, big_exp) in be { - if !se.contains_key(var) && *big_exp > 1e-15 { - any_strictly_less = true; - } - } - } - - all_leq && any_strictly_less -} - -/// Extract variable → exponent mapping from a monomial expression. -/// Returns None for non-polynomial terms (exp, log, etc.). -fn extract_var_exponents(expr: &Expr) -> Option> { - use std::collections::BTreeMap; - let mut exps = BTreeMap::new(); - extract_var_exponents_inner(expr, &mut exps)?; - Some(exps) -} - -fn extract_var_exponents_inner( - expr: &Expr, - exps: &mut std::collections::BTreeMap<&'static str, f64>, -) -> Option<()> { - match expr { - Expr::Var(name) => { - *exps.entry(name).or_insert(0.0) += 1.0; - Some(()) - } - Expr::Pow(base, exp) => { - if let (Expr::Var(name), Some(e)) = (base.as_ref(), exp.constant_value()) { - if e < 0.0 { - return None; - } - *exps.entry(name).or_insert(0.0) += e; - Some(()) - } else { - None // Non-simple power - } - } - Expr::Mul(a, b) => { - extract_var_exponents_inner(a, exps)?; - extract_var_exponents_inner(b, exps) - } - Expr::Const(_) => Some(()), // Constants don't affect exponents - _ => None, // exp, log, sqrt → not a polynomial monomial - } -} - -fn contains_negative_exponent(expr: &Expr) -> bool { - match expr { - Expr::Pow(_, exp) => exp.constant_value().is_some_and(|e| e < 0.0), - Expr::Mul(a, b) | Expr::Add(a, b) => { - contains_negative_exponent(a) || contains_negative_exponent(b) - } - Expr::Exp(arg) | Expr::Log(arg) | Expr::Sqrt(arg) | Expr::Factorial(arg) => { - contains_negative_exponent(arg) - } - Expr::Const(_) | Expr::Var(_) => false, - } -} - -/// Check if an expression has exponential growth. -/// -/// Returns true if the expression contains `exp(var_expr)` or `c^(var_expr)` where c > 1. -fn has_exponential_growth(expr: &Expr) -> bool { - match expr { - Expr::Exp(arg) => !arg.variables().is_empty(), - Expr::Pow(base, exp) => { - base.constant_value().is_some_and(|c| c > 1.0) && !exp.variables().is_empty() - } - Expr::Mul(a, b) => has_exponential_growth(a) || has_exponential_growth(b), - _ => false, - } -} - -/// Compute the effective exponential base for growth rate comparison. -/// -/// For `c^(f(n))`, approximates the effective base as `c^(f(1))`. -/// This works correctly for linear exponents (the common case in complexity expressions). -fn effective_exp_base(expr: &Expr) -> Option { - match expr { - Expr::Exp(arg) => { - let vars = arg.variables(); - if vars.is_empty() { - None - } else { - let size = unit_problem_size(&vars); - let rate = arg.eval(&size); - Some(std::f64::consts::E.powf(rate)) - } - } - Expr::Pow(base, exp) => { - if let Some(c) = base.constant_value() { - let vars = exp.variables(); - if c > 1.0 && !vars.is_empty() { - let size = unit_problem_size(&vars); - let exp_at_1 = exp.eval(&size); - Some(c.powf(exp_at_1)) - } else { - None - } - } else { - None - } - } - Expr::Mul(a, b) => match (effective_exp_base(a), effective_exp_base(b)) { - (Some(ba), Some(bb)) => Some(ba * bb), - (Some(b), None) | (None, Some(b)) => Some(b), - (None, None) => None, - }, - _ => None, - } -} - -/// Create a `ProblemSize` with all variables set to the given value. -fn make_problem_size( - vars: &std::collections::HashSet<&'static str>, - val: usize, -) -> crate::types::ProblemSize { - crate::types::ProblemSize::new(vars.iter().map(|&v| (v, val)).collect()) -} - -/// Create a `ProblemSize` with all variables set to 1. -fn unit_problem_size(vars: &std::collections::HashSet<&'static str>) -> crate::types::ProblemSize { - make_problem_size(vars, 1) -} - -/// Check dominance numerically by evaluating at two scales. -/// -/// Returns true if `big/small` ratio is > 1 and increasing between the two -/// evaluation points, indicating `big` grows asymptotically faster. -fn numerical_dominance_check( - small: &Expr, - big: &Expr, - vars: &std::collections::HashSet<&'static str>, -) -> bool { - let size1 = make_problem_size(vars, 100); - let size2 = make_problem_size(vars, 10_000); - - let s1 = small.eval(&size1); - let b1 = big.eval(&size1); - let s2 = small.eval(&size2); - let b2 = big.eval(&size2); - - // Both must be finite and positive at both points - if !s1.is_finite() || !b1.is_finite() || !s2.is_finite() || !b2.is_finite() { - return false; - } - if s1 <= 1e-300 || b1 <= 1e-300 || s2 <= 1e-300 || b2 <= 1e-300 { - return false; - } - - let ratio1 = b1 / s1; - let ratio2 = b2 / s2; - - // Dominance: ratio is > 1 at both points and strictly increasing - ratio1 > 1.0 + 1e-10 && ratio2 > ratio1 * (1.0 + 1e-6) } #[cfg(test)] diff --git a/src/canonical.rs b/src/canonical.rs deleted file mode 100644 index 4f8c73ca7..000000000 --- a/src/canonical.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Exact symbolic canonicalization for `Expr`. -//! -//! Normalizes expressions into a canonical sum-of-terms form with signed -//! coefficients and deterministic ordering, without losing algebraic precision. - -use std::collections::BTreeMap; - -use crate::expr::{CanonicalizationError, Expr}; - -/// Hard cap on the number of additive terms produced while expanding an -/// expression into canonical sum-of-monomials form. -/// -/// Expanding a nested `(sum)^2 * (sum)^2` structure is exponential in nesting -/// depth: composed-path overheads that traverse quadratic-overhead reductions -/// (e.g. `QuadraticAssignment`) blow up to multi-GB of monomials and OOM/hang. -/// When the intermediate term count would exceed this cap we abandon expansion -/// and report the expression as `Unsupported`; callers (e.g. `big_o_of`) fall -/// back to printing the compact, un-expanded expression. See issue #1069. -/// -/// Legitimate overhead expressions stay far below this bound (the worst -/// non-pathological case is a few hundred terms), so this never affects normal -/// output — it only stops pathological blowups. This is a stopgap guard; the -/// symbolic system is slated for a larger rework. -const MAX_CANONICAL_TERMS: usize = 50_000; - -/// An opaque non-polynomial factor (exp, log, fractional-power base). -/// -/// Stored by its canonical string representation for deterministic ordering. -#[derive(Clone, Debug, PartialEq)] -struct OpaqueFactor { - /// The canonical string form (used for equality and ordering). - key: String, - /// The original `Expr` for reconstruction. - expr: Expr, -} - -impl Eq for OpaqueFactor {} - -impl PartialOrd for OpaqueFactor { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for OpaqueFactor { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.key.cmp(&other.key) - } -} - -fn normalized_f64_bits(value: f64) -> u64 { - if value == 0.0 { - 0.0f64.to_bits() - } else { - value.to_bits() - } -} - -/// A single additive term: coefficient × product of canonical factors. -#[derive(Clone, Debug)] -struct CanonicalTerm { - /// Signed numeric coefficient. - coeff: f64, - /// Polynomial variable exponents (variable_name → exponent). - vars: BTreeMap<&'static str, f64>, - /// Non-polynomial opaque factors, sorted by key. - opaque: Vec, -} - -/// Try to merge a new opaque factor into an existing list using transcendental identities. -/// Returns `Some(updated_list)` if a merge happened, `None` if no identity applies. -fn try_merge_opaque(existing: &[OpaqueFactor], new: &OpaqueFactor) -> Option> { - for (i, existing_factor) in existing.iter().enumerate() { - // exp(a) * exp(b) -> exp(a + b) - if let (Expr::Exp(a), Expr::Exp(b)) = (&existing_factor.expr, &new.expr) { - let merged_arg = (**a).clone() + (**b).clone(); - let merged_expr = - Expr::Exp(Box::new(canonical_form(&merged_arg).unwrap_or(merged_arg))); - let mut result = existing.to_vec(); - result[i] = OpaqueFactor { - key: merged_expr.to_string(), - expr: merged_expr, - }; - return Some(result); - } - - // c^a * c^b -> c^(a+b) for matching positive constant base c - if let (Expr::Pow(base1, exp1), Expr::Pow(base2, exp2)) = (&existing_factor.expr, &new.expr) - { - if let (Some(c1), Some(c2)) = (base1.constant_value(), base2.constant_value()) { - if c1 > 0.0 && c2 > 0.0 && (c1 - c2).abs() < 1e-15 { - let merged_exp = (**exp1).clone() + (**exp2).clone(); - let canon_exp = canonical_form(&merged_exp).unwrap_or(merged_exp); - let merged_expr = Expr::Pow(base1.clone(), Box::new(canon_exp)); - let mut result = existing.to_vec(); - result[i] = OpaqueFactor { - key: merged_expr.to_string(), - expr: merged_expr, - }; - return Some(result); - } - } - } - } - None -} - -/// A canonical sum of terms: the exact normal form of an expression. -#[derive(Clone, Debug)] -pub(crate) struct CanonicalSum { - terms: Vec, -} - -impl CanonicalTerm { - fn constant(c: f64) -> Self { - Self { - coeff: c, - vars: BTreeMap::new(), - opaque: Vec::new(), - } - } - - fn variable(name: &'static str) -> Self { - let mut vars = BTreeMap::new(); - vars.insert(name, 1.0); - Self { - coeff: 1.0, - vars, - opaque: Vec::new(), - } - } - - fn opaque_factor(expr: Expr) -> Self { - let key = expr.to_string(); - Self { - coeff: 1.0, - vars: BTreeMap::new(), - opaque: vec![OpaqueFactor { key, expr }], - } - } - - /// Multiply two terms, applying transcendental identities: - /// - `exp(a) * exp(b) -> exp(a + b)` - /// - `c^a * c^b -> c^(a + b)` for matching constant base `c` - fn mul(&self, other: &CanonicalTerm) -> CanonicalTerm { - let coeff = self.coeff * other.coeff; - let mut vars = self.vars.clone(); - for (&v, &e) in &other.vars { - *vars.entry(v).or_insert(0.0) += e; - } - // Remove zero-exponent variables - vars.retain(|_, e| e.abs() > 1e-15); - - // Merge opaque factors with transcendental identities - let mut opaque = self.opaque.clone(); - for other_factor in &other.opaque { - if let Some(merged) = try_merge_opaque(&opaque, other_factor) { - opaque = merged; - } else { - opaque.push(other_factor.clone()); - } - } - opaque.sort(); - CanonicalTerm { - coeff, - vars, - opaque, - } - } - - /// Deterministic sort key for ordering terms in a sum. - fn sort_key(&self) -> (Vec<(&'static str, u64)>, Vec) { - let vars: Vec<_> = self - .vars - .iter() - .map(|(&k, &v)| (k, normalized_f64_bits(v))) - .collect(); - let opaque: Vec<_> = self.opaque.iter().map(|o| o.key.clone()).collect(); - (vars, opaque) - } -} - -impl CanonicalSum { - fn from_term(term: CanonicalTerm) -> Self { - Self { terms: vec![term] } - } - - fn add(mut self, other: CanonicalSum) -> Self { - self.terms.extend(other.terms); - self - } - - fn mul(&self, other: &CanonicalSum) -> CanonicalSum { - let mut terms = Vec::new(); - for a in &self.terms { - for b in &other.terms { - terms.push(a.mul(b)); - } - } - CanonicalSum { terms } - } - - /// Multiply with a guard against pathological expansion (see - /// [`MAX_CANONICAL_TERMS`]). The Cartesian product size is checked *before* - /// it is materialized, so this never allocates the blown-up vector. - fn try_mul(&self, other: &CanonicalSum) -> Result { - let product = self.terms.len().saturating_mul(other.terms.len()); - if product > MAX_CANONICAL_TERMS { - return Err(CanonicalizationError::Unsupported(format!( - "expression too large to canonicalize ({product} terms exceeds cap of {MAX_CANONICAL_TERMS})" - ))); - } - Ok(self.mul(other)) - } - - /// Merge terms with the same signature and drop zero-coefficient terms. - /// Sort the result deterministically. - fn simplify(self) -> Self { - type SortKey = (Vec<(&'static str, u64)>, Vec); - let mut groups: BTreeMap = BTreeMap::new(); - - for term in self.terms { - let key = term.sort_key(); - groups - .entry(key) - .and_modify(|existing| existing.coeff += term.coeff) - .or_insert(term); - } - - let mut terms: Vec<_> = groups - .into_values() - .filter(|t| t.coeff.abs() > 1e-15) - .collect(); - - terms.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); - - CanonicalSum { terms } - } -} - -/// Normalize an expression into its exact canonical sum-of-terms form. -/// -/// This performs exact symbolic simplification: -/// - Flattens nested Add/Mul -/// - Merges duplicate additive terms by summing coefficients -/// - Merges repeated multiplicative factors into powers -/// - Preserves signed coefficients (supports subtraction) -/// - Preserves transcendental identities: exp(a)*exp(b)=exp(a+b), etc. -/// - Produces deterministic ordering -/// -/// Does NOT drop terms or constant factors — use `big_o_normal_form()` for that. -pub fn canonical_form(expr: &Expr) -> Result { - let sum = expr_to_canonical(expr)?; - let simplified = sum.simplify(); - Ok(canonical_sum_to_expr(&simplified)) -} - -fn expr_to_canonical(expr: &Expr) -> Result { - match expr { - Expr::Const(c) => Ok(CanonicalSum::from_term(CanonicalTerm::constant(*c))), - Expr::Var(name) => Ok(CanonicalSum::from_term(CanonicalTerm::variable(name))), - Expr::Add(a, b) => { - let ca = expr_to_canonical(a)?; - let cb = expr_to_canonical(b)?; - Ok(ca.add(cb)) - } - Expr::Mul(a, b) => { - let ca = expr_to_canonical(a)?; - let cb = expr_to_canonical(b)?; - ca.try_mul(&cb) - } - Expr::Pow(base, exp) => canonicalize_pow(base, exp), - Expr::Exp(arg) => { - // Treat exp(canonicalized_arg) as an opaque factor - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Exp(Box::new(inner)), - ))) - } - Expr::Log(arg) => { - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Log(Box::new(inner)), - ))) - } - Expr::Sqrt(arg) => { - // sqrt(x) = x^0.5 — canonicalize as power - canonicalize_pow(arg, &Expr::Const(0.5)) - } - Expr::Factorial(arg) => { - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Factorial(Box::new(inner)), - ))) - } - } -} - -fn canonicalize_pow(base: &Expr, exp: &Expr) -> Result { - match (base, exp) { - // Constant base, constant exp → numeric constant - (_, _) if base.constant_value().is_some() && exp.constant_value().is_some() => { - let b = base.constant_value().unwrap(); - let e = exp.constant_value().unwrap(); - Ok(CanonicalSum::from_term(CanonicalTerm::constant(b.powf(e)))) - } - // Variable ^ constant exponent → vars map (supports fractional/negative exponents) - (Expr::Var(name), _) if exp.constant_value().is_some() => { - let e = exp.constant_value().unwrap(); - if e.abs() < 1e-15 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - let mut vars = BTreeMap::new(); - vars.insert(*name, e); - Ok(CanonicalSum::from_term(CanonicalTerm { - coeff: 1.0, - vars, - opaque: Vec::new(), - })) - } - // Polynomial base ^ constant integer exponent → expand - (_, _) if exp.constant_value().is_some() => { - let e = exp.constant_value().unwrap(); - if e >= 0.0 && (e - e.round()).abs() < 1e-10 { - let n = e.round() as usize; - let base_sum = expr_to_canonical(base)?; - if n == 0 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - let mut result = base_sum.clone(); - for _ in 1..n { - result = result.try_mul(&base_sum)?; - } - Ok(result) - } else { - // Fractional exponent with non-variable base → opaque - let canon_base = canonical_form(base)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Pow(Box::new(canon_base), Box::new(Expr::Const(e))), - ))) - } - } - // Constant base ^ variable exponent → opaque (exponential growth) - (_, _) if base.constant_value().is_some() => { - let c = base.constant_value().unwrap(); - if (c - 1.0).abs() < 1e-15 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - if c <= 0.0 { - return Err(CanonicalizationError::Unsupported(format!( - "{}^{}", - base, exp - ))); - } - let canon_exp = canonical_form(exp)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Pow(Box::new(base.clone()), Box::new(canon_exp)), - ))) - } - // Variable base ^ variable exponent → unsupported - _ => Err(CanonicalizationError::Unsupported(format!( - "{}^{}", - base, exp - ))), - } -} - -fn canonical_sum_to_expr(sum: &CanonicalSum) -> Expr { - if sum.terms.is_empty() { - return Expr::Const(0.0); - } - - let term_exprs: Vec = sum.terms.iter().map(canonical_term_to_expr).collect(); - - let mut result = term_exprs[0].clone(); - for term in &term_exprs[1..] { - result = result + term.clone(); - } - result -} - -fn canonical_term_to_expr(term: &CanonicalTerm) -> Expr { - let mut factors: Vec = Vec::new(); - - // Add coefficient if not 1.0 (or -1.0, handled specially) - let (coeff_factor, sign) = if term.coeff < 0.0 { - (term.coeff.abs(), true) - } else { - (term.coeff, false) - }; - - let has_other_factors = !term.vars.is_empty() || !term.opaque.is_empty(); - - if (coeff_factor - 1.0).abs() > 1e-15 || !has_other_factors { - factors.push(Expr::Const(coeff_factor)); - } - - // Add variable powers - for (&var, &exp) in &term.vars { - if (exp - 1.0).abs() < 1e-15 { - factors.push(Expr::Var(var)); - } else { - factors.push(Expr::pow(Expr::Var(var), Expr::Const(exp))); - } - } - - // Add opaque factors - for opaque in &term.opaque { - factors.push(opaque.expr.clone()); - } - - let mut result = if factors.is_empty() { - Expr::Const(1.0) - } else { - let mut r = factors[0].clone(); - for f in &factors[1..] { - r = r * f.clone(); - } - r - }; - - if sign { - result = -result; - } - - result -} - -#[cfg(test)] -#[path = "unit_tests/canonical.rs"] -mod tests; diff --git a/src/example_db/mod.rs b/src/example_db/mod.rs index 6ed577a95..958ace123 100644 --- a/src/example_db/mod.rs +++ b/src/example_db/mod.rs @@ -54,7 +54,7 @@ fn validate_model_uniqueness(models: &[ModelExample]) -> Result<()> { /// Build the full example database from specs. /// /// ILP rule examples call the ILP solver at build time to compute solutions -/// dynamically (feature-gated behind `ilp-solver`). +/// dynamically. pub fn build_example_db() -> Result { let model_db = build_model_db()?; let rule_db = build_rule_db()?; diff --git a/src/example_db/specs.rs b/src/example_db/specs.rs index d6facb3ca..b13385487 100644 --- a/src/example_db/specs.rs +++ b/src/example_db/specs.rs @@ -68,7 +68,6 @@ where /// This is the standard pattern for canonical ILP rule examples: reduce once, /// solve the ILP, extract the source config, and build the example — avoiding /// the double `reduce_to()` that would occur with `rule_example_with_witness`. -#[cfg(feature = "ilp-solver")] pub fn rule_example_via_ilp(source: S) -> RuleExample where S: Problem + Serialize + ReduceTo>, @@ -81,7 +80,7 @@ where let ilp_solution = crate::solvers::ILPSolver::new() .solve(reduction.target_problem()) .expect("canonical example must be ILP-solvable"); - let source_config = reduction.extract_solution(&ilp_solution); + let source_config = reduction.extract_solution(&ilp_solution).unwrap(); assemble_rule_example( &source, reduction.target_problem(), diff --git a/src/export.rs b/src/export.rs index 331055caf..c53f3b33e 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1,6 +1,6 @@ //! JSON export schema for example payloads. -use crate::rules::registry::ReductionOverhead; +use crate::rules::registry::{ReductionSizeContract, SizeContractError}; use crate::rules::ReductionGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -117,17 +117,19 @@ pub struct ExampleDb { pub rules: Vec, } -/// Look up `ReductionOverhead` for a direct reduction using `ReductionGraph::find_best_entry`. -pub fn lookup_overhead( +/// Look up the explicit size contract for an exact direct reduction entry. +pub fn lookup_size_contract( source_name: &str, source_variant: &BTreeMap, target_name: &str, target_variant: &BTreeMap, -) -> Option { +) -> Result, SizeContractError> { let graph = ReductionGraph::new(); - let matched = - graph.find_best_entry(source_name, source_variant, target_name, target_variant)?; - Some(matched.overhead) + let Some(matched) = graph.find_entry(source_name, source_variant, target_name, target_variant) + else { + return Ok(None); + }; + matched.size_contract.map(Some) } /// Convert `Problem::variant()` output to a stable `BTreeMap`. diff --git a/src/expr.rs b/src/expr.rs index a880b6a09..c5b5ef2ad 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -1,572 +1,461 @@ -//! General symbolic expression AST for reduction overhead. +//! Symbolic expression integration for the problem-reduction domain. -use crate::types::ProblemSize; -use std::collections::{HashMap, HashSet}; +pub use num_bigint::BigInt; +use num_rational::BigRational; +#[cfg(test)] +use num_traits::FromPrimitive; +use num_traits::{One, Signed, ToPrimitive, Zero}; +pub use problemreductions_expr::{ + Expr, ExprNode, ExprNodeId, ParseError, SubstitutionError, Symbol, +}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap}; use std::fmt; -/// A symbolic math expression over problem size variables. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum Expr { - /// Numeric constant. - Const(f64), - /// Named variable (e.g., "num_vertices"). - Var(&'static str), - /// Addition: a + b. - Add(Box, Box), - /// Multiplication: a * b. - Mul(Box, Box), - /// Exponentiation: base ^ exponent. - Pow(Box, Box), - /// Exponential function: exp(a). - Exp(Box), - /// Natural logarithm: log(a). - Log(Box), - /// Square root: sqrt(a). - Sqrt(Box), - /// Factorial: factorial(a). - Factorial(Box), -} +use crate::types::ProblemSize; -impl Expr { - /// Convenience constructor for exponentiation. - pub fn pow(base: Expr, exp: Expr) -> Self { - Expr::Pow(Box::new(base), Box::new(exp)) - } +/// Algebraic facts computed once from the shared expression DAG and consumed by +/// exact-size, monotone-bound, and asymptotic-growth projections. +#[derive(Clone, Debug)] +pub(crate) struct AlgebraicAnalysis { + facts: HashMap, +} - /// Multiply expression by a scalar constant. - pub fn scale(self, c: f64) -> Self { - Expr::Const(c) * self - } +#[derive(Clone, Debug)] +pub(crate) struct AlgebraicFacts { + pub(crate) is_constant: bool, + pub(crate) exact_rational: Option, + pub(crate) linear: Option>, + pub(crate) constant_domain: Option, + pub(crate) sign: Option, + pub(crate) cmp_one: Option, +} - /// Evaluate the expression given concrete variable values. - pub fn eval(&self, vars: &ProblemSize) -> f64 { - match self { - Expr::Const(c) => *c, - Expr::Var(name) => vars.get(name).unwrap_or(0) as f64, - Expr::Add(a, b) => a.eval(vars) + b.eval(vars), - Expr::Mul(a, b) => a.eval(vars) * b.eval(vars), - Expr::Pow(base, exp) => base.eval(vars).powf(exp.eval(vars)), - Expr::Exp(a) => a.eval(vars).exp(), - Expr::Log(a) => a.eval(vars).ln(), - Expr::Sqrt(a) => a.eval(vars).sqrt(), - Expr::Factorial(a) => gamma_factorial(a.eval(vars)), +impl AlgebraicAnalysis { + pub(crate) fn new(expressions: &[&Expr]) -> Self { + let mut facts = HashMap::new(); + for expression in expressions { + analyze_algebraic(expression, &mut facts); } + Self { facts } } - /// Collect all variable names referenced in this expression. - pub fn variables(&self) -> HashSet<&'static str> { - let mut vars = HashSet::new(); - self.collect_variables(&mut vars); - vars + pub(crate) fn facts(&self, expression: &Expr) -> &AlgebraicFacts { + &self.facts[&expression.node_identity()] } +} - fn collect_variables(&self, vars: &mut HashSet<&'static str>) { - match self { - Expr::Const(_) => {} - Expr::Var(name) => { - vars.insert(name); +fn analyze_algebraic( + expression: &Expr, + memo: &mut HashMap, +) -> AlgebraicFacts { + if let Some(facts) = memo.get(&expression.node_identity()) { + return facts.clone(); + } + let facts = match expression.node() { + ExprNode::Const(value) => AlgebraicFacts { + is_constant: true, + exact_rational: Some(value.clone()), + linear: Some(BTreeMap::new()), + constant_domain: Some(true), + sign: Some(value.cmp(&BigRational::from_integer(0.into()))), + cmp_one: Some(value.cmp(&BigRational::from_integer(1.into()))), + }, + ExprNode::Var(symbol) => AlgebraicFacts { + is_constant: false, + exact_rational: None, + linear: Some(BTreeMap::from([( + symbol.clone(), + BigRational::from_integer(1.into()), + )])), + constant_domain: None, + sign: None, + cmp_one: None, + }, + ExprNode::Add(values) => { + let children = values + .iter() + .map(|value| analyze_algebraic(value, memo)) + .collect::>(); + let is_constant = children.iter().all(|facts| facts.is_constant); + AlgebraicFacts { + is_constant, + exact_rational: sum_exact(&children), + linear: sum_linear(&children), + constant_domain: is_constant.then(|| all_domains(&children)).flatten(), + sign: is_constant.then(|| sum_sign(&children)).flatten(), + cmp_one: None, } - Expr::Add(a, b) | Expr::Mul(a, b) | Expr::Pow(a, b) => { - a.collect_variables(vars); - b.collect_variables(vars); - } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { - a.collect_variables(vars); + } + ExprNode::Mul(values) => { + let children = values + .iter() + .map(|value| analyze_algebraic(value, memo)) + .collect::>(); + let is_constant = children.iter().all(|facts| facts.is_constant); + let exact_rational = product_exact(&children); + AlgebraicFacts { + is_constant, + linear: product_linear(&children), + constant_domain: is_constant.then(|| all_domains(&children)).flatten(), + sign: is_constant.then(|| product_sign(&children)).flatten(), + cmp_one: exact_rational + .as_ref() + .map(|value| value.cmp(&BigRational::from_integer(1.into()))), + exact_rational, } } - } - - /// Substitute variables with other expressions. - pub fn substitute(&self, mapping: &HashMap<&str, &Expr>) -> Expr { - match self { - Expr::Const(c) => Expr::Const(*c), - Expr::Var(name) => { - if let Some(replacement) = mapping.get(name) { - (*replacement).clone() - } else { - Expr::Var(name) + ExprNode::Pow(base, exponent) => { + let base = analyze_algebraic(base, memo); + let exponent = analyze_algebraic(exponent, memo); + let is_constant = base.is_constant && exponent.is_constant; + let exact_rational = match ( + base.exact_rational.as_ref(), + exponent.exact_rational.as_ref(), + ) { + (Some(base), Some(exponent)) if exponent == &-BigRational::one() => { + (!base.is_zero()).then(|| base.recip()) } + _ => None, + }; + let domain = if is_constant { + power_domain(&base, &exponent) + } else { + None + }; + let sign = domain + .is_some_and(|defined| defined) + .then(|| power_sign(&base, &exponent)) + .flatten(); + let cmp_one = domain + .is_some_and(|defined| defined) + .then(|| power_cmp_one(&base, &exponent)) + .flatten(); + AlgebraicFacts { + is_constant, + exact_rational, + linear: is_constant.then(BTreeMap::new), + constant_domain: domain, + sign, + cmp_one, } - Expr::Add(a, b) => a.substitute(mapping) + b.substitute(mapping), - Expr::Mul(a, b) => a.substitute(mapping) * b.substitute(mapping), - Expr::Pow(a, b) => Expr::pow(a.substitute(mapping), b.substitute(mapping)), - Expr::Exp(a) => Expr::Exp(Box::new(a.substitute(mapping))), - Expr::Log(a) => Expr::Log(Box::new(a.substitute(mapping))), - Expr::Sqrt(a) => Expr::Sqrt(Box::new(a.substitute(mapping))), - Expr::Factorial(a) => Expr::Factorial(Box::new(a.substitute(mapping))), } - } - - /// Parse an expression string into an `Expr` at runtime. - /// - /// **Memory note:** Variable names are leaked to `&'static str` via `Box::leak` - /// since `Expr::Var` requires static lifetimes. Each unique variable name leaks - /// a small allocation that is never freed. This is acceptable for testing and - /// one-time cross-check evaluation, but should not be used in hot loops with - /// dynamic input. - /// - /// # Panics - /// Panics if the expression string has invalid syntax. - pub fn parse(input: &str) -> Expr { - Self::try_parse(input) - .unwrap_or_else(|e| panic!("failed to parse expression \"{input}\": {e}")) - } - - /// Parse an expression string into an `Expr`, returning a normal error on failure. - pub fn try_parse(input: &str) -> Result { - parse_to_expr(input) - } - - /// Check if this expression is a polynomial (no exp/log/sqrt, integer exponents only). - pub fn is_polynomial(&self) -> bool { - match self { - Expr::Const(_) | Expr::Var(_) => true, - Expr::Add(a, b) | Expr::Mul(a, b) => a.is_polynomial() && b.is_polynomial(), - Expr::Pow(base, exp) => { - base.is_polynomial() - && matches!(exp.as_ref(), Expr::Const(c) if *c >= 0.0 && (*c - c.round()).abs() < 1e-10) + ExprNode::Exp(value) => { + let value = analyze_algebraic(value, memo); + let domain = value.is_constant.then_some(value.constant_domain).flatten(); + AlgebraicFacts { + is_constant: value.is_constant, + exact_rational: value + .exact_rational + .as_ref() + .filter(|value| value.is_zero()) + .map(|_| BigRational::from_integer(1.into())), + linear: value.is_constant.then(BTreeMap::new), + constant_domain: domain, + sign: domain + .is_some_and(|defined| defined) + .then_some(Ordering::Greater), + cmp_one: value.sign, } - Expr::Exp(_) | Expr::Log(_) | Expr::Sqrt(_) | Expr::Factorial(_) => false, } - } - - /// Check whether this expression is suitable for asymptotic complexity notation. - /// - /// This is intentionally conservative for symbolic size formulas: - /// - rejects explicit multiplicative constant factors like `3 * n` - /// - rejects additive constant terms like `n + 1` - /// - allows constants used as exponents (e.g. `n^(1/3)`) - /// - allows constants used as exponential bases (e.g. `2^n`) - /// - /// The goal is to accept expressions that already look like reduced - /// asymptotic notation, rather than exact-count formulas. - pub fn is_valid_complexity_notation(&self) -> bool { - self.is_valid_complexity_notation_inner() - } - - fn is_valid_complexity_notation_inner(&self) -> bool { - match self { - Expr::Const(c) => (*c - 1.0).abs() < 1e-10, - Expr::Var(_) => true, - Expr::Add(a, b) => { - a.constant_value().is_none() - && b.constant_value().is_none() - && a.is_valid_complexity_notation_inner() - && b.is_valid_complexity_notation_inner() - } - Expr::Mul(a, b) => { - a.constant_value().is_none() - && b.constant_value().is_none() - && a.is_valid_complexity_notation_inner() - && b.is_valid_complexity_notation_inner() - } - Expr::Pow(base, exp) => { - let base_is_constant = base.constant_value().is_some(); - let exp_is_constant = exp.constant_value().is_some(); - - let base_ok = if base_is_constant { - base.is_valid_exponential_base() - } else { - base.is_valid_complexity_notation_inner() - }; - - let exp_ok = if exp_is_constant { - true - } else { - exp.is_valid_complexity_notation_inner() - }; - - base_ok && exp_ok - } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { - a.is_valid_complexity_notation_inner() + ExprNode::Log(value) => { + let value = analyze_algebraic(value, memo); + let domain = if value.is_constant { + value + .constant_domain + .map(|defined| defined && value.sign == Some(Ordering::Greater)) + } else { + None + }; + AlgebraicFacts { + is_constant: value.is_constant, + exact_rational: value + .exact_rational + .as_ref() + .filter(|value| value.is_one()) + .map(|_| BigRational::from_integer(0.into())), + linear: value.is_constant.then(BTreeMap::new), + constant_domain: domain, + sign: domain + .is_some_and(|defined| defined) + .then_some(value.cmp_one) + .flatten(), + cmp_one: None, } } - } - - fn is_valid_exponential_base(&self) -> bool { - self.constant_value().is_some_and(|c| c > 0.0) - } - - pub(crate) fn constant_value(&self) -> Option { - match self { - Expr::Const(c) => Some(*c), - Expr::Var(_) => None, - Expr::Add(a, b) => Some(a.constant_value()? + b.constant_value()?), - Expr::Mul(a, b) => Some(a.constant_value()? * b.constant_value()?), - Expr::Pow(base, exp) => Some(base.constant_value()?.powf(exp.constant_value()?)), - Expr::Exp(a) => Some(a.constant_value()?.exp()), - Expr::Log(a) => Some(a.constant_value()?.ln()), - Expr::Sqrt(a) => Some(a.constant_value()?.sqrt()), - Expr::Factorial(a) => Some(gamma_factorial(a.constant_value()?)), - } - } -} - -impl fmt::Display for Expr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Expr::Const(c) => { - let ci = c.round() as i64; - if (*c - ci as f64).abs() < 1e-10 { - write!(f, "{ci}") - } else { - write!(f, "{c}") - } - } - Expr::Var(name) => write!(f, "{name}"), - Expr::Add(a, b) => write!(f, "{a} + {b}"), - Expr::Mul(a, b) => { - let left = if matches!(a.as_ref(), Expr::Add(_, _)) { - format!("({a})") - } else { - format!("{a}") - }; - let right = if matches!(b.as_ref(), Expr::Add(_, _)) { - format!("({b})") - } else { - format!("{b}") - }; - write!(f, "{left} * {right}") - } - Expr::Pow(base, exp) => { - // Special case: x^0.5 → sqrt(x) - if let Expr::Const(e) = exp.as_ref() { - if (*e - 0.5).abs() < 1e-15 { - return write!(f, "sqrt({base})"); - } - } - let base_str = if matches!(base.as_ref(), Expr::Add(_, _) | Expr::Mul(_, _)) { - format!("({base})") - } else { - format!("{base}") - }; - let exp_str = if matches!(exp.as_ref(), Expr::Add(_, _) | Expr::Mul(_, _)) { - format!("({exp})") - } else { - format!("{exp}") - }; - write!(f, "{base_str}^{exp_str}") + ExprNode::Factorial(value) => { + let value = analyze_algebraic(value, memo); + let valid = value + .exact_rational + .as_ref() + .map(|value| value.is_integer() && !value.is_negative()); + AlgebraicFacts { + is_constant: value.is_constant, + exact_rational: None, + linear: value.is_constant.then(BTreeMap::new), + constant_domain: value.is_constant.then_some(valid).flatten(), + sign: valid + .is_some_and(|valid| valid) + .then_some(Ordering::Greater), + cmp_one: valid.and_then(|valid| { + valid.then(|| { + if value + .exact_rational + .as_ref() + .is_some_and(|value| value <= &BigRational::from_integer(1.into())) + { + Ordering::Equal + } else { + Ordering::Greater + } + }) + }), } - Expr::Exp(a) => write!(f, "exp({a})"), - Expr::Log(a) => write!(f, "log({a})"), - Expr::Sqrt(a) => write!(f, "sqrt({a})"), - Expr::Factorial(a) => write!(f, "factorial({a})"), } - } + }; + memo.insert(expression.node_identity(), facts.clone()); + facts } -impl std::ops::Add for Expr { - type Output = Self; - - fn add(self, other: Self) -> Self { - Expr::Add(Box::new(self), Box::new(other)) - } +fn sum_exact(children: &[AlgebraicFacts]) -> Option { + children.iter().try_fold(BigRational::zero(), |sum, child| { + Some(sum + child.exact_rational.as_ref()?) + }) } -impl std::ops::Mul for Expr { - type Output = Self; - - fn mul(self, other: Self) -> Self { - Expr::Mul(Box::new(self), Box::new(other)) - } +fn product_exact(children: &[AlgebraicFacts]) -> Option { + children + .iter() + .try_fold(BigRational::one(), |product, child| { + Some(product * child.exact_rational.as_ref()?) + }) } -impl std::ops::Sub for Expr { - type Output = Self; - - fn sub(self, other: Self) -> Self { - self + Expr::Const(-1.0) * other +fn sum_linear(children: &[AlgebraicFacts]) -> Option> { + let mut result = BTreeMap::new(); + for child in children { + for (symbol, coefficient) in child.linear.as_ref()? { + *result + .entry(symbol.clone()) + .or_insert_with(BigRational::zero) += coefficient; + } } + result.retain(|_, coefficient| !coefficient.is_zero()); + Some(result) } -impl std::ops::Div for Expr { - type Output = Self; - - fn div(self, other: Self) -> Self { - self * Expr::pow(other, Expr::Const(-1.0)) +fn product_linear(children: &[AlgebraicFacts]) -> Option> { + if children.iter().all(|child| child.is_constant) { + return Some(BTreeMap::new()); + } + let mut coefficient = BigRational::one(); + let mut linear = None; + for child in children { + if child.is_constant { + coefficient *= child.exact_rational.as_ref()?; + } else if linear.is_some() { + return None; + } else { + linear = Some(child.linear.clone()?); + } + } + let mut linear = linear?; + for value in linear.values_mut() { + *value *= &coefficient; } + linear.retain(|_, value| !value.is_zero()); + Some(linear) } -impl std::ops::Neg for Expr { - type Output = Self; - - fn neg(self) -> Self { - Expr::Const(-1.0) * self +fn all_domains(children: &[AlgebraicFacts]) -> Option { + let mut defined = true; + for child in children { + defined &= child.constant_domain?; } + Some(defined) } -/// Error returned when analyzing asymptotic behavior. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AsymptoticAnalysisError { - Unsupported(String), +fn sum_sign(children: &[AlgebraicFacts]) -> Option { + if let Some(value) = sum_exact(children) { + return Some(value.cmp(&BigRational::zero())); + } + let signs = children + .iter() + .map(|child| child.sign) + .collect::>>()?; + if signs.iter().all(|sign| *sign != Ordering::Less) { + Some(if signs.contains(&Ordering::Greater) { + Ordering::Greater + } else { + Ordering::Equal + }) + } else if signs.iter().all(|sign| *sign != Ordering::Greater) { + Some(Ordering::Less) + } else { + None + } } -impl fmt::Display for AsymptoticAnalysisError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unsupported(expr) => write!(f, "unsupported asymptotic expression: {expr}"), +fn product_sign(children: &[AlgebraicFacts]) -> Option { + let mut sign = Ordering::Greater; + for child in children { + match child.sign? { + Ordering::Equal => return Some(Ordering::Equal), + Ordering::Less => sign = sign.reverse(), + Ordering::Greater => {} } } + Some(sign) } -impl std::error::Error for AsymptoticAnalysisError {} - -/// Error returned when exact canonicalization fails. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CanonicalizationError { - /// Expression cannot be canonicalized (e.g., variable in both base and exponent). - Unsupported(String), +fn power_domain(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option { + if !base.constant_domain? || !exponent.constant_domain? { + return Some(false); + } + match base.sign? { + Ordering::Greater => Some(true), + Ordering::Equal => Some(exponent.sign? == Ordering::Greater), + Ordering::Less => exponent + .exact_rational + .as_ref() + .map(|value| value.is_integer()), + } } -impl fmt::Display for CanonicalizationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unsupported(expr) => { - write!(f, "unsupported expression for canonicalization: {expr}") +fn power_sign(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option { + let exponent_value = exponent.exact_rational.as_ref()?; + if exponent_value.is_zero() { + return Some(Ordering::Greater); + } + match base.sign? { + Ordering::Greater => Some(Ordering::Greater), + Ordering::Equal => Some(Ordering::Equal), + Ordering::Less => { + let exponent = exponent_value.to_integer(); + if (&exponent % 2u8).is_zero() { + Some(Ordering::Greater) + } else { + Some(Ordering::Less) } } } } -impl std::error::Error for CanonicalizationError {} - -/// Return a normalized `Expr` representing the asymptotic behavior of `expr`. -/// -/// This is now a compatibility wrapper for `big_o_normal_form()`. -pub fn asymptotic_normal_form(expr: &Expr) -> Result { - crate::big_o::big_o_normal_form(expr) -} - -/// Compute factorial for non-negative values. -/// -/// For non-negative integers, returns the exact integer factorial. -/// For non-integer values, uses Stirling's approximation of the gamma function: -/// n! = Γ(n+1) ≈ √(2πn) · (n/e)^n. -fn gamma_factorial(n: f64) -> f64 { - if n < 0.0 { - return f64::NAN; - } - let rounded = n.round(); - if (n - rounded).abs() < 1e-10 && rounded >= 0.0 { - let k = rounded as u64; - let mut result = 1u64; - for i in 2..=k { - result = result.saturating_mul(i); - } - result as f64 +fn power_cmp_one(base: &AlgebraicFacts, exponent: &AlgebraicFacts) -> Option { + let exponent = exponent.exact_rational.as_ref()?; + if exponent.is_zero() || base.cmp_one == Some(Ordering::Equal) { + Some(Ordering::Equal) + } else if exponent.is_positive() { + base.cmp_one } else { - // Stirling's approximation: Γ(n+1) ≈ √(2πn) · (n/e)^n - (2.0 * std::f64::consts::PI * n).sqrt() * (n / std::f64::consts::E).powf(n) - } -} - -// --- Runtime expression parser --- - -/// Parse an expression string into an `Expr`. -/// -/// Uses the same grammar as the proc macro parser. Variable names are leaked -/// to `&'static str` for compatibility with `Expr::Var`. -fn parse_to_expr(input: &str) -> Result { - let tokens = tokenize_expr(input)?; - let mut parser = ExprParser::new(tokens); - let expr = parser.parse_additive()?; - if parser.pos != parser.tokens.len() { - return Err(format!("trailing tokens at position {}", parser.pos)); + base.cmp_one.map(Ordering::reverse) } - Ok(expr) } -#[derive(Debug, Clone, PartialEq)] -enum ExprToken { - Number(f64), - Ident(String), - Plus, - Minus, - Star, - Slash, - Caret, - LParen, - RParen, +/// Evaluate an expression numerically at an explicitly approximate boundary. +pub fn evaluate_approximate( + expression: &Expr, + variables: &ProblemSize, +) -> Result { + evaluate_approximate_inner(expression, variables, &mut HashMap::new()) } -fn tokenize_expr(input: &str) -> Result, String> { - let mut tokens = Vec::new(); - let mut chars = input.chars().peekable(); - while let Some(&ch) = chars.peek() { - match ch { - ' ' | '\t' | '\n' => { - chars.next(); - } - '+' => { - chars.next(); - tokens.push(ExprToken::Plus); - } - '-' => { - chars.next(); - tokens.push(ExprToken::Minus); - } - '*' => { - chars.next(); - tokens.push(ExprToken::Star); - } - '/' => { - chars.next(); - tokens.push(ExprToken::Slash); - } - '^' => { - chars.next(); - tokens.push(ExprToken::Caret); - } - '(' => { - chars.next(); - tokens.push(ExprToken::LParen); - } - ')' => { - chars.next(); - tokens.push(ExprToken::RParen); - } - c if c.is_ascii_digit() || c == '.' => { - let mut num = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() || c == '.' { - num.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(ExprToken::Number( - num.parse().map_err(|_| format!("invalid number: {num}"))?, - )); - } - c if c.is_ascii_alphabetic() || c == '_' => { - let mut ident = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_alphanumeric() || c == '_' { - ident.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(ExprToken::Ident(ident)); - } - _ => return Err(format!("unexpected character: '{ch}'")), +fn evaluate_approximate_inner( + expression: &Expr, + variables: &ProblemSize, + memo: &mut HashMap, +) -> Result { + if let Some(value) = memo.get(&expression.node_identity()) { + return Ok(*value); + } + let value = match expression.node() { + ExprNode::Const(value) => rational_to_f64(value), + ExprNode::Var(name) => variables + .get(name.as_str()) + .map(|value| value as f64) + .ok_or_else(|| ApproximationError::MissingVariable(name.to_string())), + ExprNode::Add(values) => values.iter().try_fold(0.0, |sum, value| { + Ok(sum + evaluate_approximate_inner(value, variables, memo)?) + }), + ExprNode::Mul(values) => values.iter().try_fold(1.0, |product, value| { + Ok(product * evaluate_approximate_inner(value, variables, memo)?) + }), + ExprNode::Pow(base, exponent) => Ok(evaluate_approximate_inner(base, variables, memo)? + .powf(evaluate_approximate_inner(exponent, variables, memo)?)), + ExprNode::Exp(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.exp()), + ExprNode::Log(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.ln()), + ExprNode::Factorial(value) => { + approximate_factorial(evaluate_approximate_inner(value, variables, memo)?) } + }?; + if !value.is_finite() { + return Err(ApproximationError::NonFiniteResult(expression.to_string())); } - Ok(tokens) + memo.insert(expression.node_identity(), value); + Ok(value) } -struct ExprParser { - tokens: Vec, - pos: usize, +/// Convert an approximation produced by the growth domain back to an exact AST constant. +#[cfg(test)] +pub(crate) fn expression_from_approximation(value: f64) -> Expr { + Expr::constant( + BigRational::from_f64(value) + .expect("growth-domain expression constants must be finite numbers"), + ) } -impl ExprParser { - fn new(tokens: Vec) -> Self { - Self { tokens, pos: 0 } - } - - fn peek(&self) -> Option<&ExprToken> { - self.tokens.get(self.pos) - } +pub(crate) fn rational_to_f64(value: &BigRational) -> Result { + value + .to_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| ApproximationError::OutOfRange(value.to_string())) +} - fn advance(&mut self) -> Option { - let tok = self.tokens.get(self.pos).cloned(); - self.pos += 1; - tok +pub(crate) fn approximate_factorial(value: f64) -> Result { + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 { + return Err(ApproximationError::InvalidFactorialArgument( + value.to_string(), + )); } - - fn expect(&mut self, expected: &ExprToken) -> Result<(), String> { - match self.advance() { - Some(ref tok) if tok == expected => Ok(()), - Some(tok) => Err(format!("expected {expected:?}, got {tok:?}")), - None => Err(format!("expected {expected:?}, got end of input")), - } - } - - fn parse_additive(&mut self) -> Result { - let mut left = self.parse_multiplicative()?; - while matches!(self.peek(), Some(ExprToken::Plus) | Some(ExprToken::Minus)) { - let op = self.advance().unwrap(); - let right = self.parse_multiplicative()?; - left = match op { - ExprToken::Plus => left + right, - ExprToken::Minus => left - right, - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_multiplicative(&mut self) -> Result { - let mut left = self.parse_unary()?; - while matches!(self.peek(), Some(ExprToken::Star) | Some(ExprToken::Slash)) { - let op = self.advance().unwrap(); - let right = self.parse_unary()?; - left = match op { - ExprToken::Star => left * right, - ExprToken::Slash => left / right, - _ => unreachable!(), - }; - } - Ok(left) + if value > 170.0 { + Err(ApproximationError::NonFiniteResult(format!( + "factorial({value})" + ))) + } else { + Ok((2..=value as u64).fold(1.0, |product, factor| product * factor as f64)) } +} - fn parse_power(&mut self) -> Result { - let base = self.parse_primary()?; - if matches!(self.peek(), Some(ExprToken::Caret)) { - self.advance(); - let exp = self.parse_unary()?; // right-associative, allows unary minus in exponent - Ok(Expr::pow(base, exp)) - } else { - Ok(base) - } - } +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ApproximationError { + #[error("missing expression variable {0}")] + MissingVariable(String), + #[error("exact constant {0} is outside the f64 approximation domain")] + OutOfRange(String), + #[error("factorial argument must be a non-negative integer, found {0}")] + InvalidFactorialArgument(String), + #[error("expression {0} has no finite real approximation")] + NonFiniteResult(String), +} - fn parse_unary(&mut self) -> Result { - if matches!(self.peek(), Some(ExprToken::Minus)) { - self.advance(); - let expr = self.parse_unary()?; - Ok(-expr) - } else { - self.parse_power() - } - } +/// Error returned when analyzing asymptotic behavior. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AsymptoticAnalysisError { + Unsupported(String), +} - fn parse_primary(&mut self) -> Result { - match self.advance() { - Some(ExprToken::Number(n)) => Ok(Expr::Const(n)), - Some(ExprToken::Ident(name)) => { - if matches!(self.peek(), Some(ExprToken::LParen)) { - self.advance(); - let arg = self.parse_additive()?; - self.expect(&ExprToken::RParen)?; - match name.as_str() { - "exp" => Ok(Expr::Exp(Box::new(arg))), - "log" => Ok(Expr::Log(Box::new(arg))), - "sqrt" => Ok(Expr::Sqrt(Box::new(arg))), - "factorial" => Ok(Expr::Factorial(Box::new(arg))), - _ => Err(format!("unknown function: {name}")), - } - } else { - // Leak the string to get &'static str for Expr::Var - let leaked: &'static str = Box::leak(name.into_boxed_str()); - Ok(Expr::Var(leaked)) - } - } - Some(ExprToken::LParen) => { - let expr = self.parse_additive()?; - self.expect(&ExprToken::RParen)?; - Ok(expr) +impl fmt::Display for AsymptoticAnalysisError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unsupported(expression) => { + write!(formatter, "unsupported asymptotic expression: {expression}") } - Some(tok) => Err(format!("unexpected token: {tok:?}")), - None => Err("unexpected end of input".to_string()), } } } +impl std::error::Error for AsymptoticAnalysisError {} + #[cfg(test)] #[path = "unit_tests/expr.rs"] mod tests; diff --git a/src/growth.rs b/src/growth.rs new file mode 100644 index 000000000..c24766e45 --- /dev/null +++ b/src/growth.rs @@ -0,0 +1,1058 @@ +//! Symbolic growth domain: a dedicated asymptotic normal form for reduction +//! size expressions. +//! +//! Where full monomial canonicalization answers Big-O questions by expanding an +//! [`Expr`] to monomial normal form, with exponential cost in nesting depth, the +//! growth domain computes an asymptotic upper bound bottom-up without rewriting +//! the source AST into a fully distributed polynomial. Work is output-sensitive: +//! exact antichains are never truncated, so genuinely large Pareto fronts remain +//! large and visible to the caller. +//! +//! # Representation +//! +//! A [`GrowthTerm`] is one growth monomial +//! +//! ```text +//! ∏_v ∏_f base[f]^(coefficient[f] · v) +//! · ∏_v v^(poly[v]) · ∏_v (log v)^(logs[v]) +//! ``` +//! +//! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms +//! (each summand of an asymptotic sum), or [`Growth::Unknown`] with explicit +//! reasons for content we cannot bound symbolically. +//! +//! # Semantic foundation (the trust contract) +//! +//! Every expression admitted to the domain is assumed **nonnegative** and +//! **weakly monotone** (nondecreasing in each variable) on `vars ≥ 2`. Under +//! these axioms Howell's multivariate-O inconsistencies vanish and +//! `f + g ≍ max(f, g)` up to a constant factor, which licenses +//! `add = antichain union + prune`. All bounds produced are **upper** bounds. +//! +//! Widening (always toward a valid upper bound): +//! - Subtraction is normalized to addition of a negative term, and +//! [`Growth::from_expr`] widens it to the union of both operands. +//! This also covers the +//! `sqrt((a − b)^2)` absolute-value idiom (`|a − b| ≤ a + b`). +//! - Constants and constant multipliers/divisors are dropped on entry. +//! - Exponentials with a **linear** exponent (`c^x`, `c^(r·x)`, `exp(x)`) are +//! first-class via symbolic base/coefficient factors. The original base is +//! authoritative: it is never normalized through a floating-point logarithm +//! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, +//! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to +//! [`Growth::Unknown`], which preserves its reasons through every operation. +//! - The explicit approximation boundary treats [`Expr::log`] as the natural +//! logarithm, but all fixed +//! logarithm bases greater than one have the same asymptotic class and are +//! intentionally represented by the single `log(v)` factor. +//! +//! # `Pow` note +//! +//! `Pow(base, k)` for a nonnegative constant `k` raises **each** antichain term +//! of `base` to the power `k` (scaling its exponents). This is the tight +//! asymptotic answer — `(n + m)^2 ≍ max(n, m)^2 = max(n^2, m^2)` by AM-GM, so no +//! binomial cross term is introduced — and it is what makes the widening chain +//! `sqrt((n − m)^2) ≍ n + m` hold exactly. + +use crate::expr::{AlgebraicAnalysis, BigInt, Expr, ExprNode, ExprNodeId}; +use num_rational::BigRational; +use num_traits::{One, Signed, ToPrimitive, Zero}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// A base retained exactly as it appeared in the input expression. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +enum ExpBase { + /// A positive, finite constant expression used as the base of `Pow`. + Constant(Expr), + /// The distinguished base of the `exp(...)` AST constructor. + Natural, +} + +impl<'de> serde::Deserialize<'de> for ExpBase { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + enum Repr { + Constant(Expr), + Natural, + } + + match Repr::deserialize(deserializer)? { + Repr::Natural => Ok(ExpBase::Natural), + Repr::Constant(base) => match base.node() { + ExprNode::Const(value) if value.is_positive() => Ok(ExpBase::Constant(base)), + _ => Err(serde::de::Error::custom( + "symbolic exponential base must be a positive rational constant", + )), + }, + } + } +} + +impl ExpBase { + fn structural_key(&self) -> String { + match self { + ExpBase::Constant(base) => format!("C{base:?}"), + ExpBase::Natural => "N".to_string(), + } + } + + fn directly_comparable_value(&self) -> Option<&BigRational> { + match self { + ExpBase::Constant(base) => match base.node() { + ExprNode::Const(value) => Some(value), + _ => unreachable!("constant exponential bases are validated when constructed"), + }, + ExpBase::Natural => None, + } + } + + fn direction(&self) -> Ordering { + match self { + ExpBase::Constant(_) => self + .directly_comparable_value() + .expect("constant base") + .cmp(&BigRational::one()), + ExpBase::Natural => Ordering::Greater, + } + } + + fn coefficient_cmp(&self, a: &BigRational, b: &BigRational) -> Ordering { + if self.direction() == Ordering::Greater { + a.cmp(b) + } else { + a.cmp(b).reverse() + } + } +} + +/// One symbolic exponential factor `base^(coefficient * variable)`. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct ExpFactor { + base: ExpBase, + coefficient: BigRational, +} + +/// Canonical product of growing exponential factors for one variable. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct ExpProduct { + factors: Vec, +} + +impl ExpProduct { + fn empty() -> Self { + ExpProduct { + factors: Vec::new(), + } + } + + fn single(base: ExpBase, coefficient: BigRational) -> Self { + Self::new(vec![ExpFactor { base, coefficient }]) + } + + /// Canonicalize without translating bases through a common logarithm. + fn new(factors: Vec) -> Self { + let mut combined: Vec = Vec::new(); + for factor in factors { + if factor.coefficient.is_zero() { + continue; + } + if let Some(existing) = combined.iter_mut().find(|f| f.base == factor.base) { + existing.coefficient += factor.coefficient; + } else { + combined.push(factor); + } + } + combined.retain(|factor| !factor.coefficient.is_zero()); + combined.sort_by_cached_key(|factor| factor.base.structural_key()); + ExpProduct { factors: combined } + } + + fn mul(&self, other: &Self) -> Self { + let mut factors = self.factors.clone(); + factors.extend(other.factors.iter().cloned()); + Self::new(factors) + } + + fn pow(&self, power: &BigRational) -> Self { + let factors = self + .factors + .iter() + .filter_map(|factor| { + let coefficient = &factor.coefficient * power; + (!coefficient.is_zero()).then(|| ExpFactor { + base: factor.base.clone(), + coefficient, + }) + }) + .collect(); + ExpProduct { factors } + } + + fn is_empty(&self) -> bool { + self.factors.is_empty() + } + + fn is_valid(&self) -> bool { + self.factors.iter().all(|factor| { + matches!( + ( + factor.base.direction(), + factor.coefficient.cmp(&BigRational::zero()) + ), + (Ordering::Greater, Ordering::Greater) | (Ordering::Less, Ordering::Less) + ) + }) + } + + /// Prove an ordering using only structural cancellation and direct constant + /// comparisons. `None` means "not proved", never "equal". + fn cmp_proven(&self, other: &Self) -> Option { + if self == other { + return Some(Ordering::Equal); + } + + let mut left_count = 0; + let mut right_count = 0; + let mut left_single: Option<(&ExpBase, BigRational)> = None; + let mut right_single: Option<(&ExpBase, BigRational)> = None; + + for a in &self.factors { + if let Some(b) = other.factors.iter().find(|b| a.base == b.base) { + match a.base.coefficient_cmp(&a.coefficient, &b.coefficient) { + Ordering::Equal => {} + Ordering::Greater => { + left_count += 1; + left_single = Some((&a.base, &a.coefficient - &b.coefficient)); + } + Ordering::Less => { + right_count += 1; + right_single = Some((&a.base, &b.coefficient - &a.coefficient)); + } + } + } else { + left_count += 1; + left_single = Some((&a.base, a.coefficient.clone())); + } + } + + for b in &other.factors { + if !self.factors.iter().any(|a| a.base == b.base) { + right_count += 1; + right_single = Some((&b.base, b.coefficient.clone())); + } + } + + match (left_count, right_count) { + (0, 0) => Some(Ordering::Equal), + (0, _) => Some(Ordering::Less), + (_, 0) => Some(Ordering::Greater), + (1, 1) => { + let (a_base, a_coefficient) = left_single?; + let (b_base, b_coefficient) = right_single?; + Self::cmp_single_factor(a_base, &a_coefficient, b_base, &b_coefficient) + } + _ => None, + } + } + + fn cmp_single_factor( + a_base: &ExpBase, + a_coefficient: &BigRational, + b_base: &ExpBase, + b_coefficient: &BigRational, + ) -> Option { + if a_base == b_base { + return Some(a_base.coefficient_cmp(a_coefficient, b_coefficient)); + } + + if a_coefficient == b_coefficient { + match (a_base, b_base) { + (ExpBase::Natural, ExpBase::Constant(_)) => { + let base = b_base.directly_comparable_value()?; + if base <= &BigRational::from_integer(2.into()) { + return Some(Ordering::Greater); + } + if base >= &BigRational::from_integer(3.into()) { + return Some(Ordering::Less); + } + return None; + } + (ExpBase::Constant(_), ExpBase::Natural) => { + return Self::cmp_single_factor(b_base, b_coefficient, a_base, a_coefficient) + .map(Ordering::reverse); + } + _ => {} + } + } + + let (a_base, b_base) = ( + a_base.directly_comparable_value()?, + b_base.directly_comparable_value()?, + ); + if a_coefficient == b_coefficient { + let base_order = a_base.cmp(b_base); + return if a_coefficient.is_positive() { + Some(base_order) + } else { + Some(base_order.reverse()) + }; + } + + if a_base > &BigRational::one() && b_base > &BigRational::one() { + match (a_base.cmp(b_base), a_coefficient.cmp(b_coefficient)) { + (Ordering::Greater | Ordering::Equal, Ordering::Greater | Ordering::Equal) => { + Some(Ordering::Greater) + } + (Ordering::Less | Ordering::Equal, Ordering::Less | Ordering::Equal) => { + Some(Ordering::Less) + } + _ => None, + } + } else if a_base < &BigRational::one() && b_base < &BigRational::one() { + match (a_base.cmp(b_base), a_coefficient.cmp(b_coefficient)) { + (Ordering::Less | Ordering::Equal, Ordering::Less | Ordering::Equal) => { + Some(Ordering::Greater) + } + (Ordering::Greater | Ordering::Equal, Ordering::Greater | Ordering::Equal) => { + Some(Ordering::Less) + } + _ => None, + } + } else { + None + } + } + + fn sort_key(&self) -> String { + self.factors + .iter() + .map(|factor| format!("{}={:?}", factor.base.structural_key(), factor.coefficient)) + .collect::>() + .join(",") + } +} + +/// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)`. +/// +/// Empty maps represent `O(1)`. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct GrowthTerm { + /// Variable → canonical product of symbolic exponential factors. + exp: BTreeMap, ExpProduct>, + /// variable → polynomial degree (`0.5` covers `sqrt`). + poly: BTreeMap, BigRational>, + /// variable → log power. + logs: BTreeMap, u32>, +} + +/// The asymptotic growth class of an [`Expr`]. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Growth { + /// Antichain of pairwise-incomparable dominant terms, sorted by a + /// deterministic total order for platform-stable output/serialization. + Terms(Vec), + /// Content outside the represented growth domain, with every reason that + /// contributed to the result. + Unknown(Vec), +} + +/// A precise reason why an expression has no represented [`Growth`] value. +#[derive( + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, + thiserror::Error, +)] +pub enum GrowthFailure { + #[error("invalid or unproved constant domain for {expression}")] + InvalidConstantDomain { expression: String }, + #[error("negative exponent is unsupported: {0}")] + NegativeExponent(String), + #[error("nonlinear exponent is unsupported: {0}")] + NonlinearExponent(String), + #[error("variable base and exponent are unsupported: {0}")] + VariableBaseAndExponent(String), + #[error("factorial of a nonconstant expression is unsupported: {0}")] + FactorialOfNonconstant(String), + #[error("invalid exponential base: {0}")] + InvalidExponentialBase(String), + #[error("represented exponent is outside the growth domain: {0}")] + RepresentedExponentOutOfRange(String), + #[error( + "exponential factor {base}^({coefficient} * {variable}) decreases as {variable} grows" + )] + DecayingExponential { + base: String, + variable: String, + coefficient: String, + }, + #[error("growth construction produced an invalid term")] + InvalidGrowthTerm, + #[error("missing substitution for {0}")] + MissingSubstitution(String), +} + +impl GrowthTerm { + /// The `O(1)` term (all maps empty). + fn one() -> Self { + GrowthTerm { + exp: BTreeMap::new(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + } + } + + /// A deterministic, platform-stable total-order key. + fn sort_key(&self) -> String { + let mut s = String::new(); + for (k, v) in &self.exp { + s.push('E'); + s.push_str(k); + s.push('='); + s.push_str(&v.sort_key()); + s.push(';'); + } + s.push('|'); + for (k, v) in &self.poly { + s.push('P'); + s.push_str(k); + s.push('='); + s.push_str(&format!("{v:?}")); + s.push(';'); + } + s.push('|'); + for (k, v) in &self.logs { + s.push('L'); + s.push_str(k); + s.push('='); + s.push_str(&v.to_string()); + s.push(';'); + } + s + } + + /// Raise this term to a nonnegative real power `k` (scale every exponent). + /// Log powers are `u32`; a fractional result is rounded **up** (a valid + /// upper bound, since `(log v)^p ≤ (log v)^⌈p⌉` for `v ≥ 2`). + fn pow(&self, k: &BigRational) -> Result { + let mut r = GrowthTerm::one(); + for (v, product) in &self.exp { + let product = product.pow(k); + if !product.is_empty() { + r.exp.insert(v.clone(), product); + } + } + for (v, deg) in &self.poly { + r.poly.insert(v.clone(), deg * k); + } + for (v, p) in &self.logs { + let scaled = BigRational::from_integer(BigInt::from(*p)) * k; + let rounded = (scaled.numer() + scaled.denom() - BigInt::one()) / scaled.denom(); + let Some(rounded) = rounded.to_u32() else { + return Err(GrowthFailure::RepresentedExponentOutOfRange( + scaled.to_string(), + )); + }; + r.logs.insert(v.clone(), rounded); + } + Ok(r) + } + + /// Multiply two monomials (add matching exponents). + fn mul(&self, other: &GrowthTerm) -> Result { + let mut t = self.clone(); + for (k, product) in &other.exp { + let combined = t + .exp + .get(k) + .map_or_else(|| product.clone(), |current| current.mul(product)); + if combined.is_empty() { + t.exp.remove(k); + } else { + t.exp.insert(k.clone(), combined); + } + } + for (k, v) in &other.poly { + *t.poly.entry(k.clone()).or_insert_with(BigRational::zero) += v; + } + for (k, v) in &other.logs { + let current = t.logs.entry(k.clone()).or_insert(0); + let Some(combined) = current.checked_add(*v) else { + return Err(GrowthFailure::RepresentedExponentOutOfRange(format!( + "{current} + {v}" + ))); + }; + *current = combined; + } + Ok(t) + } + + /// Partial order on terms: `Some(Greater)` iff `self` dominates `other` + /// (`≥` on every variable and `>` on at least one). Per variable, + /// exponential products are compared only when a symbolic proof succeeds; + /// polynomial degree and log power then break proven exponential ties. + /// Returns `None` for incomparable or unproved terms. + fn cmp(&self, other: &GrowthTerm) -> Option { + let mut vars: BTreeSet<&str> = BTreeSet::new(); + for m in [&self.exp, &other.exp] { + vars.extend(m.keys().map(Box::as_ref)); + } + for m in [&self.poly, &other.poly] { + vars.extend(m.keys().map(Box::as_ref)); + } + for m in [&self.logs, &other.logs] { + vars.extend(m.keys().map(Box::as_ref)); + } + + let mut saw_gt = false; + let mut saw_lt = false; + let empty_exp = ExpProduct::empty(); + for v in vars { + let exp_a = self.exp.get(v).unwrap_or(&empty_exp); + let exp_b = other.exp.get(v).unwrap_or(&empty_exp); + let exp_order = exp_a.cmp_proven(exp_b)?; + let order = if exp_order == Ordering::Equal { + self.poly + .get(v) + .cloned() + .unwrap_or_else(BigRational::zero) + .cmp(&other.poly.get(v).cloned().unwrap_or_else(BigRational::zero)) + .then( + self.logs + .get(v) + .copied() + .unwrap_or(0) + .cmp(&other.logs.get(v).copied().unwrap_or(0)), + ) + } else { + exp_order + }; + match order { + Ordering::Greater => saw_gt = true, + Ordering::Less => saw_lt = true, + Ordering::Equal => {} + } + } + match (saw_gt, saw_lt) { + (true, true) => None, + (true, false) => Some(Ordering::Greater), + (false, true) => Some(Ordering::Less), + (false, false) => Some(Ordering::Equal), + } + } + + /// `true` iff `self` dominates `other` (grows at least as fast, and strictly + /// faster on at least one variable). + fn dominates(&self, other: &GrowthTerm) -> bool { + matches!(self.cmp(other), Some(Ordering::Greater)) + } + + /// `true` iff `self` dominates `other` or is asymptotically equal to it. + fn dominates_or_eq(&self, other: &GrowthTerm) -> bool { + matches!( + self.cmp(other), + Some(Ordering::Greater) | Some(Ordering::Equal) + ) + } +} + +impl Growth { + pub(crate) fn unknown(failure: GrowthFailure) -> Self { + Self::Unknown(vec![failure]) + } + + pub fn failures(&self) -> Option<&[GrowthFailure]> { + match self { + Self::Terms(_) => None, + Self::Unknown(failures) => Some(failures), + } + } + + /// Compute the growth class of an expression in a single bottom-up pass. + pub fn from_expr(expr: &Expr) -> Growth { + let analysis = AlgebraicAnalysis::new(&[expr]); + Self::from_analysis(expr, &analysis) + } + + pub(crate) fn from_analysis(expr: &Expr, analysis: &AlgebraicAnalysis) -> Growth { + growth_from_analysis(expr, analysis, &mut HashMap::new()) + } + + /// Partial order: `true` iff `self` grows at least as fast as `other`. + /// + /// Per the growth-rate reading, [`Growth::Unknown`] is the top element (it + /// may be arbitrarily large, e.g. a factorial), so it dominates everything + /// and nothing known dominates it. For two term antichains, `self` + /// dominates `other` iff every term of `other` is dominated-or-equal by + /// some term of `self` — the standard antichain (Pareto) comparison. + pub fn dominates(&self, other: &Growth) -> bool { + match (self, other) { + (Growth::Unknown(_), _) => true, + (Growth::Terms(_), Growth::Unknown(_)) => false, + (Growth::Terms(a), Growth::Terms(b)) => { + b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) + } + } + } + + /// Render this growth class back to a display [`Expr`] (a sum of monomials), + /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic + /// sort order, so the rendered expression is platform-stable. + /// + /// Exponential factors are rendered directly from their authoritative + /// symbolic bases and coefficients; no base reconstruction is performed. + pub fn to_expr(&self) -> Option { + match self { + Growth::Unknown(_) => None, + Growth::Terms(terms) => { + if terms.is_empty() { + return Some(Expr::integer(1)); + } + let mut it = terms.iter().map(term_to_expr); + let mut acc = it.next().unwrap(); + for e in it { + acc = acc + e; + } + Some(acc) + } + } + } + + /// Canonical Big-O string for this growth class: `O()` for a bounded + /// class, or `O(?)` for [`Growth::Unknown`] (no honest asymptotic bound — + /// nonlinear exponent or factorial). This is the single source of truth for + /// how a growth is displayed as Big-O; presentation layers must call it rather + /// than re-deriving the mapping (and the `Unknown` spelling) themselves. + pub fn to_big_o(&self) -> String { + match self.to_expr() { + Some(e) => format!("O({e})"), + None => "O(?)".to_string(), + } + } +} + +fn growth_from_analysis( + expression: &Expr, + analysis: &AlgebraicAnalysis, + memo: &mut HashMap, +) -> Growth { + if let Some(growth) = memo.get(&expression.node_identity()) { + return growth.clone(); + } + + let facts = analysis.facts(expression); + if facts.is_constant { + let growth = if facts.constant_domain == Some(true) { + constant_growth() + } else { + unknown(GrowthFailure::InvalidConstantDomain { + expression: expression.to_string(), + }) + }; + memo.insert(expression.node_identity(), growth.clone()); + return growth; + } + + let growth = match expression.node() { + ExprNode::Const(_) => unreachable!("constants are handled before node projection"), + ExprNode::Var(variable) => { + let mut term = GrowthTerm::one(); + term.poly + .insert(variable.as_str().into(), BigRational::one()); + Growth::Terms(vec![term]) + } + ExprNode::Add(values) => values + .iter() + .map(|value| growth_from_analysis(value, analysis, memo)) + .reduce(add) + .expect("normalized sum has at least two terms"), + ExprNode::Mul(values) => values + .iter() + .map(|value| growth_from_analysis(value, analysis, memo)) + .reduce(mul) + .expect("normalized product has at least two factors"), + ExprNode::Pow(base, exponent) => { + let base_facts = analysis.facts(base); + let exponent_facts = analysis.facts(exponent); + if base_facts.is_constant && base_facts.constant_domain != Some(true) { + unknown(GrowthFailure::InvalidConstantDomain { + expression: base.to_string(), + }) + } else if exponent_facts.is_constant && exponent_facts.constant_domain != Some(true) { + unknown(GrowthFailure::InvalidConstantDomain { + expression: exponent.to_string(), + }) + } else if let Some(power) = exponent_facts.exact_rational.as_ref() { + if power.is_negative() { + unknown(GrowthFailure::NegativeExponent(exponent.to_string())) + } else { + pow_const(growth_from_analysis(base, analysis, memo), power) + } + } else if let ExprNode::Exp(argument) = base.node() { + match analysis.facts(argument).exact_rational.as_ref() { + Some(coefficient) => exponential( + ExpBase::Natural, + scale_growth_linear(exponent_facts.linear.clone(), coefficient), + exponent, + ), + None => unknown(GrowthFailure::InvalidExponentialBase(base.to_string())), + } + } else if let Some(base_value) = base_facts.exact_rational.as_ref() { + if base_value.is_positive() { + exponential( + ExpBase::Constant(Expr::constant(base_value.clone())), + growth_linear(exponent_facts.linear.clone()), + exponent, + ) + } else { + unknown(GrowthFailure::InvalidExponentialBase(base.to_string())) + } + } else if base_facts.is_constant { + unknown(GrowthFailure::InvalidExponentialBase(base.to_string())) + } else { + unknown(GrowthFailure::VariableBaseAndExponent( + expression.to_string(), + )) + } + } + ExprNode::Exp(value) => { + let value_growth = growth_from_analysis(value, analysis, memo); + if matches!(value_growth, Growth::Unknown(_)) { + value_growth + } else { + exponential( + ExpBase::Natural, + growth_linear(analysis.facts(value).linear.clone()), + expression, + ) + } + } + ExprNode::Log(value) => log_growth(growth_from_analysis(value, analysis, memo)), + ExprNode::Factorial(_) => unknown(GrowthFailure::FactorialOfNonconstant( + expression.to_string(), + )), + }; + memo.insert(expression.node_identity(), growth.clone()); + growth +} + +fn growth_linear( + linear: Option>, +) -> Option, BigRational>> { + Some( + linear? + .into_iter() + .map(|(symbol, coefficient)| (symbol.as_str().into(), coefficient)) + .collect(), + ) +} + +fn scale_growth_linear( + linear: Option>, + coefficient: &BigRational, +) -> Option, BigRational>> { + Some( + linear? + .into_iter() + .map(|(symbol, value)| (symbol.as_str().into(), coefficient * value)) + .collect(), + ) +} +fn constant_growth() -> Growth { + Growth::Terms(vec![GrowthTerm::one()]) +} + +fn unknown(failure: GrowthFailure) -> Growth { + Growth::unknown(failure) +} + +fn merge_unknown(left: Growth, right: Growth) -> Growth { + let mut failures = Vec::new(); + if let Growth::Unknown(left) = left { + failures.extend(left); + } + if let Growth::Unknown(right) = right { + failures.extend(right); + } + failures.sort(); + failures.dedup(); + Growth::Unknown(failures) +} + +/// Render one monomial as a product of its factors (or `Const(1)` when empty). +fn term_to_expr(t: &GrowthTerm) -> Expr { + let mut factors: Vec = Vec::new(); + for (v, product) in &t.exp { + factors.extend(product.factors.iter().map(|factor| exp_factor(v, factor))); + } + for (v, deg) in &t.poly { + factors.push(poly_factor(v, deg)); + } + for (v, power) in &t.logs { + factors.push(log_factor(v, *power)); + } + let mut it = factors.into_iter(); + match it.next() { + None => Expr::integer(1), + Some(first) => it.fold(first, |acc, f| acc * f), + } +} + +/// Render a stored exponential factor without changing its base or coefficient. +fn exp_factor(v: &str, factor: &ExpFactor) -> Expr { + let exponent = if factor.coefficient.is_one() { + Expr::variable(v) + } else { + Expr::constant(factor.coefficient.clone()) * Expr::variable(v) + }; + match &factor.base { + ExpBase::Constant(base) => Expr::pow(base.clone(), exponent), + ExpBase::Natural => Expr::exp(exponent), + } +} + +/// Render `v^degree` (`Display` turns degree `0.5` into `sqrt(v)`). +fn poly_factor(v: &str, degree: &BigRational) -> Expr { + if degree.is_one() { + Expr::variable(v) + } else { + Expr::pow(Expr::variable(v), Expr::constant(degree.clone())) + } +} + +/// Render `(log v)^power`. +fn log_factor(v: &str, power: u32) -> Expr { + let log = Expr::log(Expr::variable(v)); + if power == 1 { + log + } else { + Expr::pow(log, Expr::integer(power)) + } +} + +/// Prune a bag of terms to its maximal antichain: drop any term dominated by +/// another and collapse exact duplicates. The resulting *set* is independent of +/// input order. +fn prune(mut terms: Vec) -> Vec { + // Proven-equal terms can retain different symbolic spellings (for example, + // `exp(n)` and a literal-e base). Sort first so the representative does not + // depend on operand order. + terms.sort_by_cached_key(GrowthTerm::sort_key); + let mut result: Vec = Vec::new(); + for t in terms { + if result.iter().any(|r| r.dominates_or_eq(&t)) { + continue; + } + result.retain(|r| !t.dominates(r)); + result.push(t); + } + result +} + +fn growth_term_is_valid(term: &GrowthTerm) -> bool { + term.exp + .values() + .all(|product| !product.is_empty() && product.is_valid()) + && term.poly.values().all(|degree| !degree.is_negative()) +} + +/// Prune to the exact maximal antichain and sort deterministically. +fn make_growth(terms: Vec) -> Growth { + if !terms.iter().all(growth_term_is_valid) { + return unknown(GrowthFailure::InvalidGrowthTerm); + } + let pruned = prune(terms); + debug_assert!(pruned.iter().all(growth_term_is_valid)); + Growth::Terms(pruned) +} + +/// Antichain union (asymptotic `+ ≍ max`). +fn add(a: Growth, b: Growth) -> Growth { + match (a, b) { + (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { + merge_unknown(left, right) + } + (Growth::Terms(mut x), Growth::Terms(y)) => { + x.extend(y); + make_growth(x) + } + } +} + +/// Pairwise product of two antichains. +fn mul(a: Growth, b: Growth) -> Growth { + match (a, b) { + (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { + merge_unknown(left, right) + } + (Growth::Terms(x), Growth::Terms(y)) => { + let mut prod = Vec::with_capacity(x.len() * y.len()); + for tx in &x { + for ty in &y { + match tx.mul(ty) { + Ok(term) => prod.push(term), + Err(failure) => return unknown(failure), + } + } + } + make_growth(prod) + } + } +} + +/// Raise a whole antichain to a nonnegative real power `k` (raise each term). +fn pow_const(g: Growth, k: &BigRational) -> Growth { + match g { + Growth::Unknown(failures) => Growth::Unknown(failures), + Growth::Terms(terms) => match terms.iter().map(|term| term.pow(k)).collect() { + Ok(terms) => make_growth(terms), + Err(failure) => unknown(failure), + }, + } +} + +/// Transfer function for a symbolic fixed-base exponential. +fn exponential( + base: ExpBase, + linear: Option, BigRational>>, + exponent: &Expr, +) -> Growth { + let direction = base.direction(); + if direction == Ordering::Equal { + // 1^x = 1 for every x: bounded by O(1). + return Growth::Terms(vec![GrowthTerm::one()]); + } + match linear { + None => unknown(GrowthFailure::NonlinearExponent(exponent.to_string())), + Some(coeffs) => { + let mut term = GrowthTerm::one(); + for (v, coeff) in coeffs { + if (direction == Ordering::Greater && coeff.is_positive()) + || (direction == Ordering::Less && coeff.is_negative()) + { + term.exp.insert(v, ExpProduct::single(base.clone(), coeff)); + } else if !coeff.is_zero() { + return unknown(GrowthFailure::DecayingExponential { + base: base.structural_key(), + variable: v.to_string(), + coefficient: coeff.to_string(), + }); + } + } + make_growth(vec![term]) + } + } +} + +/// Transfer function for `Log(a)`: `log` of an antichain is `log` of its +/// dominant term(s), unioned. Uses `log(n^a · m^b) ≍ log n + log m` and +/// `log(2^(r·n)) ≍ n`. +fn log_growth(g: Growth) -> Growth { + match g { + Growth::Unknown(failures) => Growth::Unknown(failures), + Growth::Terms(terms) => { + let mut out = Vec::new(); + for t in &terms { + out.extend(log_term(t)); + } + if out.is_empty() { + out.push(GrowthTerm::one()); // log(O(1)) = O(1) + } + make_growth(out) + } + } +} + +/// `log` of a single monomial, returned as its own (small) antichain of +/// summands. `log(∏ baseᵢ^(rᵢ·vᵢ) · ∏vⱼ^aⱼ · ∏(log vₖ)^sₖ)` distributes over the +/// product into a *sum* of the log of each factor, so every factor class of the +/// monomial contributes its own summand — none may be dropped (e.g. `log(2^n·m)` +/// is `n + log m`, not `n`). `make_growth`/`prune` then collapse any dominated +/// summands (so `log(2^n·n^2)` reduces back to `n`). +fn log_term(t: &GrowthTerm) -> Vec { + let mut out = Vec::new(); + // Every stored exponential product grows, so its logarithm is linear. + for v in t.exp.keys().cloned() { + let mut g = GrowthTerm::one(); + g.poly.insert(v, BigRational::one()); + out.push(g); + } + // log(v^a) ≍ log v: each positive-degree polynomial factor becomes a log. + for v in t + .poly + .iter() + .filter(|(_, degree)| degree.is_positive()) + .map(|(variable, _)| variable.clone()) + { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + out.push(g); + } + // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for + // v ≥ 2): each log factor stays a single log. + for v in t.logs.keys().cloned() { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + out.push(g); + } + // Empty term: log(O(1)) = O(1). + if out.is_empty() { + out.push(GrowthTerm::one()); + } + out +} + +// --- serde --- +// +// Deserialize through an unchecked representation, then enforce the growth +// domain's invariants before constructing a term. + +impl<'de> serde::Deserialize<'de> for GrowthTerm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct Repr { + exp: BTreeMap, + poly: BTreeMap, + logs: BTreeMap, + } + let r = Repr::deserialize(deserializer)?; + let term = GrowthTerm { + exp: r + .exp + .into_iter() + .map(|(key, product)| (key.into_boxed_str(), ExpProduct::new(product.factors))) + .collect(), + poly: r + .poly + .into_iter() + .map(|(key, value)| (key.into_boxed_str(), value)) + .collect(), + logs: r + .logs + .into_iter() + .map(|(key, value)| (key.into_boxed_str(), value)) + .collect(), + }; + if growth_term_is_valid(&term) { + Ok(term) + } else { + Err(serde::de::Error::custom("invalid symbolic growth term")) + } + } +} + +#[cfg(test)] +#[path = "unit_tests/growth.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index 2083070c1..2a2b618ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,17 +20,21 @@ extern crate self as problemreductions; pub(crate) mod big_o; -pub(crate) mod canonical; pub mod config; pub mod error; #[cfg(feature = "example-db")] pub mod example_db; pub mod export; -pub(crate) mod expr; +pub mod expr; +// Growth is an explicit terminal projection for complexity display. Exact and certified +// size propagation never re-enters this domain. +pub mod growth; pub mod io; pub mod models; +pub mod random; pub mod registry; pub mod rules; +pub mod size; pub mod solvers; pub mod topology; pub mod traits; @@ -110,9 +114,11 @@ pub mod prelude { // Re-export commonly used items at crate root pub use big_o::big_o_normal_form; -pub use canonical::canonical_form; pub use error::{ProblemError, Result}; -pub use expr::{asymptotic_normal_form, AsymptoticAnalysisError, CanonicalizationError, Expr}; +pub use expr::{ + evaluate_approximate, ApproximationError, AsymptoticAnalysisError, Expr, ParseError, +}; +pub use growth::Growth; pub use registry::{ComplexityClass, ProblemInfo}; pub use solvers::{BruteForce, Solver}; pub use traits::Problem; @@ -122,11 +128,14 @@ 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; +#[cfg(all(test, feature = "example-db"))] +#[path = "unit_tests/symbolic_size_contracts.rs"] +mod symbolic_size_contracts; #[cfg(test)] #[path = "unit_tests/graph_models.rs"] mod test_graph_models; 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 3e3410538..a6d004a28 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}; @@ -14,13 +14,10 @@ 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: &[ - 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 +150,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 +318,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..5b5efc62d 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}; @@ -18,12 +18,10 @@ 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: &[ - 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 +71,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 +198,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..8337ffe96 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}; @@ -14,12 +14,10 @@ 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: &[ - 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 +27,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 +149,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/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 d80cc1ac9..4866ed45d 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}; @@ -17,13 +17,10 @@ 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: &[ - 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 +63,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 +370,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/ilp.rs b/src/models/algebraic/ilp.rs index adcb80d15..c58ff7e23 100644 --- a/src/models/algebraic/ilp.rs +++ b/src/models/algebraic/ilp.rs @@ -7,7 +7,7 @@ //! - `ILP`: binary variables (0 or 1) //! - `ILP`: non-negative integer variables (0..2^31-1) -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::Extremum; use serde::{Deserialize, Serialize}; @@ -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: &[ @@ -30,6 +31,13 @@ inventory::submit! { } } +inventory::submit! { + ProblemSizeFieldEntry { + name: "ILP", + fields: &["num_vars", "num_constraints"], + } +} + /// Sealed trait for ILP variable domains. /// /// `bool` = binary variables (0 or 1), `i32` = non-negative integers (0..2^31-1). 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 a4c424200..18d42a760 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}; @@ -15,12 +15,10 @@ 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: &[ - 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 +59,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 +175,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..ff04f5042 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}; @@ -14,12 +14,10 @@ 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: &[ - 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 +58,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 +236,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/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 a36fd7bc4..e15eba615 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, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; use serde::{Deserialize, Serialize}; @@ -13,12 +13,17 @@ 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: &[ - 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, + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "QUBO", + fields: &["num_vars"], } } @@ -61,6 +66,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. /// @@ -174,7 +197,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/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 7f4f5e9c5..a92d8fc07 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}; @@ -14,12 +14,10 @@ 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: &[ - 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 +33,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 +155,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 7ef3d129d..e64b10590 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 @@ -29,8 +29,8 @@ macro_rules! decision_problem_meta { /// /// The `size_getters` parameter defines problem-specific size fields as /// `(name, getter_on_inner)` pairs, e.g., `[("num_vertices", num_vertices), ("num_edges", num_edges)]`. -/// These are used for overhead expressions and `ProblemSize` extraction. -/// The macro automatically adds a `("k", k)` entry for `source_size_fn` on the Decision side. +/// These are used for size expressions and `ProblemSize` extraction. +/// The macro also measures `k` on the Decision source side. /// /// Callers must define inherent methods on `Decision` (delegating to `self.inner()`) /// and a `k()` method (from `self.bound()`) **before** invoking this macro. @@ -42,20 +42,30 @@ 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)),* $(,)?] + $(, $random:ident)? ) => { - $crate::declare_variants! { - default $crate::models::decision::Decision<$inner> => $complexity, + 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::register_decision_variant!(@declare $inner, $complexity $(, $random)?); + $crate::inventory::submit! { $crate::registry::ProblemSchemaEntry { name: $name, display_name: $crate::register_decision_variant!(@display_name $name), aliases: $aliases, dimensions: &[$($dim),*], + category: $category, module_path: module_path!(), description: $description, fields: &[$($field),*], @@ -69,7 +79,11 @@ macro_rules! register_decision_variant { target_name: <$inner as $crate::traits::Problem>::NAME, source_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, target_variant_fn: <$inner as $crate::traits::Problem>::variant, - overhead_fn: || $crate::rules::ReductionOverhead::identity(&[$($sg_name),*]), + size_declarations_fn: || $crate::rules::registry::ReductionSizeDeclarations { + relation: Some($crate::size::SizeRelation::Exact), + fields: vec![$(($sg_name, $crate::expr::Expr::variable($sg_name))),*], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: Some(|any| { let source = any @@ -87,16 +101,8 @@ macro_rules! register_decision_variant { <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source), ) }), - capabilities: $crate::rules::EdgeCapabilities::both(), - overhead_eval_fn: |any| { - let source = any - .downcast_ref::<$crate::models::decision::Decision<$inner>>() - .expect(concat!($name, " overhead source type mismatch")); - $crate::types::ProblemSize::new(vec![ - $(($sg_name, source.$sg_method())),* - ]) - }, - source_size_fn: |any| { + turing: false, + source_size_measure_fn: |any| { let source = any .downcast_ref::<$crate::models::decision::Decision<$inner>>() .expect(concat!($name, " size source type mismatch")); @@ -105,6 +111,14 @@ macro_rules! register_decision_variant { ("k", source.k()), ]) }, + target_size_measure_fn: |any| { + let target = any + .downcast_ref::<$inner>() + .expect(concat!($name, " size target type mismatch")); + $crate::types::ProblemSize::new(vec![ + $(($sg_name, target.$sg_method())),* + ]) + }, } } @@ -115,30 +129,45 @@ macro_rules! register_decision_variant { target_name: $name, source_variant_fn: <$inner as $crate::traits::Problem>::variant, target_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, - overhead_fn: || $crate::rules::ReductionOverhead::identity(&[$($sg_name),*]), + size_declarations_fn: || $crate::rules::registry::ReductionSizeDeclarations { + relation: Some($crate::size::SizeRelation::Exact), + fields: vec![$(($sg_name, $crate::expr::Expr::variable($sg_name))),*], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: $crate::rules::EdgeCapabilities::turing(), - overhead_eval_fn: |any| { + turing: true, + source_size_measure_fn: |any| { let source = any .downcast_ref::<$inner>() - .expect(concat!($name, " turing overhead source type mismatch")); + .expect(concat!($name, " turing size source type mismatch")); $crate::types::ProblemSize::new(vec![ $(($sg_name, source.$sg_method())),* ]) }, - source_size_fn: |any| { - let source = any - .downcast_ref::<$inner>() - .expect(concat!($name, " turing size source type mismatch")); + target_size_measure_fn: |any| { + let target = any + .downcast_ref::<$crate::models::decision::Decision<$inner>>() + .expect(concat!($name, " turing size target type mismatch")); $crate::types::ProblemSize::new(vec![ - $(($sg_name, source.$sg_method())),* + $(($sg_name, target.$sg_method())),* ]) }, } } }; + + (@declare $inner:ty, $complexity:literal, random) => { + $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" }; @@ -153,6 +182,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 @@ -279,8 +356,13 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } 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 1dc118638..23e185bd8 100644 --- a/src/models/formula/ksat.rs +++ b/src/models/formula/ksat.rs @@ -8,9 +8,9 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::variant::{KValue, K2, K3, KN}; -use serde::{Deserialize, Serialize}; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; -use super::CNFClause; +use super::{sat::validate_cnf_literals, CNFClause}; pub(crate) fn first_n_odd_primes(count: usize) -> Vec { let mut primes = Vec::with_capacity(count); @@ -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: &[ @@ -93,8 +94,7 @@ inventory::submit! { /// let solutions = solver.find_all_witnesses(&problem); /// assert!(!solutions.is_empty()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = ""))] +#[derive(Debug, Clone, Serialize)] pub struct KSatisfiability { /// Number of variables. num_vars: usize, @@ -104,6 +104,22 @@ pub struct KSatisfiability { _phantom: std::marker::PhantomData, } +#[derive(Deserialize)] +struct KSatisfiabilityDef { + num_vars: usize, + clauses: Vec, +} + +impl<'de, K: KValue> Deserialize<'de> for KSatisfiability { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = KSatisfiabilityDef::deserialize(deserializer)?; + Self::try_new(value.num_vars, value.clauses).map_err(D::Error::custom) + } +} + impl KSatisfiability { /// Create a new K-SAT problem. /// @@ -112,22 +128,27 @@ impl KSatisfiability { /// concrete value like K2, K3). When K is KN (arbitrary), no clause-length /// validation is performed. pub fn new(num_vars: usize, clauses: Vec) -> Self { + Self::try_new(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a K-SAT problem after validating its clauses. + pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; if let Some(k) = K::K { for (i, clause) in clauses.iter().enumerate() { - assert!( - clause.len() == k, - "Clause {} has {} literals, expected {}", - i, - clause.len(), - k - ); + if clause.len() != k { + return Err(format!( + "Clause {i} has {} literals, expected {k}", + clause.len() + )); + } } } - Self { + Ok(Self { num_vars, clauses, _phantom: std::marker::PhantomData, - } + }) } /// Create a new K-SAT problem allowing clauses with fewer than K literals. @@ -140,22 +161,27 @@ impl KSatisfiability { /// value like K2, K3). When K is KN (arbitrary), no clause-length /// validation is performed. pub fn new_allow_less(num_vars: usize, clauses: Vec) -> Self { + Self::try_new_allow_less(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a K-SAT problem with shorter clauses after validation. + pub fn try_new_allow_less(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; if let Some(k) = K::K { for (i, clause) in clauses.iter().enumerate() { - assert!( - clause.len() <= k, - "Clause {} has {} literals, expected at most {}", - i, - clause.len(), - k - ); + if clause.len() > k { + return Err(format!( + "Clause {i} has {} literals, expected at most {k}", + clause.len() + )); + } } } - Self { + Ok(Self { num_vars, clauses, _phantom: std::marker::PhantomData, - } + }) } /// Get the number of variables. diff --git a/src/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index 6d9f20843..ca415b878 100644 --- a/src/models/formula/maximum_2_satisfiability.rs +++ b/src/models/formula/maximum_2_satisfiability.rs @@ -9,7 +9,7 @@ use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; -use super::CNFClause; +use super::{sat::validate_cnf_literals, CNFClause}; inventory::submit! { ProblemSchemaEntry { @@ -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: &[ @@ -51,6 +52,7 @@ inventory::submit! { /// let value = solver.solve(&problem); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "Maximum2SatisfiabilityDef")] pub struct Maximum2Satisfiability { /// Number of Boolean variables. num_vars: usize, @@ -64,15 +66,21 @@ impl Maximum2Satisfiability { /// # Panics /// Panics if any clause does not have exactly 2 literals. pub fn new(num_vars: usize, clauses: Vec) -> Self { + Self::try_new(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a new MAX-2-SAT problem after validating its clauses. + pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; for (i, clause) in clauses.iter().enumerate() { - assert!( - clause.len() == 2, - "Clause {} has {} literals, expected 2", - i, - clause.len() - ); + if clause.len() != 2 { + return Err(format!( + "Clause {i} has {} literals, expected 2", + clause.len() + )); + } } - Self { num_vars, clauses } + Ok(Self { num_vars, clauses }) } /// Get the number of variables. @@ -121,6 +129,20 @@ crate::declare_variants! { default Maximum2Satisfiability => "2^(0.7905 * num_variables)", } +#[derive(Deserialize)] +struct Maximum2SatisfiabilityDef { + num_vars: usize, + clauses: Vec, +} + +impl TryFrom for Maximum2Satisfiability { + type Error = String; + + fn try_from(value: Maximum2SatisfiabilityDef) -> Result { + Self::try_new(value.num_vars, value.clauses) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/formula/nae_satisfiability.rs b/src/models/formula/nae_satisfiability.rs index 5e79f2de4..6874d5c93 100644 --- a/src/models/formula/nae_satisfiability.rs +++ b/src/models/formula/nae_satisfiability.rs @@ -7,7 +7,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; -use super::CNFClause; +use super::{sat::validate_cnf_literals, CNFClause}; inventory::submit! { ProblemSchemaEntry { @@ -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: &[ @@ -50,6 +51,7 @@ impl NAESatisfiability { /// Create a new NAE-SAT problem, returning an error instead of panicking /// when a clause has fewer than two literals. pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; validate_clause_lengths(&clauses)?; Ok(Self { num_vars, clauses }) } 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 8b5453ee3..2d320ba8f 100644 --- a/src/models/formula/one_in_three_satisfiability.rs +++ b/src/models/formula/one_in_three_satisfiability.rs @@ -8,7 +8,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; -use super::CNFClause; +use super::{sat::validate_cnf_literals, CNFClause}; inventory::submit! { ProblemSchemaEntry { @@ -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: &[ @@ -55,6 +56,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "OneInThreeSatisfiabilityDef")] pub struct OneInThreeSatisfiability { /// Number of variables. num_vars: usize, @@ -69,26 +71,21 @@ impl OneInThreeSatisfiability { /// Panics if any clause does not have exactly 3 literals, or if any /// literal references a variable outside the range [1, num_vars]. pub fn new(num_vars: usize, clauses: Vec) -> Self { + Self::try_new(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a new 1-in-3 SAT problem after validating its clauses. + pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; for (i, clause) in clauses.iter().enumerate() { - assert!( - clause.len() == 3, - "Clause {} has {} literals, expected 3", - i, - clause.len() - ); - for &lit in &clause.literals { - let var = lit.unsigned_abs() as usize; - assert!( - var >= 1 && var <= num_vars, - "Clause {} contains literal {} referencing variable {} outside range [1, {}]", - i, - lit, - var, - num_vars - ); + if clause.len() != 3 { + return Err(format!( + "Clause {i} has {} literals, expected 3", + clause.len() + )); } } - Self { num_vars, clauses } + Ok(Self { num_vars, clauses }) } /// Get the number of variables. @@ -156,6 +153,20 @@ crate::declare_variants! { default OneInThreeSatisfiability => "1.307^num_variables", } +#[derive(Deserialize)] +struct OneInThreeSatisfiabilityDef { + num_vars: usize, + clauses: Vec, +} + +impl TryFrom for OneInThreeSatisfiability { + type Error = String; + + fn try_from(value: OneInThreeSatisfiabilityDef) -> Result { + Self::try_new(value.num_vars, value.clauses) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/formula/planar_3_satisfiability.rs b/src/models/formula/planar_3_satisfiability.rs index b3b91871b..0f5e51c57 100644 --- a/src/models/formula/planar_3_satisfiability.rs +++ b/src/models/formula/planar_3_satisfiability.rs @@ -9,7 +9,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; -use super::CNFClause; +use super::{sat::validate_cnf_literals, CNFClause}; inventory::submit! { ProblemSchemaEntry { @@ -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: &[ @@ -64,6 +65,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "Planar3SatisfiabilityDef")] pub struct Planar3Satisfiability { /// Number of variables. num_vars: usize, @@ -80,26 +82,21 @@ impl Planar3Satisfiability { /// /// **Note:** Planarity of the incidence graph is not checked. pub fn new(num_vars: usize, clauses: Vec) -> Self { + Self::try_new(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a new Planar 3-SAT problem after validating its clauses. + pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; for (i, clause) in clauses.iter().enumerate() { - assert!( - clause.len() == 3, - "Clause {} has {} literals, expected 3", - i, - clause.len() - ); - for &lit in &clause.literals { - let var = lit.unsigned_abs() as usize; - assert!( - var >= 1 && var <= num_vars, - "Clause {} contains literal {} referencing variable {} outside range [1, {}]", - i, - lit, - var, - num_vars - ); + if clause.len() != 3 { + return Err(format!( + "Clause {i} has {} literals, expected 3", + clause.len() + )); } } - Self { num_vars, clauses } + Ok(Self { num_vars, clauses }) } /// Get the number of variables. @@ -152,6 +149,20 @@ crate::declare_variants! { default Planar3Satisfiability => "1.307^num_variables", } +#[derive(Deserialize)] +struct Planar3SatisfiabilityDef { + num_vars: usize, + clauses: Vec, +} + +impl TryFrom for Planar3Satisfiability { + type Error = String; + + fn try_from(value: Planar3SatisfiabilityDef) -> Result { + Self::try_new(value.num_vars, value.clauses) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/formula/qbf.rs b/src/models/formula/qbf.rs index d202e9f17..99a8e76f7 100644 --- a/src/models/formula/qbf.rs +++ b/src/models/formula/qbf.rs @@ -8,7 +8,7 @@ //! ∀ (ForAll) or ∃ (Exists) and E is a Boolean expression in CNF, //! determine whether F is true. -use crate::models::formula::CNFClause; +use crate::models::formula::{sat::validate_cnf_literals, CNFClause}; use crate::registry::{FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -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: &[ @@ -63,6 +64,7 @@ pub enum Quantifier { /// assert!(problem.is_true()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "QuantifiedBooleanFormulasDef")] pub struct QuantifiedBooleanFormulas { /// Number of variables. num_vars: usize, @@ -79,18 +81,27 @@ impl QuantifiedBooleanFormulas { /// /// Panics if `quantifiers.len() != num_vars`. pub fn new(num_vars: usize, quantifiers: Vec, clauses: Vec) -> Self { - assert_eq!( - quantifiers.len(), - num_vars, - "quantifiers length ({}) must equal num_vars ({})", - quantifiers.len(), - num_vars - ); - Self { + Self::try_new(num_vars, quantifiers, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a QBF problem after validating its quantifiers and CNF literals. + pub fn try_new( + num_vars: usize, + quantifiers: Vec, + clauses: Vec, + ) -> Result { + if quantifiers.len() != num_vars { + return Err(format!( + "quantifiers length ({}) must equal num_vars ({num_vars})", + quantifiers.len() + )); + } + validate_cnf_literals(num_vars, &clauses)?; + Ok(Self { num_vars, quantifiers, clauses, - } + }) } /// Get the number of variables. @@ -181,6 +192,21 @@ crate::declare_variants! { default QuantifiedBooleanFormulas => "2^num_vars", } +#[derive(Deserialize)] +struct QuantifiedBooleanFormulasDef { + num_vars: usize, + quantifiers: Vec, + clauses: Vec, +} + +impl TryFrom for QuantifiedBooleanFormulas { + type Error = String; + + fn try_from(value: QuantifiedBooleanFormulasDef) -> Result { + Self::try_new(value.num_vars, value.quantifiers, value.clauses) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/formula/sat.rs b/src/models/formula/sat.rs index 8be2e2b90..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: &[ @@ -54,7 +55,10 @@ impl CNFClause { /// * `assignment` - Boolean assignment, 0-indexed pub fn is_satisfied(&self, assignment: &[bool]) -> bool { self.literals.iter().any(|&lit| { - let var = lit.unsigned_abs() as usize - 1; // Convert to 0-indexed + let var = usize::try_from(lit.unsigned_abs()) + .expect("u32 literal magnitude must fit usize") + .checked_sub(1) + .expect("CNF literal 0 is invalid"); let value = assignment.get(var).copied().unwrap_or(false); if lit > 0 { value @@ -68,7 +72,12 @@ impl CNFClause { pub fn variables(&self) -> Vec { self.literals .iter() - .map(|&lit| lit.unsigned_abs() as usize - 1) + .map(|&lit| { + usize::try_from(lit.unsigned_abs()) + .expect("u32 literal magnitude must fit usize") + .checked_sub(1) + .expect("CNF literal 0 is invalid") + }) .collect() } @@ -114,6 +123,7 @@ impl CNFClause { /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SatisfiabilityDef")] pub struct Satisfiability { /// Number of variables. num_vars: usize, @@ -124,7 +134,13 @@ pub struct Satisfiability { impl Satisfiability { /// Create a new SAT problem. pub fn new(num_vars: usize, clauses: Vec) -> Self { - Self { num_vars, clauses } + Self::try_new(num_vars, clauses).unwrap_or_else(|message| panic!("{message}")) + } + + /// Create a new SAT problem after validating its literal encoding. + pub fn try_new(num_vars: usize, clauses: Vec) -> Result { + validate_cnf_literals(num_vars, &clauses)?; + Ok(Self { num_vars, clauses }) } /// Get the number of variables. @@ -197,6 +213,49 @@ crate::declare_variants! { default Satisfiability => "2^num_variables", } +#[derive(Deserialize)] +struct SatisfiabilityDef { + num_vars: usize, + clauses: Vec, +} + +impl TryFrom for Satisfiability { + type Error = String; + + fn try_from(value: SatisfiabilityDef) -> Result { + Self::try_new(value.num_vars, value.clauses) + } +} + +pub(super) fn validate_cnf_literals(num_vars: usize, clauses: &[CNFClause]) -> Result<(), String> { + if num_vars > i32::MAX as usize { + return Err(format!( + "num_vars {num_vars} exceeds the SAT literal limit {}", + i32::MAX + )); + } + + for (clause_index, clause) in clauses.iter().enumerate() { + for &literal in &clause.literals { + if literal == 0 || literal == i32::MIN { + return Err(format!( + "clause {clause_index} contains invalid literal {literal}; allowed variable numbers are 1..={num_vars} with either sign" + )); + } + if usize::try_from(literal.unsigned_abs()) + .expect("SAT literal magnitude must fit usize") + > num_vars + { + return Err(format!( + "clause {clause_index} contains invalid literal {literal}; allowed variable numbers are 1..={num_vars} with either sign" + )); + } + } + } + + Ok(()) +} + /// Check if an assignment satisfies a SAT formula. /// /// # Arguments diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 4bb5f4935..1510dedfc 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -5,7 +5,7 @@ //! DAG, each group's total vertex weight is bounded, and the total //! inter-partition arc cost is bounded. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -21,15 +21,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition a directed graph into bounded-weight groups with an acyclic quotient graph and bounded inter-partition cost", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "arc_costs", type_name: "Vec", description: "Arc costs c(a) for each arc a in A, matching graph.arcs() order" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Maximum total vertex weight B for each partition" }, - FieldInfo { name: "cost_bound", type_name: "W::Sum", description: "Maximum total inter-partition arc cost K" }, - ], + fields: AcyclicPartitionCreateSpec::FIELDS, } } @@ -50,6 +45,68 @@ pub struct AcyclicPartition { cost_bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct AcyclicPartitionCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(name = "arc_costs", codec = "comma-separated")] + arc_weights: Option>, + weight_bound: i64, + cost_bound: i64, +} + +impl TryFrom for AcyclicPartition { + type Error = String; + + fn try_from(spec: AcyclicPartitionCreateSpec) -> Result { + if spec.arcs.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty arc list".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let vertex_weights = spec.weights.unwrap_or_else(|| vec![1; num_vertices]); + if vertex_weights.len() != num_vertices { + return Err(format!( + "weights has length {}, expected {num_vertices}", + vertex_weights.len() + )); + } + let arc_costs = spec + .arc_weights + .unwrap_or_else(|| vec![1; graph.num_arcs()]); + if arc_costs.len() != graph.num_arcs() { + return Err(format!( + "arc_weights has length {}, expected {}", + arc_costs.len(), + graph.num_arcs() + )); + } + Ok(Self::new( + graph, + vertex_weights, + arc_costs, + spec.weight_bound, + spec.cost_bound, + )) + } +} + impl AcyclicPartition { /// Create a new Acyclic Partition instance. pub fn new( @@ -237,7 +294,7 @@ fn is_valid_acyclic_partition( } crate::declare_variants! { - default AcyclicPartition => "num_vertices^num_vertices", + default AcyclicPartition => "num_vertices^num_vertices" create AcyclicPartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/balanced_complete_bipartite_subgraph.rs b/src/models/graph/balanced_complete_bipartite_subgraph.rs index fe2ce502f..6609764f4 100644 --- a/src/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,4 +1,4 @@ -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -10,12 +10,10 @@ inventory::submit! { display_name: "Balanced Complete Bipartite Subgraph", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Decide whether a bipartite graph contains a K_{k,k} subgraph", - fields: &[ - FieldInfo { name: "graph", type_name: "BipartiteGraph", description: "The bipartite graph G = (A, B, E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Balanced biclique size" }, - ], + fields: BalancedCompleteBipartiteSubgraphCreateSpec::FIELDS, } } @@ -28,6 +26,44 @@ pub struct BalancedCompleteBipartiteSubgraph { edge_lookup: HashSet<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BalancedCompleteBipartiteSubgraphCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Balanced biclique size. + k: usize, +} + +impl TryFrom for BalancedCompleteBipartiteSubgraph { + type Error = String; + + fn try_from(spec: BalancedCompleteBipartiteSubgraphCreateSpec) -> Result { + for (index, &(left, right)) in spec.biedges.iter().enumerate() { + if left >= spec.left { + return Err(format!( + "biedges[{index}] left vertex {left} is out of bounds for left partition size {}", + spec.left + )); + } + if right >= spec.right { + return Err(format!( + "biedges[{index}] right vertex {right} is out of bounds for right partition size {}", + spec.right + )); + } + } + Ok(Self::new( + BipartiteGraph::new(spec.left, spec.right, spec.biedges), + spec.k, + )) + } +} + impl BalancedCompleteBipartiteSubgraph { pub fn new(graph: BipartiteGraph, k: usize) -> Self { let edge_lookup = Self::build_edge_lookup(&graph); @@ -144,7 +180,7 @@ impl From for BalancedCompleteBipartiteSu } crate::declare_variants! { - default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices", + default BalancedCompleteBipartiteSubgraph => "1.3803^num_vertices" create BalancedCompleteBipartiteSubgraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biclique_cover.rs b/src/models/graph/biclique_cover.rs index ef94bad62..69b5b6a95 100644 --- a/src/models/graph/biclique_cover.rs +++ b/src/models/graph/biclique_cover.rs @@ -13,7 +13,7 @@ //! matrix of `G` (Monson, Pullman, Rees 1995), matching exact Boolean //! Matrix Factorization. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::BipartiteGraph; use crate::traits::Problem; use crate::types::Min; @@ -26,14 +26,10 @@ inventory::submit! { display_name: "Biclique Cover", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Cover bipartite edges with k bicliques", - fields: &[ - FieldInfo { name: "left_size", type_name: "usize", description: "Vertices in left partition" }, - FieldInfo { name: "right_size", type_name: "usize", description: "Vertices in right partition" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Bipartite edges" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of bicliques" }, - ], + fields: BicliqueCoverCreateSpec::FIELDS, } } @@ -70,6 +66,43 @@ pub struct BicliqueCover { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BicliqueCoverCreateSpec { + /// Number of vertices in the left partition. + left: usize, + /// Number of vertices in the right partition. + right: usize, + /// Bipartite edges in left-local, right-local coordinates. + #[create(codec = "bipartite-edge-list")] + biedges: Vec<(usize, usize)>, + /// Number of bicliques available to cover the edges. + k: usize, +} + +impl TryFrom for BicliqueCover { + type Error = String; + + fn try_from(spec: BicliqueCoverCreateSpec) -> Result { + for (edge_index, &(left_vertex, right_vertex)) in spec.biedges.iter().enumerate() { + if left_vertex >= spec.left { + return Err(format!( + "biedges[{edge_index}] left vertex {left_vertex} is out of bounds for left partition size {}", + spec.left + )); + } + if right_vertex >= spec.right { + return Err(format!( + "biedges[{edge_index}] right vertex {right_vertex} is out of bounds for right partition size {}", + spec.right + )); + } + } + + let graph = BipartiteGraph::new(spec.left, spec.right, spec.biedges); + Ok(Self::new(graph, spec.k)) + } +} + impl BicliqueCover { /// Create a new Biclique Cover problem. /// @@ -290,7 +323,7 @@ impl Problem for BicliqueCover { } crate::declare_variants! { - default BicliqueCover => "2^(num_vertices * rank)", + default BicliqueCover => "2^(num_vertices * rank)" create BicliqueCoverCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index d923c2ce9..70e084a22 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -4,7 +4,7 @@ //! adding some subset of the potential edges can make the graph biconnected //! without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -21,13 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Add weighted potential edges to make a graph biconnected within budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "potential_weights", type_name: "Vec<(usize, usize, W)>", description: "Potential edges with augmentation weights" }, - FieldInfo { name: "budget", type_name: "W::Sum", description: "Maximum total augmentation weight B" }, - ], + fields: BiconnectivityAugmentationCreateSpec::FIELDS, } } @@ -54,6 +51,65 @@ where budget: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BiconnectivityAugmentationCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + potential_weights: Vec<(usize, usize, i32)>, + budget: i64, +} + +impl TryFrom + for BiconnectivityAugmentation +{ + type Error = String; + fn try_from(spec: BiconnectivityAugmentationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + let graph = SimpleGraph::new(count, spec.graph); + let mut seen = BTreeSet::new(); + for &(u, v, _) in &spec.potential_weights { + if u >= count || v >= count { + return Err("potential edge endpoint is out of bounds".into()); + } + if u == v { + return Err("potential edge is a self-loop".into()); + } + let edge = normalize_edge(u, v); + if graph.has_edge(edge.0, edge.1) { + return Err("potential edge already exists in graph".into()); + } + if !seen.insert(edge) { + return Err("duplicate potential edge".into()); + } + } + Ok(Self { + graph, + potential_weights: spec.potential_weights, + budget: spec.budget, + }) + } +} + impl BiconnectivityAugmentation { /// Create a new biconnectivity augmentation instance. /// @@ -255,7 +311,7 @@ fn is_biconnected(graph: &G) -> bool { } crate::declare_variants! { - default BiconnectivityAugmentation => "2^num_potential_edges", + default BiconnectivityAugmentation => "2^num_potential_edges" create BiconnectivityAugmentationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 3badad9cb..ea0b841bc 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Bottleneck Traveling Salesman problem asks for a Hamiltonian cycle //! minimizing the maximum selected edge weight. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Bottleneck Traveling Salesman", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a Hamiltonian cycle minimizing the maximum selected edge weight", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z" }, - ], + fields: BottleneckTravelingSalesmanCreateSpec::FIELDS, } } @@ -31,6 +29,62 @@ pub struct BottleneckTravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BottleneckTravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for BottleneckTravelingSalesman { + type Error = String; + + fn try_from(spec: BottleneckTravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BottleneckTravelingSalesman { /// Create a BottleneckTravelingSalesman problem from a graph with edge weights. pub fn new(graph: SimpleGraph, edge_weights: Vec) -> Self { @@ -156,8 +210,18 @@ pub(crate) fn canonical_model_example_specs() -> Vec "num_vertices^2 * 2^num_vertices", + default BottleneckTravelingSalesman => "num_vertices^2 * 2^num_vertices" create BottleneckTravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 32f229a1a..68dc4e49d 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -4,7 +4,7 @@ //! weighted graph can be partitioned into at most `K` connected components, each //! of total weight at most `B`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -21,14 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into at most K connected components, each of total weight at most B", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, - FieldInfo { name: "max_components", type_name: "usize", description: "Upper bound K on the number of connected components" }, - FieldInfo { name: "max_weight", type_name: "W::Sum", description: "Upper bound B on the total weight of each component" }, - ], + fields: BoundedComponentSpanningForestCreateSpec::FIELDS, } } @@ -50,6 +46,44 @@ pub struct BoundedComponentSpanningForest { max_weight: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedComponentSpanningForestCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w(v) for each vertex v in V. + weights: Vec, + /// Upper bound K on the number of connected components. + k: usize, + /// Upper bound B on the total weight of each component. + max_weight: i64, +} + +impl TryFrom + for BoundedComponentSpanningForest +{ + type Error = String; + + fn try_from(spec: BoundedComponentSpanningForestCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.weights.iter().any(|&weight| weight < 0) { + return Err("weights must be nonnegative".to_string()); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + if spec.max_weight <= 0 { + return Err("max_weight must be positive".to_string()); + } + Ok(Self::new(spec.graph, spec.weights, spec.k, spec.max_weight)) + } +} + impl BoundedComponentSpanningForest { /// Create a new bounded-component spanning forest instance. pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { @@ -230,7 +264,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "3^num_vertices", + default BoundedComponentSpanningForest => "3^num_vertices" create BoundedComponentSpanningForestCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 217e203b6..16b580dc4 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -4,7 +4,7 @@ //! bound D, determine whether G has a spanning tree with total weight at most B //! and diameter (longest shortest path in edges) at most D. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -22,14 +22,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does G have a spanning tree with total weight <= B and diameter <= D?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound B on total tree weight" }, - FieldInfo { name: "diameter_bound", type_name: "usize", description: "Upper bound D on tree diameter (in edges)" }, - ], + fields: BoundedDiameterSpanningTreeCreateSpec::FIELDS, } } @@ -80,6 +76,78 @@ pub struct BoundedDiameterSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoundedDiameterSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + weight_bound: i64, + diameter_bound: usize, +} + +impl TryFrom + for BoundedDiameterSpanningTree +{ + type Error = String; + + fn try_from(spec: BoundedDiameterSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + if edge_weights.iter().any(|&weight| weight <= 0) { + return Err("edge_weights must be positive".to_string()); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + if spec.diameter_bound == 0 { + return Err("diameter_bound must be at least 1".to_string()); + } + Ok(Self::new( + graph, + edge_weights, + spec.weight_bound, + spec.diameter_bound, + )) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl BoundedDiameterSpanningTree { /// Create a new Bounded Diameter Spanning Tree instance. /// @@ -280,7 +348,7 @@ where } crate::declare_variants! { - default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices", + default BoundedDiameterSpanningTree => "num_vertices ^ num_vertices" create BoundedDiameterSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs index 47338a8f1..e17ac6954 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does G have a spanning tree with maximum vertex degree at most K?", fields: &[ diff --git a/src/models/graph/directed_hamiltonian_path.rs b/src/models/graph/directed_hamiltonian_path.rs index b395853cc..6dd2d6128 100644 --- a/src/models/graph/directed_hamiltonian_path.rs +++ b/src/models/graph/directed_hamiltonian_path.rs @@ -16,6 +16,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed graph contain a Hamiltonian path?", fields: &[ diff --git a/src/models/graph/directed_two_commodity_integral_flow.rs b/src/models/graph/directed_two_commodity_integral_flow.rs index f67445df5..9a1d18b92 100644 --- a/src/models/graph/directed_two_commodity_integral_flow.rs +++ b/src/models/graph/directed_two_commodity_integral_flow.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Directed Two-Commodity Integral Flow", aliases: &["D2CIF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Two-commodity integral flow feasibility on a directed graph", fields: &[ diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index d565fa123..92599eb1e 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -3,7 +3,7 @@ //! The problem asks whether an undirected graph contains pairwise //! vertex-disjoint paths connecting a prescribed collection of terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find pairwise vertex-disjoint paths connecting given terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminal_pairs", type_name: "Vec<(usize, usize)>", description: "Disjoint terminal pairs (s_i, t_i)" }, - ], + fields: DisjointConnectingPathsCreateSpec::FIELDS, } } @@ -39,6 +37,62 @@ pub struct DisjointConnectingPaths { terminal_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct DisjointConnectingPathsCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "edge-list")] + terminal_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for DisjointConnectingPaths { + type Error = String; + fn try_from(spec: DisjointConnectingPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.terminal_pairs.is_empty() { + return Err("terminal_pairs must contain at least one pair".into()); + } + let mut used = vec![false; count]; + for &(source, sink) in &spec.terminal_pairs { + if source >= count || sink >= count { + return Err("terminal pair endpoint is out of bounds".into()); + } + if source == sink { + return Err("terminal pair endpoints must be distinct".into()); + } + if used[source] || used[sink] { + return Err("terminal vertices must be pairwise disjoint".into()); + } + used[source] = true; + used[sink] = true; + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + terminal_pairs: spec.terminal_pairs, + }) + } +} + impl DisjointConnectingPaths { /// Create a new Disjoint Connecting Paths instance. /// @@ -243,7 +297,7 @@ fn is_valid_disjoint_connecting_paths( } crate::declare_variants! { - default DisjointConnectingPaths => "2^num_edges", + default DisjointConnectingPaths => "2^num_edges" create DisjointConnectingPathsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/eulerian_path.rs b/src/models/graph/eulerian_path.rs index b45f43db8..8f29e4261 100644 --- a/src/models/graph/eulerian_path.rs +++ b/src/models/graph/eulerian_path.rs @@ -28,6 +28,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed multigraph admit a directed trail using every arc exactly once?", fields: &[ diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index ef7252aae..0e44aef52 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; @@ -20,13 +20,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine whether Player 1 has a forced blue path between two terminals", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The source terminal s" }, - FieldInfo { name: "target", type_name: "usize", description: "The target terminal t" }, - ], + fields: GeneralizedHexCreateSpec::FIELDS, } } @@ -43,6 +40,40 @@ pub struct GeneralizedHex { target: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GeneralizedHexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// The source terminal s. + source: usize, + /// The target terminal t. + sink: usize, +} + +impl TryFrom for GeneralizedHex { + type Error = String; + + fn try_from(spec: GeneralizedHexCreateSpec) -> Result { + let num_vertices = spec.graph.num_vertices(); + if spec.source >= num_vertices { + return Err(format!( + "source {} is outside graph with {num_vertices} vertices", + spec.source + )); + } + if spec.sink >= num_vertices { + return Err(format!( + "sink {} is outside graph with {num_vertices} vertices", + spec.sink + )); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + Ok(Self::new(spec.graph, spec.source, spec.sink)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ClaimState { Unclaimed, @@ -263,8 +294,17 @@ where } } +crate::impl_random_generate!( + GeneralizedHex, + crate::random::EndpointRandomSpec, + |spec| { + let (source, sink) = spec.endpoints()?; + Ok(GeneralizedHex::new(spec.graph()?, source, sink)) + } +); + crate::declare_variants! { - default GeneralizedHex => "3^num_playable_vertices", + default GeneralizedHex => "3^num_playable_vertices" create GeneralizedHexCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index f69aadd2b..8901f07df 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum cut balanced bisection of a graph", fields: &[ diff --git a/src/models/graph/hamiltonian_circuit.rs b/src/models/graph/hamiltonian_circuit.rs index 471c15af0..7617b761c 100644 --- a/src/models/graph/hamiltonian_circuit.rs +++ b/src/models/graph/hamiltonian_circuit.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the graph contain a Hamiltonian circuit?", fields: &[ @@ -164,8 +165,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(HamiltonianCircuit::new(spec.graph()?)) } +); + crate::declare_variants! { - default HamiltonianCircuit => "1.657^num_vertices", + default HamiltonianCircuit => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/hamiltonian_path.rs b/src/models/graph/hamiltonian_path.rs index ddc39ffa1..fc324b7d9 100644 --- a/src/models/graph/hamiltonian_path.rs +++ b/src/models/graph/hamiltonian_path.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a Hamiltonian path in a graph", fields: &[ @@ -167,8 +168,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(HamiltonianPath::new(spec.graph()?)) } +); + crate::declare_variants! { - default HamiltonianPath => "1.657^num_vertices", + default HamiltonianPath => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index 08dfe8408..42b1ba45a 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a Hamiltonian path between two specified vertices in a graph", fields: &[ @@ -75,6 +76,20 @@ pub struct HamiltonianPathBetweenTwoVertices { target_vertex: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct HamiltonianPathBetweenTwoVerticesRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Path start vertex (default: 0). + source_vertex: Option, + /// Path end vertex (default: the final vertex). + target_vertex: Option, +} + impl HamiltonianPathBetweenTwoVertices { /// Create a new Hamiltonian Path Between Two Vertices problem. /// @@ -229,8 +244,32 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + HamiltonianPathBetweenTwoVerticesRandomSpec, + |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let source = spec.source_vertex.unwrap_or(0); + let sink = spec.target_vertex.unwrap_or(spec.num_vertices - 1); + if source >= spec.num_vertices || sink >= spec.num_vertices || source == sink { + return Err( + "source_vertex and target_vertex must be distinct valid vertices".to_string(), + ); + } + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + Ok(HamiltonianPathBetweenTwoVertices::new(graph, source, sink)) + } +); + crate::declare_variants! { - default HamiltonianPathBetweenTwoVertices => "1.657^num_vertices", + default HamiltonianPathBetweenTwoVertices => "1.657^num_vertices" random, } #[cfg(test)] diff --git a/src/models/graph/highly_connected_deletion.rs b/src/models/graph/highly_connected_deletion.rs index a932f3c81..53c4d92a8 100644 --- a/src/models/graph/highly_connected_deletion.rs +++ b/src/models/graph/highly_connected_deletion.rs @@ -32,6 +32,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Minimum number of edge deletions so every component is an isolated vertex or a highly connected graph on >=3 vertices", fields: &[ diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index 893cb1da3..935c2076c 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -3,7 +3,7 @@ //! Given a directed graph with overlapping bundle-capacity constraints on arcs, //! determine whether an integral flow can deliver a required amount to the sink. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow with Bundles", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow feasibility on a directed graph with overlapping bundle capacities", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G=(V,A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "bundles", type_name: "Vec>", description: "Bundles of arc indices covering A" }, - FieldInfo { name: "bundle_capacities", type_name: "Vec", description: "Capacity c_j for each bundle I_j" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowBundlesCreateSpec::FIELDS, } } @@ -46,6 +40,92 @@ pub struct IntegralFlowBundles { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowBundlesCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "semicolon-separated")] + bundles: Vec>, + #[create(codec = "comma-separated")] + bundle_capacities: Vec, + source: usize, + sink: usize, + requirement: u64, +} + +impl TryFrom for IntegralFlowBundles { + type Error = String; + fn try_from(spec: IntegralFlowBundlesCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + if spec.bundles.len() != spec.bundle_capacities.len() { + return Err("bundles length must match bundle_capacities length".into()); + } + if spec.requirement == 0 { + return Err("requirement must be positive".into()); + } + let mut covered = vec![false; spec.arcs.len()]; + let mut upper = vec![u64::MAX; spec.arcs.len()]; + for (i, (bundle, &capacity)) in spec.bundles.iter().zip(&spec.bundle_capacities).enumerate() + { + if capacity == 0 { + return Err(format!("bundle capacity {i} must be positive")); + } + let mut seen = BTreeSet::new(); + for &arc in bundle { + if arc >= spec.arcs.len() { + return Err(format!("bundle {i} arc is out of range")); + } + if !seen.insert(arc) { + return Err(format!("bundle {i} contains duplicate arc")); + } + covered[arc] = true; + upper[arc] = upper[arc].min(capacity); + } + } + for (arc, &is_covered) in covered.iter().enumerate() { + if !is_covered { + return Err(format!("arc {arc} must belong to a bundle")); + } + if usize::try_from(upper[arc]) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err(format!("arc {arc} upper bound is too large")); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + bundles: spec.bundles, + bundle_capacities: spec.bundle_capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowBundles { /// Create a new Integral Flow with Bundles instance. pub fn new( @@ -267,7 +347,7 @@ impl Problem for IntegralFlowBundles { } crate::declare_variants! { - default IntegralFlowBundles => "2^num_arcs", + default IntegralFlowBundles => "2^num_arcs" create IntegralFlowBundlesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 0798cd834..c54f7ea72 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -4,7 +4,7 @@ //! that must carry equal flow, determine whether an integral flow meeting the //! required sink inflow exists. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow with Homologous Arcs", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow feasibility with arc-pair equality constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - FieldInfo { name: "homologous_pairs", type_name: "Vec<(usize, usize)>", description: "Arc-index pairs (a, a') with f(a) = f(a')" }, - ], + fields: IntegralFlowHomologousArcsCreateSpec::FIELDS, } } @@ -51,6 +45,70 @@ pub struct IntegralFlowHomologousArcs { homologous_pairs: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowHomologousArcsCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Option>, + source: usize, + sink: usize, + requirement: u64, + #[create(codec = "equality-pair-list")] + homologous_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for IntegralFlowHomologousArcs { + type Error = String; + fn try_from(spec: IntegralFlowHomologousArcsCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + if capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + for &(a, b) in &spec.homologous_pairs { + if a >= spec.arcs.len() || b >= spec.arcs.len() { + return Err("homologous pair arc index is out of range".into()); + } + } + for &c in &capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + capacities, + source: spec.source, + sink: spec.sink, + requirement: spec.requirement, + homologous_pairs: spec.homologous_pairs, + }) + } +} + impl IntegralFlowHomologousArcs { pub fn new( graph: DirectedGraph, @@ -208,7 +266,7 @@ impl Problem for IntegralFlowHomologousArcs { } crate::declare_variants! { - default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs", + default IntegralFlowHomologousArcs => "(max_capacity + 1)^num_arcs" create IntegralFlowHomologousArcsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index d620d4b24..7fda1c4a8 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -4,7 +4,7 @@ //! non-terminals, and a sink demand, determine whether there exists an //! integral flow satisfying multiplier-scaled conservation. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,16 +15,10 @@ inventory::submit! { display_name: "Integral Flow With Multipliers", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow feasibility on a directed graph with multiplier-scaled conservation at non-terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "multipliers", type_name: "Vec", description: "Vertex multipliers h(v) in vertex order; source/sink entries are ignored" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Arc capacities c(a) in graph arc order" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at the sink" }, - ], + fields: IntegralFlowWithMultipliersCreateSpec::FIELDS, } } @@ -45,6 +39,75 @@ pub struct IntegralFlowWithMultipliers { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct IntegralFlowWithMultipliersCreateSpec { + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + capacities: Vec, + source: usize, + sink: usize, + #[create(codec = "comma-separated")] + multipliers: Vec, + requirement: u64, +} + +impl TryFrom for IntegralFlowWithMultipliers { + type Error = String; + fn try_from(spec: IntegralFlowWithMultipliersCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".into()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.capacities.len() != spec.arcs.len() { + return Err("capacities length must match arcs length".into()); + } + if spec.multipliers.len() != count { + return Err("multipliers length must match num_vertices".into()); + } + if spec.source >= count || spec.sink >= count { + return Err("source and sink must be valid vertices".into()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".into()); + } + for (v, &m) in spec.multipliers.iter().enumerate() { + if v != spec.source && v != spec.sink && m == 0 { + return Err("non-terminal multipliers must be positive".into()); + } + } + for &c in &spec.capacities { + if usize::try_from(c) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large".into()); + } + } + Ok(Self { + graph: DirectedGraph::new(count, spec.arcs), + source: spec.source, + sink: spec.sink, + multipliers: spec.multipliers, + capacities: spec.capacities, + requirement: spec.requirement, + }) + } +} + impl IntegralFlowWithMultipliers { pub fn new( graph: DirectedGraph, @@ -214,7 +277,7 @@ impl Problem for IntegralFlowWithMultipliers { } crate::declare_variants! { - default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs", + default IntegralFlowWithMultipliers => "(max_capacity + 1)^num_arcs" create IntegralFlowWithMultipliersCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/isomorphic_spanning_tree.rs b/src/models/graph/isomorphic_spanning_tree.rs index b624280e7..3bb981c61 100644 --- a/src/models/graph/isomorphic_spanning_tree.rs +++ b/src/models/graph/isomorphic_spanning_tree.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does graph G contain a spanning tree isomorphic to tree T?", fields: &[ diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 94fa7e788..24d99b665 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -3,7 +3,7 @@ //! KClique is the decision version of Clique: determine whether a graph //! contains a clique of size at least `k`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,12 +14,10 @@ inventory::submit! { display_name: "k-Clique", aliases: &["Clique"], dimensions: &[VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"])], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine whether a graph contains a clique of size at least k", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "k", type_name: "usize", description: "Minimum clique size threshold" }, - ], + fields: KCliqueCreateSpec::FIELDS, } } @@ -34,6 +32,50 @@ pub struct KClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KCliqueCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + k: usize, +} + +impl TryFrom for KClique { + type Error = String; + fn try_from(spec: KCliqueCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.k == 0 { + return Err("k must be positive".into()); + } + if spec.k > count { + return Err("k must be <= graph num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + k: spec.k, + }) + } +} + impl KClique { /// Create a new k-Clique problem instance. pub fn new(graph: G, k: usize) -> Self { @@ -135,8 +177,22 @@ fn is_kclique_config(graph: &G, config: &[usize], k: usize) -> bool { true } +crate::impl_random_generate!( + KClique, + crate::random::CliqueRandomSpec, + |spec| { + if spec.k == 0 || spec.k > spec.num_vertices { + return Err(format!( + "k must be between 1 and num_vertices ({})", + spec.num_vertices + )); + } + Ok(KClique::new(spec.graph()?, spec.k)) + } +); + crate::declare_variants! { - default KClique => "1.1996^num_vertices", + default KClique => "1.1996^num_vertices" create KCliqueCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9e810dfc9..d1fa16fde 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -3,7 +3,7 @@ //! The K-Coloring problem asks whether a graph can be colored with K colors //! such that no two adjacent vertices have the same color. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::{KValue, VariantParam, K2, K3, K4, K5, KN}; @@ -18,11 +18,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("k", "KN", &["KN", "K2", "K3", "K4", "K5"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find valid k-coloring of a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - ], + fields: RuntimeKColoringCreateSpec::FIELDS, } } @@ -68,6 +67,81 @@ pub struct KColoring { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct FixedKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuntimeKColoringCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Runtime color count. + k: usize, +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = num_vertices.unwrap_or(inferred); + if count < inferred { + return Err(format!( + "num_vertices {count} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(count, edges)) +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: FixedKColoringCreateSpec) -> Result { + let num_colors = K::K.ok_or("runtime KColoring requires k")?; + Ok(Self { + graph: simple_graph_from_create(spec.graph, spec.num_vertices)?, + num_colors, + _phantom: std::marker::PhantomData, + }) + } +} + +impl TryFrom for KColoring { + type Error = String; + + fn try_from(spec: RuntimeKColoringCreateSpec) -> Result { + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::with_k( + simple_graph_from_create(spec.graph, spec.num_vertices)?, + spec.k, + )) + } +} + fn default_num_colors() -> usize { K::K.unwrap_or(0) } @@ -200,13 +274,37 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::ColoringRandomSpec, |spec| { + let k = spec.k.unwrap_or(3); + if k == 0 { + return Err("k must be positive".to_string()); + } + Ok(KColoring::with_k(spec.graph()?, k)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 2) { return Err("k must match the selected K2 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 3) { return Err("k must match the selected K3 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 4) { return Err("k must match the selected K4 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); +crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { + if spec.k.is_some_and(|k| k != 5) { return Err("k must match the selected K5 variant".to_string()); } + Ok(KColoring::new(spec.graph()?)) +}); + crate::declare_variants! { - default KColoring => "2^num_vertices", - KColoring => "num_vertices + num_edges", - KColoring => "1.3289^num_vertices", - KColoring => "1.7159^num_vertices", + default KColoring => "2^num_vertices" create RuntimeKColoringCreateSpec random, + KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec random, + KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec random, + KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec random, // Best known: O*((2-ε)^n) for some ε > 0 (Zamir 2021), concrete ε unknown - KColoring => "2^num_vertices", + KColoring => "2^num_vertices" create FixedKColoringCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/kernel.rs b/src/models/graph/kernel.rs index 72b3e1b52..d9dc901a4 100644 --- a/src/models/graph/kernel.rs +++ b/src/models/graph/kernel.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "DirectedGraph", &["DirectedGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Does the directed graph contain a kernel (independent and absorbing vertex subset)?", fields: &[ diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index f26c26fcb..51f6204bc 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -3,7 +3,7 @@ //! Given a weighted graph, determine whether it contains `k` distinct spanning //! trees whose total weights are all at most a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; @@ -17,14 +17,10 @@ inventory::submit! { display_name: "Kth Best Spanning Tree", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Do there exist k distinct spanning trees with total weight at most B?", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w(e) for each edge in E" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of distinct spanning trees required" }, - FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on each spanning tree weight" }, - ], + fields: KthBestSpanningTreeCreateSpec::FIELDS, } } @@ -46,6 +42,65 @@ pub struct KthBestSpanningTree { bound: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthBestSpanningTreeCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + bound: i64, +} + +impl TryFrom for KthBestSpanningTree { + type Error = String; + + fn try_from(spec: KthBestSpanningTreeCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + weights.len(), + graph.num_edges() + )); + } + if spec.k == 0 { + return Err("k must be positive".to_string()); + } + Ok(Self::new(graph, weights, spec.k, spec.bound)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl KthBestSpanningTree { /// Create a new KthBestSpanningTree instance. /// @@ -240,7 +295,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^(num_edges * k)", + default KthBestSpanningTree => "2^(num_edges * k)" create KthBestSpanningTreeCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 93e97073c..7d4e7a366 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -3,7 +3,7 @@ //! The problem maximizes the number of internally vertex-disjoint `s-t` paths, //! each using at most `K` edges, over up to `max_paths` path slots. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Max; @@ -18,15 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Maximize the number of internally vertex-disjoint s-t paths of length at most K", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "source", type_name: "usize", description: "The shared source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "The shared sink vertex t" }, - FieldInfo { name: "max_paths", type_name: "usize", description: "Upper bound on the number of path slots" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum path length K in edges" }, - ], + fields: LengthBoundedDisjointPathsCreateSpec::FIELDS, } } @@ -48,6 +43,88 @@ pub struct LengthBoundedDisjointPaths { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LengthBoundedDisjointPathsCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Shared source vertex. + source: usize, + /// Shared sink vertex. + sink: usize, + /// Maximum path length in edges. + max_length: usize, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LengthBoundedDisjointPathsRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Source vertex (default: 0). + source: Option, + /// Sink vertex (default: the final vertex). + sink: Option, + /// Maximum path length (default: num_vertices - 1). + max_length: Option, +} + +impl TryFrom for LengthBoundedDisjointPaths { + type Error = String; + + fn try_from(spec: LengthBoundedDisjointPathsCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + if spec.source >= num_vertices || spec.sink >= num_vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.max_length == 0 { + return Err("max_length must be positive".to_string()); + } + + let graph = SimpleGraph::new(num_vertices, spec.graph); + let max_paths = graph + .neighbors(spec.source) + .len() + .min(graph.neighbors(spec.sink).len()); + Ok(Self { + graph, + source: spec.source, + sink: spec.sink, + max_paths, + max_length: spec.max_length, + }) + } +} + impl LengthBoundedDisjointPaths { /// Create a new Length-Bounded Disjoint Paths instance. /// @@ -300,8 +377,33 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + LengthBoundedDisjointPathsRandomSpec, + |spec| { + let endpoints = crate::random::EndpointRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + source: spec.source, + sink: spec.sink, + }; + let (source, sink) = endpoints.endpoints()?; + let max_length = spec.max_length.unwrap_or(spec.num_vertices - 1); + if max_length == 0 { + return Err("max_length must be positive".to_string()); + } + Ok(LengthBoundedDisjointPaths::new( + endpoints.graph()?, + source, + sink, + max_length, + )) + } +); + crate::declare_variants! { - default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)", + default LengthBoundedDisjointPaths => "2^(max_paths * num_vertices)" create LengthBoundedDisjointPathsCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 735d11d00..16d330f76 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -3,7 +3,7 @@ //! The Longest Circuit problem asks for a simple circuit in a graph //! that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a simple circuit in a graph that maximizes total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> Z_(> 0)" }, - ], + fields: LongestCircuitCreateSpec::FIELDS, } } @@ -48,6 +46,65 @@ pub struct LongestCircuit { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCircuitCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for LongestCircuit { + type Error = String; + + fn try_from(spec: LongestCircuitCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if edge_lengths.iter().any(|&length| length <= 0) { + return Err("edge_weights must be positive".to_string()); + } + Ok(Self::new(graph, edge_lengths)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl LongestCircuit { /// Create a new LongestCircuit instance. /// @@ -255,8 +312,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let lengths = vec![1; graph.num_edges()]; + Ok(LongestCircuit::new(graph, lengths)) +}); + crate::declare_variants! { - default LongestCircuit => "2^num_vertices * num_vertices^2", + default LongestCircuit => "2^num_vertices * num_vertices^2" create LongestCircuitCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index fd70dbeb3..86e2292aa 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -3,7 +3,7 @@ //! The Longest Path problem asks for a simple path between two distinguished //! vertices that maximizes the total edge length. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -20,14 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a simple s-t path of maximum total edge length", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - ], + fields: LongestPathI32CreateSpec::FIELDS, } } @@ -53,6 +49,63 @@ pub struct LongestPath { target_vertex: usize, } +macro_rules! longest_path_create_spec { + ($name:ident,$weight:ty) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_lengths: Vec<$weight>, + source_vertex: usize, + target_vertex: usize, + } + impl TryFrom<$name> for LongestPath { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + if spec.edge_lengths.len() != spec.graph.len() { + return Err("edge_lengths length must match graph edge count".into()); + } + if spec.edge_lengths.iter().any(|v| v.to_sum() <= 0) { + return Err("edge lengths must be positive".into()); + } + if spec.source_vertex >= count || spec.target_vertex >= count { + return Err("source_vertex and target_vertex must be valid vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + edge_lengths: spec.edge_lengths, + source_vertex: spec.source_vertex, + target_vertex: spec.target_vertex, + }) + } + } + }; +} +longest_path_create_spec!(LongestPathI32CreateSpec, i32); +longest_path_create_spec!(LongestPathOneCreateSpec, One); + impl LongestPath { fn assert_positive_edge_lengths(edge_lengths: &[W]) { let zero = W::Sum::zero(); @@ -253,8 +306,8 @@ fn is_simple_st_path( } crate::declare_variants! { - default LongestPath => "num_vertices * 2^num_vertices", - LongestPath => "num_vertices * 2^num_vertices", + default LongestPath => "num_vertices * 2^num_vertices" create LongestPathI32CreateSpec, + LongestPath => "num_vertices * 2^num_vertices" create LongestPathOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0dba64bbc..6df88f83a 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -3,7 +3,7 @@ //! The Maximum Cut problem asks for a partition of vertices into two sets //! that maximizes the total weight of edges crossing the partition. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -14,17 +14,15 @@ inventory::submit! { ProblemSchemaEntry { name: "MaxCut", display_name: "Max Cut", - aliases: &["GraphPartitioning", "MaximumBipartiteSubgraph"], + aliases: &["MaximumBipartiteSubgraph"], dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum weight cut in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The graph with edge weights" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaxCutI32CreateSpec::FIELDS, } } @@ -77,6 +75,67 @@ pub struct MaxCut { edge_weights: Vec, } +macro_rules! max_cut_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + } + + impl TryFrom<$name> for MaxCut { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } + } + }; +} + +max_cut_create_spec!(MaxCutI32CreateSpec, i32, 1); +max_cut_create_spec!(MaxCutOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaxCut { /// Create a MaxCut problem from a graph with specified edge weights. /// @@ -208,9 +267,15 @@ where total } +crate::impl_random_generate!(MaxCut, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(MaxCut::new(graph, weights)) +}); + crate::declare_variants! { - default MaxCut => "2^(2.372 * num_vertices / 3)", - MaxCut => "2^(0.7907 * num_vertices)", + default MaxCut => "2^(2.372 * num_vertices / 3)" create MaxCutI32CreateSpec random, + MaxCut => "2^(0.7907 * num_vertices)" create MaxCutOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index b08c37d4b..1c750f1fb 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -3,7 +3,7 @@ //! The Maximal Independent Set problem asks for an independent set that //! cannot be extended by adding any other vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum weight maximal independent set", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximalISCreateSpec::FIELDS, } } @@ -63,6 +61,28 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximalISCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom for MaximalIS { + type Error = String; + fn try_from(spec: MaximalISCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximalIS { /// Create a Maximal Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -222,8 +242,12 @@ pub(crate) fn is_maximal_independent_set(graph: &G, selected: &[bool]) true } +crate::impl_random_generate!(MaximalIS, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximalIS::new(spec.graph()?, vec![1; spec.num_vertices])) +}); + crate::declare_variants! { - default MaximalIS => "3^(num_vertices / 3)", + default MaximalIS => "3^(num_vertices / 3)" create MaximalISCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/maximum_achromatic_number.rs b/src/models/graph/maximum_achromatic_number.rs index b45aa0df2..de91a7b58 100644 --- a/src/models/graph/maximum_achromatic_number.rs +++ b/src/models/graph/maximum_achromatic_number.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a complete proper coloring maximizing the number of colors", fields: &[ @@ -155,8 +156,14 @@ where } } +crate::impl_random_generate!( + MaximumAchromaticNumber, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MaximumAchromaticNumber::new(spec.graph()?)) } +); + crate::declare_variants! { - default MaximumAchromaticNumber => "num_vertices^num_vertices", + default MaximumAchromaticNumber => "num_vertices^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 849bef38a..b7dd79e9c 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -3,7 +3,7 @@ //! The MaximumClique problem asks for a maximum weight subset of vertices //! such that all vertices in the subset are pairwise adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "One", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum weight clique in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumCliqueCreateSpec::::FIELDS, } } @@ -66,6 +64,28 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCliqueCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> for MaximumClique { + type Error = String; + fn try_from(spec: MaximumCliqueCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MaximumClique { /// Create a MaximumClique problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -164,9 +184,16 @@ fn is_clique_config(graph: &G, config: &[usize]) -> bool { true } +crate::impl_random_generate!(MaximumClique, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumClique::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumClique, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumClique::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - MaximumClique => "1.1996^num_vertices", - default MaximumClique => "1.1996^num_vertices", + MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec random, + default MaximumClique => "1.1996^num_vertices" create MaximumCliqueCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 5c691e4e0..969934cd7 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -8,7 +8,7 @@ //! For k = 1 the problem degenerates to [`MaximumIndependentSet`]; for larger //! k it is the maximum (k-1)-dependent set / co-k-plex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -26,13 +26,10 @@ inventory::submit! { VariantDimension::new("weight", "One", &["One", "i32"]), VariantDimension::new("k", "KN", &["KN"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum-weight vertex subset whose induced subgraph has maximum degree at most k-1", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound_k", type_name: "usize", description: "Co-k-plex parameter k >= 1; selected-vertex induced degree must be at most k-1" }, - ], + fields: MaximumCoKPlexCreateSpec::::FIELDS, } } @@ -91,6 +88,36 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumCoKPlexCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, + /// Co-k-plex parameter k >= 1. + k: usize, +} + +impl TryFrom> + for MaximumCoKPlex +{ + type Error = String; + + fn try_from(spec: MaximumCoKPlexCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + if spec.k == 0 { + return Err("k must be at least 1".to_string()); + } + Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + } +} + impl MaximumCoKPlex { /// Create an instance with an explicit runtime `k`. /// @@ -224,8 +251,8 @@ fn is_co_k_plex_config(graph: &G, config: &[usize], bound_k: usize) -> } crate::declare_variants! { - default MaximumCoKPlex => "2^num_vertices", - MaximumCoKPlex => "2^num_vertices", + default MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, + MaximumCoKPlex => "2^num_vertices" create MaximumCoKPlexCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_common_edge_subgraph.rs b/src/models/graph/maximum_common_edge_subgraph.rs index d35668c64..8a6577b9b 100644 --- a/src/models/graph/maximum_common_edge_subgraph.rs +++ b/src/models/graph/maximum_common_edge_subgraph.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Maximum Common Edge Subgraph", aliases: &["MCES"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Maximize the number of preserved labelled directed arcs under a partial injective vertex map from G1 into G2", fields: &[ diff --git a/src/models/graph/maximum_contact_map_overlap.rs b/src/models/graph/maximum_contact_map_overlap.rs index a325a83bb..d331c4744 100644 --- a/src/models/graph/maximum_contact_map_overlap.rs +++ b/src/models/graph/maximum_contact_map_overlap.rs @@ -26,6 +26,7 @@ inventory::submit! { display_name: "Maximum Contact Map Overlap", aliases: &["CMO", "MaxCMO"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Maximize the number of preserved contacts under an order-preserving partial injective alignment from G_1 into G_2", fields: &[ diff --git a/src/models/graph/maximum_domatic_number.rs b/src/models/graph/maximum_domatic_number.rs index 1052f6163..185b16b4a 100644 --- a/src/models/graph/maximum_domatic_number.rs +++ b/src/models/graph/maximum_domatic_number.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum number of disjoint dominating sets partitioning V", fields: &[ @@ -154,8 +155,14 @@ where } } +crate::impl_random_generate!( + MaximumDomaticNumber, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MaximumDomaticNumber::new(spec.graph()?)) } +); + crate::declare_variants! { - default MaximumDomaticNumber => "2.695^num_vertices", + default MaximumDomaticNumber => "2.695^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index 9babdbebc..74ab09b60 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -11,7 +11,7 @@ //! are allowed when `k` takes those values, with objective value 0 because no //! pair of selected vertices is induced. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -24,13 +24,10 @@ inventory::submit! { display_name: "Maximum Edge-Weighted k-Clique", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Select exactly k pairwise-adjacent vertices maximizing the total weight of induced clique edges", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights in graph edge order" }, - FieldInfo { name: "k", type_name: "usize", description: "Required clique size" }, - ], + fields: MaximumEdgeWeightedKCliqueCreateSpec::::FIELDS, } } @@ -77,6 +74,38 @@ pub struct MaximumEdgeWeightedKClique { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumEdgeWeightedKCliqueCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Required clique size. + k: usize, +} +impl TryFrom> for MaximumEdgeWeightedKClique +where + W: WeightElement + From, +{ + type Error = String; + fn try_from(spec: MaximumEdgeWeightedKCliqueCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if spec.k > spec.graph.num_vertices() { + return Err("k must not exceed the number of vertices".to_string()); + } + Ok(Self::new(spec.graph, edge_weights, spec.k)) + } +} + impl MaximumEdgeWeightedKClique { /// Create a new MaximumEdgeWeightedKClique instance. /// @@ -191,8 +220,8 @@ fn is_k_clique_config(graph: &SimpleGraph, config: &[usize], k: usize) -> bool { } crate::declare_variants! { - default MaximumEdgeWeightedKClique => "2^num_vertices", - MaximumEdgeWeightedKClique => "2^num_vertices", + default MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, + MaximumEdgeWeightedKClique => "2^num_vertices" create MaximumEdgeWeightedKCliqueCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 9f7e72a08..f3e6d047b 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -3,7 +3,7 @@ //! The Independent Set problem asks for a maximum weight subset of vertices //! such that no two vertices in the subset are adjacent. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph", "KingsSubgraph", "TriangularSubgraph", "UnitDiskGraph"]), VariantDimension::new("weight", "One", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum weight independent set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MaximumIndependentSetSimpleOneCreateSpec::FIELDS, } } @@ -66,6 +64,138 @@ pub struct MaximumIndependentSet { weights: Vec, } +macro_rules! simple_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err("self-loops are not allowed".into()); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small".into()); + } + let weights = spec.weights.unwrap_or_else(|| vec![$one; count]); + if weights.len() != count { + return Err("weights length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + weights, + }) + } + } + }; +} +simple_mis_spec!(MaximumIndependentSetSimpleOneCreateSpec, One, One); +simple_mis_spec!(MaximumIndependentSetSimpleI32CreateSpec, i32, 1_i32); + +macro_rules! grid_mis_spec { + ($name:ident,$graph:ty,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(i32, i32)>, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet<$graph, $weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: <$graph>::new(spec.positions), + weights, + }) + } + } + }; +} +grid_mis_spec!( + MaximumIndependentSetKingsOneCreateSpec, + KingsSubgraph, + One, + One +); +grid_mis_spec!( + MaximumIndependentSetKingsI32CreateSpec, + KingsSubgraph, + i32, + 1_i32 +); +grid_mis_spec!( + MaximumIndependentSetTriangularI32CreateSpec, + TriangularSubgraph, + i32, + 1_i32 +); + +macro_rules! unit_disk_mis_spec { + ($name:ident,$weight:ty,$one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + positions: Vec<(f64, f64)>, + radius: Option, + #[create(codec = "comma-separated")] + weights: Option>, + } + impl TryFrom<$name> for MaximumIndependentSet { + type Error = String; + fn try_from(spec: $name) -> Result { + let radius = spec.radius.unwrap_or(1.0); + if !radius.is_finite() || radius < 0.0 { + return Err("radius must be finite and nonnegative".into()); + } + if spec + .positions + .iter() + .any(|&(x, y)| !x.is_finite() || !y.is_finite()) + { + return Err("positions must be finite".into()); + } + let weights = spec + .weights + .unwrap_or_else(|| vec![$one; spec.positions.len()]); + if weights.len() != spec.positions.len() { + return Err("weights length must match positions length".into()); + } + Ok(Self { + graph: UnitDiskGraph::new(spec.positions, radius), + weights, + }) + } + } + }; +} +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskOneCreateSpec, One, One); +unit_disk_mis_spec!(MaximumIndependentSetUnitDiskI32CreateSpec, i32, 1_i32); + impl MaximumIndependentSet { /// Create an Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -153,14 +283,36 @@ fn is_independent_set_config(graph: &G, config: &[usize]) -> bool { true } +crate::impl_random_generate!(MaximumIndependentSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(spec.graph()?, vec![One; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(KingsSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(KingsSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![One; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::IntegerGeometryRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(TriangularSubgraph::new(crate::random::create_random_int_positions(spec.num_vertices, spec.seed)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::UnitDiskRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(UnitDiskGraph::new(crate::random::create_random_float_positions(spec.num_vertices, spec.seed), spec.radius.unwrap_or(1.0)), vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MaximumIndependentSet, crate::random::UnitDiskRandomSpec, |spec| { + Ok(MaximumIndependentSet::new(UnitDiskGraph::new(crate::random::create_random_float_positions(spec.num_vertices, spec.seed), spec.radius.unwrap_or(1.0)), vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - MaximumIndependentSet => "1.1996^num_vertices", - default MaximumIndependentSet => "1.1996^num_vertices", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", - MaximumIndependentSet => "2^sqrt(num_vertices)", + MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleI32CreateSpec random, + default MaximumIndependentSet => "1.1996^num_vertices" create MaximumIndependentSetSimpleOneCreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetKingsOneCreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetTriangularI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskI32CreateSpec random, + MaximumIndependentSet => "2^sqrt(num_vertices)" create MaximumIndependentSetUnitDiskOneCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MaximumIndependentSet diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 7fbf46b8c..475808b04 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find spanning tree maximizing the number of leaves", fields: &[ @@ -163,8 +164,19 @@ where } } +crate::impl_random_generate!( + MaximumLeafSpanningTree, + crate::random::SimpleGraphRandomSpec, + |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + Ok(MaximumLeafSpanningTree::new(spec.graph()?)) + } +); + crate::declare_variants! { - default MaximumLeafSpanningTree => "1.8966^num_vertices", + default MaximumLeafSpanningTree => "1.8966^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index f2c3d0719..f6137ca55 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -3,7 +3,7 @@ //! The Maximum Matching problem asks for a maximum weight set of edges //! such that no two edges share a vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, WeightElement}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find maximum weight matching in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: MaximumMatchingCreateSpec::FIELDS, } } @@ -66,6 +64,62 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumMatchingCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for MaximumMatching { + type Error = String; + + fn try_from(spec: MaximumMatchingCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MaximumMatching { /// Create a MaximumMatching problem from a graph with given edge weights. /// @@ -213,8 +267,14 @@ where } } +crate::impl_random_generate!(MaximumMatching, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(MaximumMatching::new(graph, weights)) +}); + crate::declare_variants! { - default MaximumMatching => "num_vertices^3", + default MaximumMatching => "num_vertices^3" create MaximumMatchingCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 5bf60ef53..52f002e28 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -3,10 +3,10 @@ //! The vertex p-center problem asks for K centers on vertices of a graph that //! minimize the maximum weighted distance from any vertex to its nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, WeightElement}; +use crate::types::{Min, One, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -19,14 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find K centers minimizing the maximum weighted distance from any vertex to its nearest center (vertex p-center)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinMaxMulticenterI32CreateSpec::FIELDS, } } @@ -69,6 +65,96 @@ pub struct MinMaxMulticenter { k: usize, } +macro_rules! min_max_multicenter_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, + } + + impl TryFrom<$name> for MinMaxMulticenter { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + let zero = <$weight as WeightElement>::Sum::zero(); + if vertex_weights + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("weights must be non-negative".to_string()); + } + if edge_lengths + .iter() + .any(|weight| weight.to_sum() < zero.clone()) + { + return Err("edge_weights must be non-negative".to_string()); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } + } + }; +} + +min_max_multicenter_create_spec!(MinMaxMulticenterI32CreateSpec, i32, 1); +min_max_multicenter_create_spec!(MinMaxMulticenterOneCreateSpec, One, One); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinMaxMulticenter { /// Create a MinMaxMulticenter problem. /// @@ -272,8 +358,8 @@ where } crate::declare_variants! { - default MinMaxMulticenter => "1.4969^num_vertices", - MinMaxMulticenter => "1.4969^num_vertices", + default MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterI32CreateSpec, + MinMaxMulticenter => "1.4969^num_vertices" create MinMaxMulticenterOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 04762cf3d..2b008f232 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -8,7 +8,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -22,15 +22,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight spanning tree with subtree capacity constraints", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "root", type_name: "usize", description: "Root vertex" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Vertex requirements r: V -> R (root has 0)" }, - FieldInfo { name: "capacity", type_name: "W::Sum", description: "Subtree capacity bound" }, - ], + fields: MinimumCapacitatedSpanningTreeCreateSpec::FIELDS, } } @@ -67,6 +62,55 @@ pub struct MinimumCapacitatedSpanningTree { capacity: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCapacitatedSpanningTreeCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + weights: Option>, + /// Root vertex. + root: usize, + /// Vertex requirements. + requirements: Vec, + /// Subtree capacity bound. + capacity: i64, +} +impl TryFrom + for MinimumCapacitatedSpanningTree +{ + type Error = String; + fn try_from(spec: MinimumCapacitatedSpanningTreeCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + let weights = spec.weights.unwrap_or_else(|| vec![1; edges]); + if weights.len() != edges { + return Err(format!( + "weights has {} entries, expected {edges}", + weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if vertices < 2 { + return Err("graph must have at least two vertices".to_string()); + } + if spec.requirements.len() != vertices { + return Err(format!( + "requirements has {} entries, expected {vertices}", + spec.requirements.len() + )); + } + if spec.root >= vertices { + return Err("root is outside the graph".to_string()); + } + Ok(Self::new( + spec.graph, + weights, + spec.root, + spec.requirements, + spec.capacity, + )) + } +} + impl MinimumCapacitatedSpanningTree { /// Create a MinimumCapacitatedSpanningTree problem. /// @@ -323,7 +367,7 @@ where } crate::declare_variants! { - default MinimumCapacitatedSpanningTree => "2^num_edges", + default MinimumCapacitatedSpanningTree => "2^num_edges" create MinimumCapacitatedSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_cost_circulation.rs b/src/models/graph/minimum_cost_circulation.rs index 9a405aab3..f9471cb35 100644 --- a/src/models/graph/minimum_cost_circulation.rs +++ b/src/models/graph/minimum_cost_circulation.rs @@ -43,6 +43,7 @@ inventory::submit! { display_name: "Minimum-Cost Circulation", aliases: &["MCC"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral circulation on a directed multigraph minimizing total signed arc cost", fields: &[ diff --git a/src/models/graph/minimum_cost_maximum_flow.rs b/src/models/graph/minimum_cost_maximum_flow.rs index 8065983f0..852a310eb 100644 --- a/src/models/graph/minimum_cost_maximum_flow.rs +++ b/src/models/graph/minimum_cost_maximum_flow.rs @@ -51,6 +51,7 @@ inventory::submit! { display_name: "Minimum-Cost Maximum-Flow", aliases: &["MCMF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow that lexicographically maximizes value then minimizes total arc cost", fields: &[ diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 55080af3c..db4e9dab7 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum number of cliques covering all edges", fields: &[ @@ -153,8 +154,14 @@ where } } +crate::impl_random_generate!( + MinimumCoveringByCliques, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumCoveringByCliques::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumCoveringByCliques => "2^num_edges", + default MinimumCoveringByCliques => "2^num_edges" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 6ebaa7af6..f13bf6991 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -4,7 +4,7 @@ //! bounded-size sets (containing designated source and sink vertices) that //! minimizes total cut weight. From Garey & Johnson, A2 ND17. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,15 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a minimum-weight cut partitioning vertices into two bounded-size sets", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G = (V, E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> Z+" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s (must be in V1)" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t (must be in V2)" }, - FieldInfo { name: "size_bound", type_name: "usize", description: "Maximum size B for each partition set" }, - ], + fields: MinimumCutIntoBoundedSetsCreateSpec::FIELDS, } } @@ -75,6 +70,44 @@ pub struct MinimumCutIntoBoundedSets { size_bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumCutIntoBoundedSetsCreateSpec { + /// The undirected graph. + graph: SimpleGraph, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Maximum size for each partition set. + size_bound: usize, +} +impl TryFrom for MinimumCutIntoBoundedSets { + type Error = String; + fn try_from(spec: MinimumCutIntoBoundedSetsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec.edge_weights.unwrap_or_else(|| vec![1; count]); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices || spec.source == spec.sink { + return Err("source and sink must be distinct valid graph vertices".to_string()); + } + Ok(Self::new( + spec.graph, + edge_weights, + spec.source, + spec.sink, + spec.size_bound, + )) + } +} + impl MinimumCutIntoBoundedSets { /// Create a new MinimumCutIntoBoundedSets problem. /// @@ -227,8 +260,15 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::EndpointRandomSpec, |spec| { + let (source, sink) = spec.endpoints()?; + let graph = spec.graph()?; + let edge_weights = vec![1; graph.num_edges()]; + Ok(MinimumCutIntoBoundedSets::new(graph, edge_weights, source, sink, spec.num_vertices)) +}); + crate::declare_variants! { - default MinimumCutIntoBoundedSets => "2^num_vertices", + default MinimumCutIntoBoundedSets => "2^num_vertices" create MinimumCutIntoBoundedSetsCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index d1c7e63e8..414a58a38 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -4,7 +4,7 @@ //! such that every vertex is either in the set or adjacent to a vertex in the set. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -21,12 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight dominating set in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumDominatingSetCreateSpec::::FIELDS, } } @@ -62,6 +60,30 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDominatingSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Vec, +} + +impl TryFrom> + for MinimumDominatingSet +{ + type Error = String; + fn try_from(spec: MinimumDominatingSetCreateSpec) -> Result { + if spec.weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + spec.weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.weights)) + } +} + impl MinimumDominatingSet { /// Create a Dominating Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -164,9 +186,16 @@ where } } +crate::impl_random_generate!(MinimumDominatingSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumDominatingSet::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MinimumDominatingSet, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumDominatingSet::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - default MinimumDominatingSet => "1.4969^num_vertices", - MinimumDominatingSet => "1.4969^num_vertices", + default MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, + MinimumDominatingSet => "1.4969^num_vertices" create MinimumDominatingSetCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet @@ -218,6 +247,7 @@ crate::register_decision_variant!( "1.4969^num_vertices", &[], "Decision version: does a dominating set of cost <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), @@ -245,6 +275,15 @@ inventory::submit! { }, is_default: false, aliases: &[], + create_inputs: None, + construct_fn: |data| { + let problem_type = > as Problem>::problem_type(); + crate::registry::validate_direct_create_inputs(problem_type.fields, &data)?; + serde_json::from_value::>>(data) + .map(|problem| Box::new(problem) as Box) + .map_err(|error| crate::registry::ConstructionError::InvalidInput(error.to_string())) + }, + random: None, factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) @@ -280,7 +319,14 @@ inventory::submit! { target_name: "MinimumDominatingSet", source_variant_fn: > as Problem>::variant, target_variant_fn: as Problem>::variant, - overhead_fn: || crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + size_declarations_fn: || crate::rules::registry::ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![ + ("num_vertices", crate::expr::Expr::variable("num_vertices")), + ("num_edges", crate::expr::Expr::variable("num_edges")), + ], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: Some(|any| { let source = any @@ -302,24 +348,24 @@ inventory::submit! { >>::reduce_to_aggregate(source), ) }), - capabilities: crate::rules::EdgeCapabilities::both(), - overhead_eval_fn: |any| { + turing: false, + source_size_measure_fn: |any| { let source = any .downcast_ref::>>() - .expect("DecisionMinimumDominatingSet overhead source type mismatch"); + .expect("DecisionMinimumDominatingSet size source type mismatch"); crate::types::ProblemSize::new(vec![ ("num_vertices", source.num_vertices()), ("num_edges", source.num_edges()), + ("k", source.k()), ]) }, - source_size_fn: |any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumDominatingSet size source type mismatch"); + target_size_measure_fn: |any| { + let target = any + .downcast_ref::>() + .expect("DecisionMinimumDominatingSet size target type mismatch"); crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), - ("k", source.k()), + ("num_vertices", target.num_vertices()), + ("num_edges", target.num_edges()), ]) }, } @@ -332,27 +378,34 @@ inventory::submit! { target_name: "DecisionMinimumDominatingSet", source_variant_fn: as Problem>::variant, target_variant_fn: > as Problem>::variant, - overhead_fn: || crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + size_declarations_fn: || crate::rules::registry::ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![ + ("num_vertices", crate::expr::Expr::variable("num_vertices")), + ("num_edges", crate::expr::Expr::variable("num_edges")), + ], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: crate::rules::EdgeCapabilities::turing(), - overhead_eval_fn: |any| { + turing: true, + source_size_measure_fn: |any| { let source = any .downcast_ref::>() - .expect("DecisionMinimumDominatingSet turing overhead source type mismatch"); + .expect("DecisionMinimumDominatingSet turing size source type mismatch"); crate::types::ProblemSize::new(vec![ ("num_vertices", source.num_vertices()), ("num_edges", source.num_edges()), ]) }, - source_size_fn: |any| { - let source = any - .downcast_ref::>() - .expect("DecisionMinimumDominatingSet turing size source type mismatch"); + target_size_measure_fn: |any| { + let target = any + .downcast_ref::>>() + .expect("DecisionMinimumDominatingSet turing size target type mismatch"); crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), + ("num_vertices", target.num_vertices()), + ("num_edges", target.num_edges()), ]) }, } diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs index c4a8dbdb0..10dc3a5e3 100644 --- a/src/models/graph/minimum_dummy_activities_pert.rs +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -7,7 +7,7 @@ //! resulting event network is acyclic and preserves exactly the same //! task-to-task reachability relation as the original DAG. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -20,15 +20,10 @@ inventory::submit! { display_name: "Minimum Dummy Activities in PERT Networks", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a PERT event network for a precedence DAG minimizing dummy activities", - fields: &[ - FieldInfo { - name: "graph", - type_name: "DirectedGraph", - description: "The precedence DAG G=(V,A) whose vertices are tasks and arcs encode direct precedence constraints", - }, - ], + fields: MinimumDummyActivitiesPertCreateSpec::FIELDS, } } @@ -46,6 +41,33 @@ pub struct MinimumDummyActivitiesPert { graph: DirectedGraph, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDummyActivitiesPertCreateSpec { + /// Directed precedence arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated tasks. + num_vertices: Option, +} +impl TryFrom for MinimumDummyActivitiesPert { + type Error = String; + fn try_from(spec: MinimumDummyActivitiesPertCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for the provided arcs".into()); + } + Self::try_new(DirectedGraph::new(num_vertices, spec.arcs)) + } +} + impl MinimumDummyActivitiesPert { /// Fallible constructor used by CLI validation and deserialization. pub fn try_new(graph: DirectedGraph) -> Result { @@ -201,7 +223,7 @@ impl Problem for MinimumDummyActivitiesPert { } crate::declare_variants! { - default MinimumDummyActivitiesPert => "2^num_arcs", + default MinimumDummyActivitiesPert => "2^num_arcs" create MinimumDummyActivitiesPertCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_edge_cost_flow.rs b/src/models/graph/minimum_edge_cost_flow.rs index 86edf9980..fd0edc752 100644 --- a/src/models/graph/minimum_edge_cost_flow.rs +++ b/src/models/graph/minimum_edge_cost_flow.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Minimum Edge-Cost Flow", aliases: &["MECF"], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow minimizing the number of arcs with nonzero flow (weighted by price)", fields: &[ diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index a69fa1aa6..cefc8dcf1 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -3,7 +3,7 @@ //! The Feedback Arc Set problem asks for a minimum-weight subset of arcs //! whose removal makes a directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight feedback arc set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w: A -> R" }, - ], + fields: MinimumFeedbackArcSetCreateSpec::FIELDS, } } @@ -65,6 +63,28 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackArcSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Arc weights; defaults to one per arc. + weights: Option>, +} +impl TryFrom for MinimumFeedbackArcSet { + type Error = String; + fn try_from(spec: MinimumFeedbackArcSetCreateSpec) -> Result { + let count = spec.graph.num_arcs(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackArcSet { /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -165,7 +185,7 @@ fn is_valid_fas(graph: &DirectedGraph, config: &[usize]) -> bool { } crate::declare_variants! { - default MinimumFeedbackArcSet => "2^num_vertices", + default MinimumFeedbackArcSet => "2^num_vertices" create MinimumFeedbackArcSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index fe6b277e4..3b03e69cc 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -3,7 +3,7 @@ //! The Feedback Vertex Set problem asks for a minimum weight subset of vertices //! whose removal makes the directed graph acyclic (a DAG). -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -18,12 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight feedback vertex set in a directed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumFeedbackVertexSetCreateSpec::FIELDS, } } @@ -59,6 +57,28 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumFeedbackVertexSetCreateSpec { + /// The directed graph. + graph: DirectedGraph, + /// Vertex weights; defaults to one per vertex. + weights: Option>, +} +impl TryFrom for MinimumFeedbackVertexSet { + type Error = String; + fn try_from(spec: MinimumFeedbackVertexSetCreateSpec) -> Result { + let count = spec.graph.num_vertices(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err(format!( + "weights has {} entries, expected {count}", + weights.len() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumFeedbackVertexSet { /// Create a Feedback Vertex Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { @@ -153,7 +173,7 @@ where } crate::declare_variants! { - default MinimumFeedbackVertexSet => "1.9977^num_vertices", + default MinimumFeedbackVertexSet => "1.9977^num_vertices" create MinimumFeedbackVertexSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_geometric_connected_dominating_set.rs b/src/models/graph/minimum_geometric_connected_dominating_set.rs index b295af09e..3d79d415f 100644 --- a/src/models/graph/minimum_geometric_connected_dominating_set.rs +++ b/src/models/graph/minimum_geometric_connected_dominating_set.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Minimum Geometric Connected Dominating Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum connected dominating set in a geometric point set", fields: &[ diff --git a/src/models/graph/minimum_graph_bandwidth.rs b/src/models/graph/minimum_graph_bandwidth.rs index aac0cbce3..227682249 100644 --- a/src/models/graph/minimum_graph_bandwidth.rs +++ b/src/models/graph/minimum_graph_bandwidth.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a vertex ordering minimizing the maximum edge stretch", fields: &[ diff --git a/src/models/graph/minimum_intersection_graph_basis.rs b/src/models/graph/minimum_intersection_graph_basis.rs index d78795962..f4485d4a5 100644 --- a/src/models/graph/minimum_intersection_graph_basis.rs +++ b/src/models/graph/minimum_intersection_graph_basis.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum universe size for intersection graph representation", fields: &[ @@ -156,8 +157,14 @@ where } } +crate::impl_random_generate!( + MinimumIntersectionGraphBasis, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumIntersectionGraphBasis::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumIntersectionGraphBasis => "num_edges^num_edges", + default MinimumIntersectionGraphBasis => "num_edges^num_edges" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_maximal_matching.rs b/src/models/graph/minimum_maximal_matching.rs index a31b886c1..6e195a381 100644 --- a/src/models/graph/minimum_maximal_matching.rs +++ b/src/models/graph/minimum_maximal_matching.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph", "BipartiteGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a minimum-size matching that cannot be extended", fields: &[ @@ -145,8 +146,14 @@ where } } +crate::impl_random_generate!( + MinimumMaximalMatching, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(MinimumMaximalMatching::new(spec.graph()?)) } +); + crate::declare_variants! { - default MinimumMaximalMatching => "1.3160^num_vertices", + default MinimumMaximalMatching => "1.3160^num_vertices" random, MinimumMaximalMatching => "1.3160^num_vertices", } diff --git a/src/models/graph/minimum_metric_dimension.rs b/src/models/graph/minimum_metric_dimension.rs index 21299860d..3414349bc 100644 --- a/src/models/graph/minimum_metric_dimension.rs +++ b/src/models/graph/minimum_metric_dimension.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum resolving set of a graph", fields: &[ diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 010a27bb7..8143937b8 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -3,7 +3,7 @@ //! The Minimum Multiway Cut problem asks for a minimum weight set of edges //! whose removal disconnects all terminal pairs. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,13 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight set of edges whose removal disconnects all terminal pairs", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The undirected graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices that must be separated" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R (same order as graph.edges())" }, - ], + fields: MinimumMultiwayCutCreateSpec::FIELDS, } } @@ -52,6 +49,49 @@ pub struct MinimumMultiwayCut { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumMultiwayCutCreateSpec { + /// The undirected graph G=(V,E). + graph: SimpleGraph, + /// Terminal vertices that must be separated. + terminals: Vec, + /// Edge weights w: E -> R in graph edge order. + edge_weights: Vec, +} + +impl TryFrom for MinimumMultiwayCut { + type Error = String; + fn try_from(spec: MinimumMultiwayCutCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.terminals, spec.edge_weights)) + } +} + impl MinimumMultiwayCut { /// Create a MinimumMultiwayCut problem. /// @@ -188,7 +228,7 @@ where } crate::declare_variants! { - default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3", + default MinimumMultiwayCut => "1.84^num_terminals * num_vertices^3" create MinimumMultiwayCutCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index e631f0d7c..fb98566b2 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -3,7 +3,7 @@ //! The p-median problem asks for K facility locations (centers) on a graph //! that minimize the total weighted distance from all vertices to their nearest center. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -19,14 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find K centers minimizing total weighted distance (p-median problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> R" }, - FieldInfo { name: "k", type_name: "usize", description: "Number of centers to place" }, - ], + fields: MinimumSumMulticenterCreateSpec::FIELDS, } } @@ -70,6 +66,88 @@ pub struct MinimumSumMulticenter { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSumMulticenterCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + weights: Option>, + #[create(codec = "comma-separated")] + edge_weights: Option>, + k: usize, +} + +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSumMulticenterRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Number of centers (default: max(1, num_vertices / 3)). + k: Option, +} + +impl TryFrom for MinimumSumMulticenter { + type Error = String; + + fn try_from(spec: MinimumSumMulticenterCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_weights = spec + .weights + .unwrap_or_else(|| vec![1; graph.num_vertices()]); + if vertex_weights.len() != graph.num_vertices() { + return Err(format!( + "weights has length {}, expected {}", + vertex_weights.len(), + graph.num_vertices() + )); + } + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if spec.k == 0 || spec.k > graph.num_vertices() { + return Err(format!("k must be between 1 and {}", graph.num_vertices())); + } + Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!("num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}")); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl MinimumSumMulticenter { /// Create a MinimumSumMulticenter problem. /// @@ -247,8 +325,22 @@ where } } +crate::impl_random_generate!(MinimumSumMulticenter, MinimumSumMulticenterRandomSpec, |spec| { + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + }.graph()?; + let k = spec.k.unwrap_or(std::cmp::max(1, spec.num_vertices / 3)); + if k == 0 || k > spec.num_vertices { + return Err(format!("k must be between 1 and {}", spec.num_vertices)); + } + let lengths = vec![1; graph.num_edges()]; + Ok(MinimumSumMulticenter::new(graph, vec![1; spec.num_vertices], lengths, k)) +}); + crate::declare_variants! { - default MinimumSumMulticenter => "2^num_vertices", + default MinimumSumMulticenter => "2^num_vertices" create MinimumSumMulticenterCreateSpec random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index f33b93134..82c5b2aa0 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -4,7 +4,7 @@ //! such that every edge has at least one endpoint in the subset. use crate::models::decision::Decision; -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -20,12 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight vertex cover in a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - ], + fields: MinimumVertexCoverCreateSpec::::FIELDS, } } @@ -62,6 +60,33 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumVertexCoverCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Vertex weights w: V -> R. + weights: Option>, +} + +impl TryFrom> + for MinimumVertexCover +{ + type Error = String; + fn try_from(spec: MinimumVertexCoverCreateSpec) -> Result { + let weights = spec + .weights + .unwrap_or_else(|| vec![W::default(); spec.graph.num_vertices()]); + if weights.len() != spec.graph.num_vertices() { + return Err(format!( + "weights has {} entries, expected {}", + weights.len(), + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, weights)) + } +} + impl MinimumVertexCover { /// Create a Vertex Covering problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { @@ -151,9 +176,16 @@ pub(crate) fn is_vertex_cover_config(graph: &G, config: &[usize]) -> b true } +crate::impl_random_generate!(MinimumVertexCover, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumVertexCover::new(spec.graph()?, vec![1; spec.num_vertices])) +}); +crate::impl_random_generate!(MinimumVertexCover, crate::random::SimpleGraphRandomSpec, |spec| { + Ok(MinimumVertexCover::new(spec.graph()?, vec![One; spec.num_vertices])) +}); + crate::declare_variants! { - default MinimumVertexCover => "1.1996^num_vertices", - MinimumVertexCover => "1.1996^num_vertices", + default MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, + MinimumVertexCover => "1.1996^num_vertices" create MinimumVertexCoverCreateSpec random, } impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover @@ -182,12 +214,45 @@ impl Decision> { } } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct DecisionMinimumVertexCoverRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Maximum allowed cover cost. + bound: i64, +} + +crate::impl_random_generate!( + Decision>, + DecisionMinimumVertexCoverRandomSpec, + |spec| { + if spec.bound < 0 { + return Err("bound must be nonnegative".to_string()); + } + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + Ok(Decision::new( + MinimumVertexCover::new(graph, vec![1; spec.num_vertices]), + spec.bound, + )) + } +); + crate::register_decision_variant!( MinimumVertexCover, "DecisionMinimumVertexCover", "1.1996^num_vertices", &["DMVC", "VC", "VertexCover"], "Decision version: does a vertex cover of cost <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), @@ -195,9 +260,10 @@ crate::register_decision_variant!( fields: [ FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed cover cost)" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Decision bound (maximum allowed cover cost)" }, ], - size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)] + size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)], + random ); #[cfg(feature = "example-db")] diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index 866e2ecb7..a0d067bf4 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -4,7 +4,7 @@ //! minimum-cost closed walk that traverses every directed arc in its prescribed //! direction and every undirected edge in at least one direction. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{DirectedGraph, MixedGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -22,13 +22,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32", "One"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a minimum-cost closed walk covering all arcs and edges in a mixed graph", - fields: &[ - FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Lengths for the directed arcs in A" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Lengths for the undirected edges in E" }, - ], + fields: MixedChinesePostmanI32CreateSpec::FIELDS, } } @@ -39,13 +36,88 @@ inventory::submit! { /// Postman subproblem, using all available arcs (including both directions of /// every undirected edge) for degree-balancing detours. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MixedChinesePostman> { +pub struct MixedChinesePostman> { graph: MixedGraph, arc_weights: Vec, edge_weights: Vec, } -impl> MixedChinesePostman { +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. /// /// # Panics @@ -53,42 +125,44 @@ impl> MixedChinesePostman { /// Panics if the weight-vector lengths do not match the graph shape or if /// any weight is negative. pub fn new(graph: MixedGraph, arc_weights: Vec, edge_weights: Vec) -> Self { - assert_eq!( - arc_weights.len(), - graph.num_arcs(), - "arc_weights length must match num_arcs" - ); - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); + Self::try_new(graph, arc_weights, edge_weights) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + ) -> Result { + if arc_weights.len() != graph.num_arcs() { + return Err("arc_weights length must match num_arcs".to_string()); + } + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".to_string()); + } for (index, weight) in arc_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "arc weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("arc weight at index {index} must be nonnegative")); + } } for (index, weight) in edge_weights.iter().enumerate() { - assert!( - matches!( - weight.to_sum().partial_cmp(&W::Sum::zero()), - Some(Ordering::Equal | Ordering::Greater) - ), - "edge weight at index {} must be nonnegative", - index - ); + if !matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ) { + return Err(format!("edge weight at index {index} must be nonnegative")); + } } - Self { + Ok(Self { graph, arc_weights, edge_weights, - } + }) } /// Return the mixed graph. @@ -157,11 +231,11 @@ impl> MixedChinesePostman { .arcs() .into_iter() .zip(self.arc_weights.iter()) - .map(|((u, v), weight)| (u, v, i64::from(weight.to_sum()))) + .map(|((u, v), weight)| (u, v, weight.to_sum())) .collect(); for ((u, v), weight) in self.graph.edges().iter().zip(self.edge_weights.iter()) { - let cost = i64::from(weight.to_sum()); + let cost = weight.to_sum(); arcs.push((*u, *v, cost)); arcs.push((*v, *u, cost)); } @@ -172,19 +246,19 @@ impl> MixedChinesePostman { fn base_cost(&self) -> i64 { self.arc_weights .iter() - .map(|weight| i64::from(weight.to_sum())) + .map(WeightElement::to_sum) .sum::() + self .edge_weights .iter() - .map(|weight| i64::from(weight.to_sum())) + .map(WeightElement::to_sum) .sum::() } } impl MixedChinesePostman where - W: WeightElement + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, { /// Check whether a configuration yields a valid orientation (strongly /// connected with proper coverage). @@ -195,7 +269,7 @@ where impl Problem for MixedChinesePostman where - W: WeightElement + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, { const NAME: &'static str = "MixedChinesePostman"; type Value = Min; @@ -233,13 +307,13 @@ where }; let total = self.base_cost() + extra_cost; - Min(Some(total as W::Sum)) + Min(Some(total)) } } crate::declare_variants! { - default MixedChinesePostman => "2^num_edges * num_vertices^3", - MixedChinesePostman => "2^num_edges * num_vertices^3", + default MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanI32CreateSpec, + MixedChinesePostman => "2^num_edges * num_vertices^3" create MixedChinesePostmanOneCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/monochromatic_triangle.rs b/src/models/graph/monochromatic_triangle.rs index 17366640b..ae7746a53 100644 --- a/src/models/graph/monochromatic_triangle.rs +++ b/src/models/graph/monochromatic_triangle.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "2-color edges so that no triangle is monochromatic", fields: &[ diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index c0e90cdba..e334347ce 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -4,7 +4,7 @@ //! threshold, determine whether there exists a high-weight branching that //! picks at most one arc from each partition group. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::WeightElement; @@ -20,14 +20,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a branching with partition constraints and weight at least K", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Arc weights w(a) for each arc a in A" }, - FieldInfo { name: "partition", type_name: "Vec>", description: "Partition of arc indices; each arc index must appear in exactly one group" }, - FieldInfo { name: "threshold", type_name: "W::Sum", description: "Weight threshold K" }, - ], + fields: MultipleChoiceBranchingCreateSpec::FIELDS, } } @@ -48,6 +44,56 @@ pub struct MultipleChoiceBranching { threshold: W::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleChoiceBranchingCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc weights w(a) for each arc a in A. + weights: Vec, + /// Partition of arc indices; each arc must appear exactly once. + partition: Vec>, + /// Weight threshold K. + threshold: i64, +} + +impl TryFrom for MultipleChoiceBranching { + type Error = String; + fn try_from(spec: MultipleChoiceBranchingCreateSpec) -> Result { + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err("num_vertices is too small for arc endpoints".to_string()); + } + let graph = DirectedGraph::new(num_vertices, spec.arcs); + let num_arcs = graph.num_arcs(); + if spec.weights.len() != num_arcs { + return Err(format!( + "weights has {} entries, expected {num_arcs}", + spec.weights.len() + )); + } + if let Some(message) = partition_validation_error(&spec.partition, num_arcs) { + return Err(message); + } + Ok(Self::new( + graph, + spec.weights, + spec.partition, + spec.threshold, + )) + } +} + #[derive(Debug, Deserialize)] struct MultipleChoiceBranchingUnchecked { graph: DirectedGraph, @@ -294,7 +340,7 @@ fn is_valid_multiple_choice_branching( } crate::declare_variants! { - default MultipleChoiceBranching => "2^num_arcs", + default MultipleChoiceBranching => "2^num_arcs" create MultipleChoiceBranchingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index c54269d30..433f7d144 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -3,7 +3,7 @@ //! The Multiple Copy File Allocation problem asks for a placement of file copies //! on graph vertices that minimizes the combined storage and access cost. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::Min; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Multiple Copy File Allocation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Place file copies on graph vertices to minimize total storage plus access cost", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The network graph G=(V,E)" }, - FieldInfo { name: "usage", type_name: "Vec", description: "Usage frequencies u(v) for each vertex" }, - FieldInfo { name: "storage", type_name: "Vec", description: "Storage costs s(v) for placing a copy at each vertex" }, - ], + fields: MultipleCopyFileAllocationCreateSpec::FIELDS, } } @@ -49,6 +46,58 @@ pub struct MultipleCopyFileAllocation { storage: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultipleCopyFileAllocationCreateSpec { + /// Network graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Usage frequency per vertex. + #[create(codec = "comma-separated")] + usage: Vec, + /// Storage cost per vertex. + #[create(codec = "comma-separated")] + storage: Vec, +} + +impl TryFrom for MultipleCopyFileAllocation { + type Error = String; + fn try_from(spec: MultipleCopyFileAllocationCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.usage.len() != count { + return Err("usage length must match num_vertices".into()); + } + if spec.storage.len() != count { + return Err("storage length must match num_vertices".into()); + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + usage: spec.usage, + storage: spec.storage, + }) + } +} + impl MultipleCopyFileAllocation { /// Create a new Multiple Copy File Allocation instance. pub fn new(graph: SimpleGraph, usage: Vec, storage: Vec) -> Self { @@ -201,7 +250,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "2^num_vertices", + default MultipleCopyFileAllocation => "2^num_vertices" create MultipleCopyFileAllocationCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/optimal_linear_arrangement.rs b/src/models/graph/optimal_linear_arrangement.rs index d2f14f2f7..ac89218c5 100644 --- a/src/models/graph/optimal_linear_arrangement.rs +++ b/src/models/graph/optimal_linear_arrangement.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a vertex ordering on a line minimizing total edge length", fields: &[ @@ -153,8 +154,14 @@ where } } +crate::impl_random_generate!( + OptimalLinearArrangement, + crate::random::SimpleGraphRandomSpec, + |spec| { Ok(OptimalLinearArrangement::new(spec.graph()?)) } +); + crate::declare_variants! { - default OptimalLinearArrangement => "2^num_vertices", + default OptimalLinearArrangement => "2^num_vertices" random, } impl crate::models::decision::DecisionProblemMeta for OptimalLinearArrangement @@ -187,6 +194,7 @@ crate::register_decision_variant!( "2^num_vertices", &["DOLA"], "Decision version: does a linear arrangement of total edge length <= bound exist?", + category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], diff --git a/src/models/graph/partial_feedback_edge_set.rs b/src/models/graph/partial_feedback_edge_set.rs index 5806cd534..f84035803 100644 --- a/src/models/graph/partial_feedback_edge_set.rs +++ b/src/models/graph/partial_feedback_edge_set.rs @@ -3,7 +3,7 @@ //! The Partial Feedback Edge Set problem asks whether removing at most `K` //! edges can hit every cycle of length at most `L`. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -18,13 +18,10 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Remove at most K edges so that every cycle of length at most L is hit", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number K of edges that may be removed" }, - FieldInfo { name: "max_cycle_length", type_name: "usize", description: "Cycle length bound L; every cycle with length at most L must be hit" }, - ], + fields: PartialFeedbackEdgeSetCreateSpec::FIELDS, } } @@ -46,6 +43,23 @@ pub struct PartialFeedbackEdgeSet { max_cycle_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartialFeedbackEdgeSetCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Maximum number K of edges that may be removed. + budget: usize, + /// Cycle length bound L. + max_cycle_length: usize, +} + +impl TryFrom for PartialFeedbackEdgeSet { + type Error = String; + fn try_from(spec: PartialFeedbackEdgeSetCreateSpec) -> Result { + Ok(Self::new(spec.graph, spec.budget, spec.max_cycle_length)) + } +} + impl PartialFeedbackEdgeSet { /// Create a new Partial Feedback Edge Set instance. pub fn new(graph: G, budget: usize, max_cycle_length: usize) -> Self { @@ -242,7 +256,7 @@ fn normalize_edge(u: usize, v: usize) -> (usize, usize) { } crate::declare_variants! { - default PartialFeedbackEdgeSet => "2^num_edges", + default PartialFeedbackEdgeSet => "2^num_edges" create PartialFeedbackEdgeSetCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 4189399be..869872aaf 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K groups each inducing a clique", fields: &[ diff --git a/src/models/graph/partition_into_forests.rs b/src/models/graph/partition_into_forests.rs index 4c82a565b..98f783358 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K classes each inducing an acyclic subgraph", fields: &[ diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index 717bcab97..e97d43046 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into triples each inducing at least two edges (P3 or triangle)", fields: &[ diff --git a/src/models/graph/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index 89fcef0bc..c6944c489 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -19,6 +19,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into K groups each inducing a perfect matching", fields: &[ diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index b14d5efe5..02148b0c5 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -17,6 +17,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Partition vertices into triangles (K3 subgraphs)", fields: &[ diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index 373598e22..8bc785b16 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -6,7 +6,7 @@ //! capacities are respected and the total delivered flow reaches the required //! threshold. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -18,16 +18,10 @@ inventory::submit! { display_name: "Path-Constrained Network Flow", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Integral flow feasibility on a prescribed collection of directed s-t paths", - fields: &[ - FieldInfo { name: "graph", type_name: "DirectedGraph", description: "Directed graph G = (V, A)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Capacity c(a) for each arc" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "paths", type_name: "Vec>", description: "Prescribed directed s-t paths as arc-index sequences" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required total flow R" }, - ], + fields: PathConstrainedNetworkFlowCreateSpec::FIELDS, } } @@ -49,6 +43,64 @@ pub struct PathConstrainedNetworkFlow { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PathConstrainedNetworkFlowCreateSpec { + /// Directed graph arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Arc capacities; defaults to one per arc. + #[create(codec = "comma-separated")] + capacities: Option>, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Prescribed paths as arc-index sequences. + #[create(codec = "semicolon-separated")] + paths: Vec>, + /// Required total flow. + requirement: u64, +} + +impl TryFrom for PathConstrainedNetworkFlow { + type Error = String; + + fn try_from(spec: PathConstrainedNetworkFlowCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.paths.is_empty() { + return Err("paths must be non-empty".to_string()); + } + let inferred = spec + .arcs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for arc endpoints; need at least {inferred}" + )); + } + let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let graph = DirectedGraph::new(num_vertices, spec.arcs); + Self::try_new( + graph, + capacities, + spec.source, + spec.sink, + spec.paths, + spec.requirement, + ) + } +} + impl PathConstrainedNetworkFlow { /// Create a new Path-Constrained Network Flow instance. /// @@ -66,38 +118,59 @@ impl PathConstrainedNetworkFlow { paths: Vec>, requirement: u64, ) -> Self { + Self::try_new(graph, capacities, source, sink, paths, requirement) + .unwrap_or_else(|message| panic!("{message}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: u64, + ) -> Result { let num_vertices = graph.num_vertices(); - assert_eq!( - capacities.len(), - graph.num_arcs(), - "capacities length must match graph num_arcs" - ); - assert!( - source < num_vertices, - "source ({source}) >= num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) >= num_vertices ({num_vertices})" - ); - assert_ne!(source, sink, "source and sink must be distinct"); - - for path in &paths { - Self::assert_valid_path(&graph, path, source, sink); + if capacities.len() != graph.num_arcs() { + return Err("capacities length must match graph num_arcs".to_string()); + } + if source >= num_vertices { + return Err(format!( + "source ({source}) >= num_vertices ({num_vertices})" + )); + } + if sink >= num_vertices { + return Err(format!("sink ({sink}) >= num_vertices ({num_vertices})")); + } + if source == sink { + return Err("source and sink must be distinct".to_string()); + } + + for (index, path) in paths.iter().enumerate() { + Self::validate_path(&graph, path, source, sink) + .map_err(|message| format!("path {index}: {message}"))?; } - Self { + Ok(Self { graph, capacities, source, sink, paths, requirement, - } + }) } - fn assert_valid_path(graph: &DirectedGraph, path: &[usize], source: usize, sink: usize) { - assert!(!path.is_empty(), "prescribed paths must be non-empty"); + fn validate_path( + graph: &DirectedGraph, + path: &[usize], + source: usize, + sink: usize, + ) -> Result<(), String> { + if path.is_empty() { + return Err("prescribed paths must be non-empty".to_string()); + } let arcs = graph.arcs(); let mut visited_vertices = HashSet::from([source]); @@ -106,22 +179,21 @@ impl PathConstrainedNetworkFlow { for &arc_idx in path { let &(tail, head) = arcs .get(arc_idx) - .unwrap_or_else(|| panic!("path arc index {arc_idx} out of bounds")); - assert_eq!( - tail, current, - "prescribed path is not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" - ); - assert!( - visited_vertices.insert(head), - "prescribed path repeats vertex {head}, so it is not a simple path" - ); + .ok_or_else(|| format!("arc index {arc_idx} out of bounds"))?; + if tail != current { + return Err(format!( + "not contiguous: expected arc leaving vertex {current}, got {tail}->{head}" + )); + } + if !visited_vertices.insert(head) { + return Err(format!("repeats vertex {head}, so it is not a simple path")); + } current = head; } - - assert_eq!( - current, sink, - "prescribed path must end at sink {sink}, ended at {current}" - ); + if current != sink { + return Err(format!("must end at sink {sink}, ended at {current}")); + } + Ok(()) } fn path_bottleneck(&self, path: &[usize]) -> u64 { @@ -235,7 +307,7 @@ impl Problem for PathConstrainedNetworkFlow { } crate::declare_variants! { - default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths", + default PathConstrainedNetworkFlow => "(max_capacity + 1)^num_paths" create PathConstrainedNetworkFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index d44281dd0..412b1f051 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -24,7 +24,7 @@ //! - Earlier conference version, RECOMB 2012, LNBI 7262, pp. 287--301. //! -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -42,15 +42,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "f64"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a forest minimizing omitted-prize plus edge-cost plus omega times the number of tree components", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying network G=(V,E)" }, - FieldInfo { name: "vertex_prizes", type_name: "Vec", description: "Nonnegative vertex prizes p: V -> R_{>=0}" }, - FieldInfo { name: "edge_costs", type_name: "Vec", description: "Nonnegative edge costs c: E -> R_{>=0} in graph.edges() order" }, - FieldInfo { name: "beta", type_name: "W", description: "Tradeoff coefficient beta >= 0 on the omitted-prize term" }, - FieldInfo { name: "omega", type_name: "W", description: "Per-component penalty omega >= 0 on the number of tree components" }, - ], + fields: PrizeCollectingSteinerForestI32CreateSpec::FIELDS, } } @@ -110,6 +105,89 @@ pub struct PrizeCollectingSteinerForest { omega: W, } +macro_rules! prize_collecting_steiner_forest_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + vertex_prizes: Option>, + #[create(codec = "comma-separated")] + edge_costs: Option>, + beta: $weight, + omega: $weight, + } + + impl TryFrom<$name> for PrizeCollectingSteinerForest { + type Error = String; + + fn try_from(spec: $name) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let vertex_prizes = spec + .vertex_prizes + .unwrap_or_else(|| vec![$one; graph.num_vertices()]); + if vertex_prizes.len() != graph.num_vertices() { + return Err(format!( + "vertex_prizes has length {}, expected {}", + vertex_prizes.len(), + graph.num_vertices() + )); + } + let edge_costs = spec + .edge_costs + .unwrap_or_else(|| vec![$one; graph.num_edges()]); + if edge_costs.len() != graph.num_edges() { + return Err(format!( + "edge_costs has length {}, expected {}", + edge_costs.len(), + graph.num_edges() + )); + } + Ok(Self::new( + graph, + vertex_prizes, + edge_costs, + spec.beta, + spec.omega, + )) + } + } + }; +} + +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestI32CreateSpec, i32, 1); +prize_collecting_steiner_forest_create_spec!(PrizeCollectingSteinerForestF64CreateSpec, f64, 1.0); + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl PrizeCollectingSteinerForest { /// Create a new Prize-Collecting Steiner Forest instance. /// @@ -306,8 +384,8 @@ fn forest_components(graph: &G, config: &[usize]) -> Option { } crate::declare_variants! { - default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", - PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)", + default PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestI32CreateSpec, + PrizeCollectingSteinerForest => "2^(num_vertices + num_edges)" create PrizeCollectingSteinerForestF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/rooted_tree_arrangement.rs b/src/models/graph/rooted_tree_arrangement.rs index 6fc20b366..59be6969f 100644 --- a/src/models/graph/rooted_tree_arrangement.rs +++ b/src/models/graph/rooted_tree_arrangement.rs @@ -18,6 +18,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a rooted-tree embedding of a graph with bounded total edge stretch", fields: &[ @@ -34,6 +35,18 @@ pub struct RootedTreeArrangement { bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RootedTreeArrangementRandomSpec { + /// Number of graph vertices. + num_vertices: usize, + /// Independent edge probability (default: 0.5). + edge_prob: Option, + /// Seed for reproducible generation. + seed: Option, + /// Maximum total edge stretch (defaults to a graph-size upper bound). + bound: Option, +} + #[derive(Debug, Clone)] struct TreeInfo { depth: Vec, @@ -204,8 +217,25 @@ fn are_ancestor_comparable(parent: &[usize], u: usize, v: usize) -> bool { is_ancestor(parent, u, v) || is_ancestor(parent, v, u) } +crate::impl_random_generate!( + RootedTreeArrangement, + RootedTreeArrangementRandomSpec, + |spec| { + let graph = crate::random::SimpleGraphRandomSpec { + num_vertices: spec.num_vertices, + edge_prob: spec.edge_prob, + seed: spec.seed, + } + .graph()?; + let bound = spec + .bound + .unwrap_or_else(|| spec.num_vertices.saturating_sub(1) * graph.num_edges()); + Ok(RootedTreeArrangement::new(graph, bound)) + } +); + crate::declare_variants! { - default RootedTreeArrangement => "2^num_vertices", + default RootedTreeArrangement => "2^num_vertices" random, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 164eccdc0..8743ffe05 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -3,7 +3,7 @@ //! The Rural Postman problem asks for a minimum-cost circuit in a graph //! that includes each edge in a required subset E'. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -20,13 +20,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a minimum-cost circuit covering all required edges (Rural Postman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge lengths l(e) for each e in E" }, - FieldInfo { name: "required_edges", type_name: "Vec", description: "Edge indices of the required subset E' ⊆ E" }, - ], + fields: RuralPostmanCreateSpec::FIELDS, } } @@ -65,6 +62,71 @@ pub struct RuralPostman { required_edges: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct RuralPostmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, + #[create(codec = "comma-separated")] + required_edges: Vec, +} + +impl TryFrom for RuralPostman { + type Error = String; + + fn try_from(spec: RuralPostmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_lengths = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_lengths.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_lengths.len(), + graph.num_edges() + )); + } + if let Some(&edge) = spec + .required_edges + .iter() + .find(|&&edge| edge >= graph.num_edges()) + { + return Err(format!("required edge index {edge} is out of bounds")); + } + Ok(Self::new(graph, edge_lengths, spec.required_edges)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl RuralPostman { /// Create a new RuralPostman problem. /// @@ -266,7 +328,7 @@ where } crate::declare_variants! { - default RuralPostman => "2^num_vertices * num_vertices^2", + default RuralPostman => "2^num_vertices * num_vertices^2" create RuralPostmanCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index bfc1272b8..9518f1e25 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -4,7 +4,7 @@ //! source vertex to a target vertex that minimizes total length while keeping //! the total weight within a prescribed bound. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -21,16 +21,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find a simple s-t path minimizing total length subject to a weight budget", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Edge lengths l: E -> ZZ_(> 0)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> ZZ_(> 0)" }, - FieldInfo { name: "source_vertex", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "target_vertex", type_name: "usize", description: "Target vertex t" }, - FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Upper bound W on total path weight" }, - ], + fields: ShortestWeightConstrainedPathCreateSpec::FIELDS, } } @@ -74,6 +68,73 @@ pub struct ShortestWeightConstrainedPath { weight_bound: N::Sum, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestWeightConstrainedPathCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Positive edge lengths in graph edge order. + edge_lengths: Vec, + /// Positive edge weights in graph edge order. + edge_weights: Vec, + /// Source vertex s. + source_vertex: usize, + /// Target vertex t. + target_vertex: usize, + /// Positive upper bound on total path weight. + weight_bound: i64, +} + +impl TryFrom + for ShortestWeightConstrainedPath +{ + type Error = String; + fn try_from(spec: ShortestWeightConstrainedPathCreateSpec) -> Result { + let edge_count = spec.graph.num_edges(); + if spec.edge_lengths.len() != edge_count { + return Err(format!( + "edge_lengths has {} entries, expected {edge_count}", + spec.edge_lengths.len() + )); + } + if spec.edge_weights.len() != edge_count { + return Err(format!( + "edge_weights has {} entries, expected {edge_count}", + spec.edge_weights.len() + )); + } + if spec.edge_lengths.iter().any(|&value| value <= 0) { + return Err("edge_lengths must be positive".to_string()); + } + if spec.edge_weights.iter().any(|&value| value <= 0) { + return Err("edge_weights must be positive".to_string()); + } + let vertex_count = spec.graph.num_vertices(); + if spec.source_vertex >= vertex_count { + return Err(format!( + "source_vertex {} is outside graph with {vertex_count} vertices", + spec.source_vertex + )); + } + if spec.target_vertex >= vertex_count { + return Err(format!( + "target_vertex {} is outside graph with {vertex_count} vertices", + spec.target_vertex + )); + } + if spec.weight_bound <= 0 { + return Err("weight_bound must be positive".to_string()); + } + Ok(Self::new( + spec.graph, + spec.edge_lengths, + spec.edge_weights, + spec.source_vertex, + spec.target_vertex, + spec.weight_bound, + )) + } +} + impl ShortestWeightConstrainedPath { fn assert_positive_edge_values(values: &[N], label: &str) { let zero = N::Sum::zero(); @@ -349,7 +410,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec => "2^num_edges", + default ShortestWeightConstrainedPath => "2^num_edges" create ShortestWeightConstrainedPathCreateSpec, } #[cfg(test)] diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index 0e86144a5..9349e830a 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -2,7 +2,7 @@ //! //! The Spin Glass problem minimizes the Ising Hamiltonian energy. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -17,13 +17,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32", "f64"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Minimize Ising Hamiltonian on a graph", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The interaction graph" }, - FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings J_ij" }, - FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields h_i" }, - ], + fields: SpinGlassI32CreateSpec::FIELDS, } } @@ -73,6 +70,72 @@ pub struct SpinGlass { fields: Vec, } +macro_rules! spin_glass_create_spec { + ($name:ident, $weight:ty, $one:expr, $zero:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Undirected interaction graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated spins. + num_vertices: Option, + /// Pairwise couplings; defaults to one per edge. + #[create(codec = "comma-separated")] + couplings: Option>, + /// On-site fields; defaults to zero per vertex. + #[create(codec = "comma-separated")] + fields: Option>, + } + + impl TryFrom<$name> for SpinGlass { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.graph.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = spec.num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + let couplings = spec + .couplings + .unwrap_or_else(|| vec![$one; spec.graph.len()]); + if couplings.len() != spec.graph.len() { + return Err("couplings length must match graph edge count".to_string()); + } + let fields = spec.fields.unwrap_or_else(|| vec![$zero; num_vertices]); + if fields.len() != num_vertices { + return Err("fields length must match num_vertices".to_string()); + } + Ok(SpinGlass { + graph: SimpleGraph::new(num_vertices, spec.graph), + couplings, + fields, + }) + } + } + }; +} + +spin_glass_create_spec!(SpinGlassI32CreateSpec, i32, 1_i32, 0_i32); +spin_glass_create_spec!(SpinGlassF64CreateSpec, f64, 1.0_f64, 0.0_f64); + impl SpinGlass { /// Create a new Spin Glass problem. /// @@ -236,9 +299,15 @@ where } } +crate::impl_random_generate!(SpinGlass, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let num_edges = graph.num_edges(); + Ok(SpinGlass::from_graph(graph, vec![1; num_edges], vec![0; spec.num_vertices])) +}); + crate::declare_variants! { - default SpinGlass => "2^num_spins", - SpinGlass => "2^num_spins", + default SpinGlass => "2^num_spins" create SpinGlassI32CreateSpec random, + SpinGlass => "2^num_spins" create SpinGlassF64CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index b58f8c331..5a805e042 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -9,7 +9,7 @@ use num_traits::Zero; use serde::{Deserialize, Serialize}; use crate::{ - registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}, + registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}, topology::{Graph, SimpleGraph}, traits::Problem, types::{Min, One, WeightElement}, @@ -24,13 +24,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight tree connecting terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Terminal vertices T that must be connected" }, - ], + fields: SteinerTreeCreateSpec::::FIELDS, } } @@ -64,6 +61,49 @@ pub struct SteinerTree { terminals: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeCreateSpec { + /// The underlying graph G=(V,E). + graph: SimpleGraph, + /// Edge weights w: E -> R. + edge_weights: Vec, + /// Terminal vertices T that must be connected. + terminals: Vec, +} + +impl TryFrom> for SteinerTree { + type Error = String; + fn try_from(spec: SteinerTreeCreateSpec) -> Result { + if spec.edge_weights.len() != spec.graph.num_edges() { + return Err(format!( + "edge_weights has {} entries, expected {}", + spec.edge_weights.len(), + spec.graph.num_edges() + )); + } + if spec.terminals.len() < 2 { + return Err("at least two terminals are required".to_string()); + } + let mut distinct = spec.terminals.clone(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != spec.terminals.len() { + return Err("terminals must be distinct".to_string()); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!( + "terminal {terminal} is outside graph with {} vertices", + spec.graph.num_vertices() + )); + } + Ok(Self::new(spec.graph, spec.edge_weights, spec.terminals)) + } +} + impl SteinerTree { /// Create a SteinerTree problem from a graph, edge weights, and terminals. pub fn new(graph: G, edge_weights: Vec, terminals: Vec) -> Self { @@ -247,9 +287,25 @@ where } } +crate::impl_random_generate!(SteinerTree, crate::random::SimpleGraphRandomSpec, |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let mut state = crate::random::lcg_init(spec.seed); + let graph = spec.graph()?; + for _ in 0..spec.num_vertices * spec.num_vertices { + crate::random::lcg_step(&mut state); + } + let weights = (0..graph.num_edges()).map(|_| (crate::random::lcg_step(&mut state) * 9.0) as i32 + 1).collect(); + let count = std::cmp::max(2, spec.num_vertices * 2 / 5); + let terminals = crate::random::lcg_choose(&mut state, spec.num_vertices, count) + .map_err(|error| error.to_string())?; + Ok(SteinerTree::new(graph, weights, terminals)) +}); + crate::declare_variants! { - default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", - SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2", + default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec random, + SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs index 9a191078c..236ede264 100644 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ b/src/models/graph/steiner_tree_in_graphs.rs @@ -3,7 +3,7 @@ //! The Steiner Tree problem asks for a minimum-weight subtree of a graph //! that connects all terminal vertices. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; @@ -19,13 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["One", "i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight subtree connecting all terminal vertices", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "terminals", type_name: "Vec", description: "Required terminal vertices R ⊆ V" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: SteinerTreeInGraphsCreateSpec::::FIELDS, } } @@ -77,6 +74,42 @@ pub struct SteinerTreeInGraphs { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SteinerTreeInGraphsCreateSpec { + /// The underlying graph. + graph: SimpleGraph, + /// Required terminal vertices. + terminals: Vec, + /// Edge weights; defaults to one per edge. + edge_weights: Option>, +} +impl TryFrom> for SteinerTreeInGraphs +where + W: Clone + Default + From, +{ + type Error = String; + fn try_from(spec: SteinerTreeInGraphsCreateSpec) -> Result { + let count = spec.graph.num_edges(); + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| (0..count).map(|_| W::from(1)).collect()); + if edge_weights.len() != count { + return Err(format!( + "edge_weights has {} entries, expected {count}", + edge_weights.len() + )); + } + if let Some(&terminal) = spec + .terminals + .iter() + .find(|&&t| t >= spec.graph.num_vertices()) + { + return Err(format!("terminal {terminal} is outside the graph")); + } + Ok(Self::new(spec.graph, spec.terminals, edge_weights)) + } +} + impl SteinerTreeInGraphs { /// Create a SteinerTreeInGraphs problem from a graph, terminals, and edge weights. /// @@ -273,9 +306,19 @@ pub(crate) fn is_steiner_tree(graph: &G, terminals: &[usize], selected terminals.iter().all(|&t| visited[t]) } +crate::impl_random_generate!(SteinerTreeInGraphs, crate::random::SimpleGraphRandomSpec, |spec| { + if spec.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let graph = spec.graph()?; + let terminals = (0..std::cmp::max(2, spec.num_vertices / 2)).collect(); + let weights = vec![1; graph.num_edges()]; + Ok(SteinerTreeInGraphs::new(graph, terminals, weights)) +}); + crate::declare_variants! { - default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", - SteinerTreeInGraphs => "2^num_terminals * num_vertices^3", + default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec random, + SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/strong_connectivity_augmentation.rs b/src/models/graph/strong_connectivity_augmentation.rs index d22906400..4b67424da 100644 --- a/src/models/graph/strong_connectivity_augmentation.rs +++ b/src/models/graph/strong_connectivity_augmentation.rs @@ -20,6 +20,7 @@ inventory::submit! { dimensions: &[ VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Add a bounded set of weighted candidate arcs to make a digraph strongly connected", fields: &[ diff --git a/src/models/graph/subgraph_isomorphism.rs b/src/models/graph/subgraph_isomorphism.rs index ca7f7506b..ecbba124d 100644 --- a/src/models/graph/subgraph_isomorphism.rs +++ b/src/models/graph/subgraph_isomorphism.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Subgraph Isomorphism", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine if host graph G contains a subgraph isomorphic to pattern graph H", fields: &[ diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index 017fabf7c..15a13700f 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -3,7 +3,7 @@ //! The Traveling Salesman problem asks for a minimum-weight cycle //! that visits every vertex exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; @@ -19,12 +19,10 @@ inventory::submit! { VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), ], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Find minimum weight Hamiltonian cycle in a graph (Traveling Salesman Problem)", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "edge_weights", type_name: "Vec", description: "Edge weights w: E -> R" }, - ], + fields: TravelingSalesmanCreateSpec::FIELDS, } } @@ -57,6 +55,62 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TravelingSalesmanCreateSpec { + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + num_vertices: Option, + #[create(codec = "comma-separated")] + edge_weights: Option>, +} + +impl TryFrom for TravelingSalesman { + type Error = String; + + fn try_from(spec: TravelingSalesmanCreateSpec) -> Result { + let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; + let edge_weights = spec + .edge_weights + .unwrap_or_else(|| vec![1; graph.num_edges()]); + if edge_weights.len() != graph.num_edges() { + return Err(format!( + "edge_weights has length {}, expected {}", + edge_weights.len(), + graph.num_edges() + )); + } + Ok(Self::new(graph, edge_weights)) + } +} + +fn simple_graph_from_create( + edges: Vec<(usize, usize)>, + num_vertices: Option, +) -> Result { + if edges.is_empty() && num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred = edges + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| vertex.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let num_vertices = num_vertices.unwrap_or(inferred); + if num_vertices < inferred { + return Err(format!( + "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" + )); + } + Ok(SimpleGraph::new(num_vertices, edges)) +} + impl TravelingSalesman { /// Create a TravelingSalesman problem from a graph with given edge weights. pub fn new(graph: G, edge_weights: Vec) -> Self { @@ -259,8 +313,14 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::SimpleGraphRandomSpec, |spec| { + let graph = spec.graph()?; + let weights = vec![1; graph.num_edges()]; + Ok(TravelingSalesman::new(graph, weights)) +}); + crate::declare_variants! { - default TravelingSalesman => "2^num_vertices", + default TravelingSalesman => "2^num_vertices" create TravelingSalesmanCreateSpec random, } #[cfg(test)] diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index be86186dd..7d78832be 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -13,7 +13,7 @@ //! lower bounds, so the registered exact complexity matches brute-force //! enumeration over the `2^|E|` edge orientations. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -25,16 +25,10 @@ inventory::submit! { display_name: "Undirected Flow with Lower Bounds", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine whether an undirected lower-bounded flow of value at least R exists", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Upper capacities c(e) in graph edge order" }, - FieldInfo { name: "lower_bounds", type_name: "Vec", description: "Lower bounds l(e) in graph edge order" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex s" }, - FieldInfo { name: "sink", type_name: "usize", description: "Sink vertex t" }, - FieldInfo { name: "requirement", type_name: "u64", description: "Required net inflow R at sink t" }, - ], + fields: UndirectedFlowLowerBoundsCreateSpec::FIELDS, } } @@ -55,6 +49,67 @@ pub struct UndirectedFlowLowerBounds { requirement: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedFlowLowerBoundsCreateSpec { + /// Undirected graph. + graph: SimpleGraph, + /// Upper capacities in graph edge order. + capacities: Vec, + /// Lower bounds in graph edge order. + lower_bounds: Vec, + /// Source vertex. + source: usize, + /// Sink vertex. + sink: usize, + /// Required net inflow at the sink. + requirement: u64, +} +impl TryFrom for UndirectedFlowLowerBounds { + type Error = String; + fn try_from(spec: UndirectedFlowLowerBoundsCreateSpec) -> Result { + let edges = spec.graph.num_edges(); + if spec.capacities.len() != edges { + return Err(format!( + "capacities has {} entries, expected {edges}", + spec.capacities.len() + )); + } + if spec.lower_bounds.len() != edges { + return Err(format!( + "lower_bounds has {} entries, expected {edges}", + spec.lower_bounds.len() + )); + } + let vertices = spec.graph.num_vertices(); + if spec.source >= vertices || spec.sink >= vertices { + return Err("source and sink must be valid graph vertices".to_string()); + } + if spec.source == spec.sink { + return Err("source and sink must be distinct".to_string()); + } + if spec.requirement == 0 { + return Err("requirement must be at least 1".to_string()); + } + if let Some((index, _)) = spec + .lower_bounds + .iter() + .zip(&spec.capacities) + .enumerate() + .find(|(_, (&lower, &upper))| lower > upper) + { + return Err(format!("lower bound at edge {index} exceeds its capacity")); + } + Ok(Self::new( + spec.graph, + spec.capacities, + spec.lower_bounds, + spec.source, + spec.sink, + spec.requirement, + )) + } +} + impl UndirectedFlowLowerBounds { pub fn new( graph: SimpleGraph, @@ -232,7 +287,7 @@ impl Problem for UndirectedFlowLowerBounds { } crate::declare_variants! { - default UndirectedFlowLowerBounds => "2^num_edges", + default UndirectedFlowLowerBounds => "2^num_edges" create UndirectedFlowLowerBoundsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index 6159336b8..d293f5643 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -3,7 +3,7 @@ //! The problem asks whether two integral commodities can be routed through an //! undirected capacitated graph while sharing edge capacities. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,18 +14,10 @@ inventory::submit! { display_name: "Undirected Two-Commodity Integral Flow", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), description: "Determine whether two integral commodities can satisfy sink demands in an undirected capacitated graph", - fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "Undirected graph G=(V,E)" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Edge capacities c(e) in graph edge order" }, - FieldInfo { name: "source_1", type_name: "usize", description: "Source vertex s_1 for commodity 1" }, - FieldInfo { name: "sink_1", type_name: "usize", description: "Sink vertex t_1 for commodity 1" }, - FieldInfo { name: "source_2", type_name: "usize", description: "Source vertex s_2 for commodity 2" }, - FieldInfo { name: "sink_2", type_name: "usize", description: "Sink vertex t_2 for commodity 2" }, - FieldInfo { name: "requirement_1", type_name: "u64", description: "Required net inflow R_1 at sink t_1" }, - FieldInfo { name: "requirement_2", type_name: "u64", description: "Required net inflow R_2 at sink t_2" }, - ], + fields: UndirectedTwoCommodityIntegralFlowCreateSpec::FIELDS, } } @@ -56,6 +48,82 @@ pub struct UndirectedTwoCommodityIntegralFlow { requirement_2: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct UndirectedTwoCommodityIntegralFlowCreateSpec { + /// Undirected graph edges. + #[create(codec = "edge-list")] + graph: Vec<(usize, usize)>, + /// Vertex count, needed for isolated vertices. + num_vertices: Option, + /// Edge capacities. + #[create(codec = "comma-separated")] + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: u64, + requirement_2: u64, +} + +impl TryFrom for UndirectedTwoCommodityIntegralFlow { + type Error = String; + fn try_from(spec: UndirectedTwoCommodityIntegralFlowCreateSpec) -> Result { + if spec.graph.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".into()); + } + for &(u, v) in &spec.graph { + if u == v { + return Err(format!("self-loop {u}-{v} is not allowed")); + } + } + let inferred = spec + .graph + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|v| v.checked_add(1).ok_or("vertex count overflows usize")) + .transpose()? + .unwrap_or(0); + let count = spec.num_vertices.unwrap_or(inferred); + if count < inferred { + return Err("num_vertices is too small for graph endpoints".into()); + } + if spec.capacities.len() != spec.graph.len() { + return Err("capacities length must match graph edge count".into()); + } + for &capacity in &spec.capacities { + if usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + { + return Err("capacity is too large for this platform".into()); + } + } + for (label, vertex) in [ + ("source_1", spec.source_1), + ("sink_1", spec.sink_1), + ("source_2", spec.source_2), + ("sink_2", spec.sink_2), + ] { + if vertex >= count { + return Err(format!("{label} must be less than num_vertices")); + } + } + Ok(Self { + graph: SimpleGraph::new(count, spec.graph), + capacities: spec.capacities, + source_1: spec.source_1, + sink_1: spec.sink_1, + source_2: spec.source_2, + sink_2: spec.sink_2, + requirement_1: spec.requirement_1, + requirement_2: spec.requirement_2, + }) + } +} + impl UndirectedTwoCommodityIntegralFlow { #[allow(clippy::too_many_arguments)] pub fn new( @@ -299,7 +367,7 @@ impl Problem for UndirectedTwoCommodityIntegralFlow { } crate::declare_variants! { - default UndirectedTwoCommodityIntegralFlow => "5^num_edges", + default UndirectedTwoCommodityIntegralFlow => "5^num_edges" create UndirectedTwoCommodityIntegralFlowCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/additional_key.rs b/src/models/misc/additional_key.rs index 6073fc46c..e1827a9c2 100644 --- a/src/models/misc/additional_key.rs +++ b/src/models/misc/additional_key.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Additional Key", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a relational schema has a candidate key not in a given set", fields: &[ diff --git a/src/models/misc/betweenness.rs b/src/models/misc/betweenness.rs index 2062f6af0..4d63462ed 100644 --- a/src/models/misc/betweenness.rs +++ b/src/models/misc/betweenness.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Betweenness", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a linear ordering where specified elements are between others", fields: &[ diff --git a/src/models/misc/bin_packing.rs b/src/models/misc/bin_packing.rs index a778c2395..25dc9d396 100644 --- a/src/models/misc/bin_packing.rs +++ b/src/models/misc/bin_packing.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Bin Packing", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32", "f64"])], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign items to bins minimizing number of bins used, subject to capacity", fields: &[ diff --git a/src/models/misc/boyce_codd_normal_form_violation.rs b/src/models/misc/boyce_codd_normal_form_violation.rs index 3e465c5bf..1d34f66ae 100644 --- a/src/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/models/misc/boyce_codd_normal_form_violation.rs @@ -5,7 +5,7 @@ //! `X ⊆ A'` such that the closure of `X` under the functional dependencies contains //! some but not all attributes of `A' \ X` — i.e., a witness to a BCNF violation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Boyce-Codd Normal Form Violation", aliases: &["BCNFViolation", "BCNF"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Test whether a subset of attributes violates Boyce-Codd normal form", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Total number of attributes in A" }, - FieldInfo { name: "functional_deps", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs_attributes, rhs_attributes)" }, - FieldInfo { name: "target_subset", type_name: "Vec", description: "Subset A' of attributes to test for BCNF violation" }, - ], + fields: BoyceCoddNormalFormViolationCreateSpec::FIELDS, } } @@ -68,6 +65,51 @@ pub struct BoyceCoddNormalFormViolation { target_subset: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct BoyceCoddNormalFormViolationCreateSpec { + /// Total number of attributes in A. + n: usize, + /// Functional dependencies (lhs attributes, rhs attributes). + #[create(codec = "functional-dependency-list")] + subsets: Vec<(Vec, Vec)>, + /// Subset A' of attributes to test for BCNF violation. + target: Vec, +} + +impl TryFrom for BoyceCoddNormalFormViolation { + type Error = String; + + fn try_from(spec: BoyceCoddNormalFormViolationCreateSpec) -> Result { + if spec.target.is_empty() { + return Err("target must be non-empty".to_string()); + } + for (dependency_index, (lhs, rhs)) in spec.subsets.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "subsets[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.n) + { + return Err(format!( + "subsets[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + } + if let Some(&attribute) = spec.target.iter().find(|&&attribute| attribute >= spec.n) { + return Err(format!( + "target contains attribute {attribute} outside universe of size {}", + spec.n + )); + } + Ok(Self::new(spec.n, spec.subsets, spec.target)) + } +} + impl BoyceCoddNormalFormViolation { /// Create a new Boyce-Codd Normal Form Violation instance. /// @@ -216,7 +258,7 @@ impl Problem for BoyceCoddNormalFormViolation { } crate::declare_variants! { - default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps", + default BoyceCoddNormalFormViolation => "2^num_target_attributes * num_target_attributes^2 * num_functional_deps" create BoyceCoddNormalFormViolationCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index cd4426daf..4008bbe4d 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -3,7 +3,7 @@ //! Capacity Assignment asks for the minimum-cost assignment of capacity levels //! to communication links, subject to a delay budget constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -13,14 +13,10 @@ inventory::submit! { display_name: "Capacity Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize total cost of capacity assignment subject to a delay budget", - fields: &[ - FieldInfo { name: "capacities", type_name: "Vec", description: "Ordered capacity levels M" }, - FieldInfo { name: "cost", type_name: "Vec>", description: "Cost matrix g(c, m) for each link and capacity" }, - FieldInfo { name: "delay", type_name: "Vec>", description: "Delay matrix d(c, m) for each link and capacity" }, - FieldInfo { name: "delay_budget", type_name: "u64", description: "Budget J on total delay penalty" }, - ], + fields: CapacityAssignmentCreateSpec::FIELDS, } } @@ -38,6 +34,57 @@ pub struct CapacityAssignment { delay_budget: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct CapacityAssignmentCreateSpec { + #[create(codec = "comma-separated")] + capacities: Vec, + #[create(codec = "semicolon-separated")] + cost: Vec>, + #[create(codec = "semicolon-separated")] + delay: Vec>, + delay_budget: u64, +} + +impl TryFrom for CapacityAssignment { + type Error = String; + fn try_from(spec: CapacityAssignmentCreateSpec) -> Result { + if spec.capacities.is_empty() { + return Err("capacities must be non-empty".into()); + } + if spec.capacities.contains(&0) { + return Err("capacities must be positive".into()); + } + if !spec.capacities.windows(2).all(|w| w[0] < w[1]) { + return Err("capacities must be strictly increasing".into()); + } + if spec.cost.len() != spec.delay.len() { + return Err("cost and delay must have the same number of links".into()); + } + for (i, row) in spec.cost.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("cost row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] <= w[1]) { + return Err(format!("cost row {i} must be non-decreasing")); + } + } + for (i, row) in spec.delay.iter().enumerate() { + if row.len() != spec.capacities.len() { + return Err(format!("delay row {i} length must match capacities length")); + } + if !row.windows(2).all(|w| w[0] >= w[1]) { + return Err(format!("delay row {i} must be non-increasing")); + } + } + Ok(Self { + capacities: spec.capacities, + cost: spec.cost, + delay: spec.delay, + delay_budget: spec.delay_budget, + }) + } +} + impl CapacityAssignment { /// Create a new Capacity Assignment instance. pub fn new( @@ -169,7 +216,7 @@ impl Problem for CapacityAssignment { } crate::declare_variants! { - default CapacityAssignment => "num_capacities ^ num_links", + default CapacityAssignment => "num_capacities ^ num_links" create CapacityAssignmentCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/closest_string.rs b/src/models/misc/closest_string.rs index 05c646890..6cfc78658 100644 --- a/src/models/misc/closest_string.rs +++ b/src/models/misc/closest_string.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Closest String", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a center string of fixed length that minimizes the maximum Hamming distance to a list of equal-length input strings", fields: &[ diff --git a/src/models/misc/closest_substring.rs b/src/models/misc/closest_substring.rs index 7e33a1b52..67e825dca 100644 --- a/src/models/misc/closest_substring.rs +++ b/src/models/misc/closest_substring.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Closest Substring", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a center string of fixed length and one length-ell window per input string that minimize the maximum Hamming distance between the center and any selected window", fields: &[ diff --git a/src/models/misc/clustering.rs b/src/models/misc/clustering.rs index 3bb340087..469cbf9dc 100644 --- a/src/models/misc/clustering.rs +++ b/src/models/misc/clustering.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Clustering", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition elements into at most K clusters where all intra-cluster distances are at most B", fields: &[ diff --git a/src/models/misc/conjunctive_boolean_query.rs b/src/models/misc/conjunctive_boolean_query.rs index e9e4b8737..9179d1189 100644 --- a/src/models/misc/conjunctive_boolean_query.rs +++ b/src/models/misc/conjunctive_boolean_query.rs @@ -10,7 +10,7 @@ //! the domain. The query is satisfiable iff there exists an assignment to the //! variables such that every conjunct's resolved tuple belongs to its relation. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -20,14 +20,10 @@ inventory::submit! { display_name: "Conjunctive Boolean Query", aliases: &["CBQ"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Evaluate a conjunctive Boolean query over a relational database", - fields: &[ - FieldInfo { name: "domain_size", type_name: "usize", description: "Size of the finite domain D" }, - FieldInfo { name: "relations", type_name: "Vec", description: "Collection of relations R" }, - FieldInfo { name: "num_variables", type_name: "usize", description: "Number of existentially quantified variables" }, - FieldInfo { name: "conjuncts", type_name: "Vec<(usize, Vec)>", description: "Query conjuncts: (relation_index, arguments)" }, - ], + fields: ConjunctiveBooleanQueryCreateSpec::FIELDS, } } @@ -87,6 +83,89 @@ pub struct ConjunctiveBooleanQuery { conjuncts: Vec<(usize, Vec)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConjunctiveBooleanQueryCreateSpec { + /// Size of the finite domain. + domain_size: usize, + /// Relations evaluated by the query. + #[create(codec = "json")] + relations: Vec, + /// Query atoms; the number of variables is inferred from their arguments. + #[create(codec = "json")] + conjuncts: Vec<(usize, Vec)>, +} + +impl TryFrom for ConjunctiveBooleanQuery { + type Error = String; + + fn try_from(spec: ConjunctiveBooleanQueryCreateSpec) -> Result { + let mut num_variables = 0_usize; + for (_, args) in &spec.conjuncts { + for arg in args { + if let QueryArg::Variable(variable) = arg { + let count = variable + .checked_add(1) + .ok_or_else(|| "number of query variables overflows usize".to_string())?; + num_variables = num_variables.max(count); + } + } + } + + for (relation_index, relation) in spec.relations.iter().enumerate() { + for (tuple_index, tuple) in relation.tuples.iter().enumerate() { + if tuple.len() != relation.arity { + return Err(format!( + "relation {relation_index} tuple {tuple_index} has length {}, expected arity {}", + tuple.len(), + relation.arity + )); + } + for (entry_index, &value) in tuple.iter().enumerate() { + if value >= spec.domain_size { + return Err(format!( + "relation {relation_index} tuple {tuple_index} entry {entry_index} is {value}, must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + for (conjunct_index, (relation_index, args)) in spec.conjuncts.iter().enumerate() { + let relation = spec.relations.get(*relation_index).ok_or_else(|| { + format!( + "conjunct {conjunct_index} relation index {relation_index} is out of range for {} relations", + spec.relations.len() + ) + })?; + if args.len() != relation.arity { + return Err(format!( + "conjunct {conjunct_index} has {} arguments, expected arity {}", + args.len(), + relation.arity + )); + } + for (argument_index, arg) in args.iter().enumerate() { + if let QueryArg::Constant(value) = arg { + if *value >= spec.domain_size { + return Err(format!( + "conjunct {conjunct_index} argument {argument_index} constant {value} must be less than domain size {}", + spec.domain_size + )); + } + } + } + } + + Ok(Self { + domain_size: spec.domain_size, + relations: spec.relations, + num_variables, + conjuncts: spec.conjuncts, + }) + } +} + impl ConjunctiveBooleanQuery { /// Create a new ConjunctiveBooleanQuery instance. /// @@ -224,7 +303,7 @@ impl Problem for ConjunctiveBooleanQuery { } crate::declare_variants! { - default ConjunctiveBooleanQuery => "domain_size ^ num_variables", + default ConjunctiveBooleanQuery => "domain_size ^ num_variables" create ConjunctiveBooleanQueryCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/conjunctive_query_foldability.rs b/src/models/misc/conjunctive_query_foldability.rs index cd3963c50..7e1014ed1 100644 --- a/src/models/misc/conjunctive_query_foldability.rs +++ b/src/models/misc/conjunctive_query_foldability.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Conjunctive Query Foldability", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine if one conjunctive query can be folded into another by substituting undistinguished variables", fields: &[ diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 04ade7a9e..4f8dab42d 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -6,7 +6,7 @@ //! assignment of attribute values to all objects that matches every published //! frequency table and every known value. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -88,14 +88,10 @@ inventory::submit! { display_name: "Consistency of Database Frequency Tables", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether pairwise frequency tables and known values admit a consistent complete database assignment", - fields: &[ - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects in the database" }, - FieldInfo { name: "attribute_domains", type_name: "Vec", description: "Domain size for each attribute" }, - FieldInfo { name: "frequency_tables", type_name: "Vec", description: "Published pairwise frequency tables" }, - FieldInfo { name: "known_values", type_name: "Vec", description: "Known object-attribute-value triples" }, - ], + fields: ConsistencyOfDatabaseFrequencyTablesCreateSpec::FIELDS, } } @@ -108,6 +104,110 @@ pub struct ConsistencyOfDatabaseFrequencyTables { known_values: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ConsistencyOfDatabaseFrequencyTablesCreateSpec { + /// Number of database objects. + num_objects: usize, + /// Domain size for each attribute. + #[create(codec = "comma-separated")] + attribute_domains: Vec, + /// Pairwise frequency tables as JSON objects. + #[create(codec = "json")] + frequency_tables: Vec, + /// Known object-attribute values as JSON objects; defaults to empty. + #[create(codec = "json")] + known_values: Option>, +} + +impl TryFrom + for ConsistencyOfDatabaseFrequencyTables +{ + type Error = String; + fn try_from(spec: ConsistencyOfDatabaseFrequencyTablesCreateSpec) -> Result { + let known_values = spec.known_values.unwrap_or_default(); + validate_cdft_create( + spec.num_objects, + &spec.attribute_domains, + &spec.frequency_tables, + &known_values, + )?; + Ok(Self { + num_objects: spec.num_objects, + attribute_domains: spec.attribute_domains, + frequency_tables: spec.frequency_tables, + known_values, + }) + } +} + +fn validate_cdft_create( + num_objects: usize, + domains: &[usize], + tables: &[FrequencyTable], + known: &[KnownValue], +) -> Result<(), String> { + for (attribute, &size) in domains.iter().enumerate() { + if size == 0 { + return Err(format!( + "attribute domain size at index {attribute} must be positive" + )); + } + } + let mut pairs = BTreeSet::new(); + for table in tables { + let a = table.attribute_a(); + let b = table.attribute_b(); + if a >= domains.len() || b >= domains.len() { + return Err("frequency table attribute is out of range".into()); + } + if a == b { + return Err("frequency table attributes must be distinct".into()); + } + let pair = if a < b { (a, b) } else { (b, a) }; + if !pairs.insert(pair) { + return Err(format!( + "duplicate frequency table pair ({}, {})", + pair.0, pair.1 + )); + } + if table.counts().len() != domains[a] { + return Err(format!( + "frequency table row count must equal domain size for attribute {a}" + )); + } + if table.counts().iter().any(|row| row.len() != domains[b]) { + return Err(format!( + "frequency table column count must equal domain size for attribute {b}" + )); + } + let total = table + .counts() + .iter() + .flatten() + .try_fold(0usize, |sum, &value| { + sum.checked_add(value) + .ok_or("frequency table count total overflows usize") + })?; + if total != num_objects { + return Err(format!( + "frequency table total {total} must equal num_objects {num_objects}" + )); + } + } + for value in known { + if value.object() >= num_objects { + return Err("known value object is out of range".into()); + } + if value.attribute() >= domains.len() { + return Err("known value attribute is out of range".into()); + } + if value.value() >= domains[value.attribute()] { + return Err("known value is outside the attribute domain".into()); + } + } + Ok(()) +} + impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. pub fn new( @@ -336,7 +436,7 @@ impl Problem for ConsistencyOfDatabaseFrequencyTables { } crate::declare_variants! { - default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects", + default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/cosine_product_integration.rs b/src/models/misc/cosine_product_integration.rs index 1716cc770..595a23b6f 100644 --- a/src/models/misc/cosine_product_integration.rs +++ b/src/models/misc/cosine_product_integration.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Cosine Product Integration", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Decide whether a balanced sign assignment exists for a sequence of integer frequencies", fields: &[ diff --git a/src/models/misc/cyclic_ordering.rs b/src/models/misc/cyclic_ordering.rs index 9087fe497..ba884db6d 100644 --- a/src/models/misc/cyclic_ordering.rs +++ b/src/models/misc/cyclic_ordering.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Cyclic Ordering", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a permutation satisfying cyclic ordering constraints on triples", fields: &[ diff --git a/src/models/misc/dynamic_storage_allocation.rs b/src/models/misc/dynamic_storage_allocation.rs index adcba4d93..8a9c3f6ac 100644 --- a/src/models/misc/dynamic_storage_allocation.rs +++ b/src/models/misc/dynamic_storage_allocation.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Dynamic Storage Allocation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign starting addresses for items with time intervals and sizes within bounded memory", fields: &[ diff --git a/src/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index 479e7eb48..5fcd2476d 100644 --- a/src/models/misc/ensemble_computation.rs +++ b/src/models/misc/ensemble_computation.rs @@ -11,6 +11,7 @@ inventory::submit! { display_name: "Ensemble Computation", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find the minimum-length sequence of disjoint unions that builds all required subsets", fields: &[ diff --git a/src/models/misc/expected_retrieval_cost.rs b/src/models/misc/expected_retrieval_cost.rs index 573e6f49d..f6df30c42 100644 --- a/src/models/misc/expected_retrieval_cost.rs +++ b/src/models/misc/expected_retrieval_cost.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Expected Retrieval Cost", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign records to circular storage sectors to minimize expected retrieval latency", fields: &[ diff --git a/src/models/misc/factoring.rs b/src/models/misc/factoring.rs index 9b72b2754..bf71653ba 100644 --- a/src/models/misc/factoring.rs +++ b/src/models/misc/factoring.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Factoring", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Factor a composite integer into two factors", fields: &[ diff --git a/src/models/misc/feasible_register_assignment.rs b/src/models/misc/feasible_register_assignment.rs index 64c0e340a..9f68aafa9 100644 --- a/src/models/misc/feasible_register_assignment.rs +++ b/src/models/misc/feasible_register_assignment.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Feasible Register Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a DAG computation can be scheduled without register conflicts under a fixed assignment", fields: &[ diff --git a/src/models/misc/flow_shop_scheduling.rs b/src/models/misc/flow_shop_scheduling.rs index d29937b8a..a86d6e3c5 100644 --- a/src/models/misc/flow_shop_scheduling.rs +++ b/src/models/misc/flow_shop_scheduling.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Flow Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine if a flow-shop schedule for jobs on m processors meets a deadline", fields: &[ diff --git a/src/models/misc/grouping_by_swapping.rs b/src/models/misc/grouping_by_swapping.rs index ff6cc0d36..eb2185edd 100644 --- a/src/models/misc/grouping_by_swapping.rs +++ b/src/models/misc/grouping_by_swapping.rs @@ -4,7 +4,7 @@ //! whether at most `K` adjacent swaps can transform the string so that every //! symbol appears in a single contiguous block. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Grouping by Swapping", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Group equal symbols into contiguous blocks using at most K adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "string", type_name: "Vec", description: "Input string over {0, ..., alphabet_size-1}" }, - FieldInfo { name: "budget", type_name: "usize", description: "Maximum number of adjacent swaps allowed" }, - ], + fields: GroupingBySwappingCreateSpec::FIELDS, } } @@ -36,6 +33,54 @@ pub struct GroupingBySwapping { budget: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct GroupingBySwappingCreateSpec { + /// Optional alphabet size; omitted values are inferred from the string. + alphabet_size: Option, + /// Input string to group. + #[create(codec = "comma-separated")] + string: Vec, + /// Maximum number of adjacent swaps. + bound: usize, +} + +impl TryFrom for GroupingBySwapping { + type Error = String; + + fn try_from(spec: GroupingBySwappingCreateSpec) -> Result { + let inferred_alphabet_size = spec + .string + .iter() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && !spec.string.is_empty() { + return Err("alphabet size must be positive for a non-empty string".to_string()); + } + if spec.string.is_empty() && spec.bound != 0 { + return Err("bound must be zero when the string is empty".to_string()); + } + + Ok(Self { + alphabet_size, + string: spec.string, + budget: spec.bound, + }) + } +} + impl GroupingBySwapping { /// Create a new GroupingBySwapping instance. /// @@ -160,7 +205,7 @@ impl Problem for GroupingBySwapping { } crate::declare_variants! { - default GroupingBySwapping => "string_len ^ budget", + default GroupingBySwapping => "string_len ^ budget" create GroupingBySwappingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/integer_expression_membership.rs b/src/models/misc/integer_expression_membership.rs index 0e47f3006..d7ff2f323 100644 --- a/src/models/misc/integer_expression_membership.rs +++ b/src/models/misc/integer_expression_membership.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Integer Expression Membership", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Decide whether a target integer belongs to the set represented by an expression tree over union and Minkowski sum", fields: &[ diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs index be2dbb4bf..26733f47c 100644 --- a/src/models/misc/job_shop_scheduling.rs +++ b/src/models/misc/job_shop_scheduling.rs @@ -5,7 +5,7 @@ //! makespan (completion time of the last task) while respecting both within-job //! precedence and single-processor capacity constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,12 +17,10 @@ inventory::submit! { display_name: "Job-Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize the makespan of a job-shop schedule", - fields: &[ - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "jobs", type_name: "Vec>", description: "jobs[j][k] = (processor, length) for the k-th task of job j" }, - ], + fields: JobShopSchedulingCreateSpec::FIELDS, } } @@ -32,6 +30,64 @@ pub struct JobShopScheduling { jobs: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct JobShopSchedulingCreateSpec { + /// Jobs expressed as ordered processor-duration operations. + #[create(codec = "semicolon-separated")] + jobs: Vec>, + /// Optional processor count; omitted values are inferred from the jobs. + num_processors: Option, +} + +impl TryFrom for JobShopScheduling { + type Error = String; + + fn try_from(spec: JobShopSchedulingCreateSpec) -> Result { + let inferred_processors = spec + .jobs + .iter() + .flatten() + .map(|(processor, _)| *processor) + .max() + .map(|processor| { + processor + .checked_add(1) + .ok_or_else(|| "inferred processor count overflows usize".to_string()) + }) + .transpose()?; + let num_processors = spec.num_processors.or(inferred_processors).ok_or_else(|| { + "cannot infer processor count from an empty job list; provide num_processors" + .to_string() + })?; + if num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + + for (job_index, job) in spec.jobs.iter().enumerate() { + for (task_index, &(processor, _)) in job.iter().enumerate() { + if processor >= num_processors { + return Err(format!( + "job {job_index} task {task_index} uses processor {processor}, but num_processors is {num_processors}" + )); + } + } + for (task_index, pair) in job.windows(2).enumerate() { + if pair[0].0 == pair[1].0 { + return Err(format!( + "job {job_index} tasks {task_index} and {} must use different processors", + task_index + 1 + )); + } + } + } + + Ok(Self { + num_processors, + jobs: spec.jobs, + }) + } +} + struct FlattenedTasks { job_task_ids: Vec>, machine_task_ids: Vec>, @@ -234,7 +290,7 @@ impl Problem for JobShopScheduling { } crate::declare_variants! { - default JobShopScheduling => "factorial(num_tasks)", + default JobShopScheduling => "factorial(num_tasks)" create JobShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/knapsack.rs b/src/models/misc/knapsack.rs index 2c282fc8c..d7c9e04e3 100644 --- a/src/models/misc/knapsack.rs +++ b/src/models/misc/knapsack.rs @@ -3,7 +3,7 @@ //! The 0-1 Knapsack problem asks for a subset of items that maximizes //! total value while respecting a weight capacity constraint. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -14,13 +14,17 @@ inventory::submit! { display_name: "Knapsack", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Select items to maximize total value subject to weight capacity constraint", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Nonnegative item weights w_i" }, - FieldInfo { name: "values", type_name: "Vec", description: "Nonnegative item values v_i" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Nonnegative knapsack capacity C" }, - ], + fields: KnapsackCreateSpec::FIELDS, + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "Knapsack", + fields: &["num_items"], } } @@ -56,6 +60,33 @@ pub struct Knapsack { capacity: i64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KnapsackCreateSpec { + /// Nonnegative item weights; defaults to one per value. + weights: Option>, + /// Nonnegative item values. + values: Vec, + /// Nonnegative knapsack capacity. + capacity: i64, +} +impl TryFrom for Knapsack { + type Error = String; + fn try_from(spec: KnapsackCreateSpec) -> Result { + let count = spec.values.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal values length".to_string()); + } + if weights.iter().any(|&value| value < 0) + || spec.values.iter().any(|&value| value < 0) + || spec.capacity < 0 + { + return Err("weights, values, and capacity must be nonnegative".to_string()); + } + Ok(Self::new(weights, spec.values, spec.capacity)) + } +} + impl Knapsack { /// Create a new Knapsack instance. /// @@ -156,7 +187,7 @@ impl Problem for Knapsack { } crate::declare_variants! { - default Knapsack => "2^(num_items / 2)", + default Knapsack => "2^(num_items / 2)" create KnapsackCreateSpec, } mod nonnegative_i64 { diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 6b600a98e..ef5d378ce 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -1,12 +1,12 @@ //! Kth Largest m-Tuple problem implementation. //! -//! Given m sets of positive integers and thresholds K and B, count how many -//! distinct m-tuples (one element per set) have total size at least B. -//! The answer is YES iff the count is at least K. Garey & Johnson MP10. +//! Given m sets of positive integers and thresholds K and B, determine whether +//! 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::Sum; +use crate::types::Or; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize}; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Kth Largest m-Tuple", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Count m-tuples whose total size meets a bound and compare against a threshold K", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "m sets, each containing positive integer sizes" }, - FieldInfo { name: "k", type_name: "u64", description: "Threshold K (answer YES iff count >= K)" }, - FieldInfo { name: "bound", type_name: "u64", description: "Lower bound B on tuple sum" }, - ], + fields: KthLargestMTupleCreateSpec::FIELDS, } } @@ -36,15 +33,14 @@ inventory::submit! { /// The Kth Largest m-Tuple problem. /// /// Given sets `X_1, ..., X_m` of positive integers, a threshold `K`, and a -/// bound `B`, count how many distinct m-tuples `(x_1, ..., x_m)` in -/// `X_1 x ... x X_m` satisfy `sum(x_i) >= B`. The answer is YES iff the -/// count is at least `K`. +/// bound `B`, determine whether at least `K` distinct m-tuples +/// `(x_1, ..., x_m)` in `X_1 x ... x X_m` satisfy `sum(x_i) >= B`. /// /// # Representation /// -/// Variable `i` selects an element from set `X_i`, ranging over `{0, ..., |X_i|-1}`. -/// `evaluate` returns `Sum(1)` if the tuple sum >= B, else `Sum(0)`. -/// The aggregate over all configurations gives the total count of qualifying tuples. +/// The empty configuration triggers enumeration of the Cartesian product. +/// `evaluate` returns `Or(true)` as soon as `K` qualifying tuples have been +/// found and `Or(false)` if the complete product contains fewer than `K`. /// /// # Example /// @@ -58,9 +54,9 @@ inventory::submit! { /// 12, /// ); /// let solver = BruteForce::new(); -/// let value = solver.solve(&problem); -/// // 14 of the 18 tuples have sum >= 12 -/// assert_eq!(value, problemreductions::types::Sum(14)); +/// let answer = solver.solve(&problem); +/// // 14 of the 18 tuples have sum >= 12, so count >= K. +/// assert_eq!(answer, problemreductions::types::Or(true)); /// ``` #[derive(Debug, Clone, Serialize)] pub struct KthLargestMTuple { @@ -69,6 +65,24 @@ pub struct KthLargestMTuple { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct KthLargestMTupleCreateSpec { + /// m sets, each containing positive integer sizes. + subsets: Vec>, + /// Threshold K (answer YES iff count >= K). + k: u64, + /// Lower bound B on tuple sum. + bound: u64, +} + +impl TryFrom for KthLargestMTuple { + type Error = String; + + fn try_from(spec: KthLargestMTupleCreateSpec) -> Result { + Self::try_new(spec.subsets, spec.k, spec.bound) + } +} + impl KthLargestMTuple { fn validate(sets: &[Vec], k: u64, bound: u64) -> Result<(), String> { if sets.is_empty() { @@ -126,7 +140,42 @@ impl KthLargestMTuple { /// Returns the total number of m-tuples (product of set sizes). pub fn total_tuples(&self) -> usize { - self.sets.iter().map(|s| s.len()).product() + self.sets + .iter() + .try_fold(1usize, |total, set| total.checked_mul(set.len())) + .expect("KthLargestMTuple total tuple count exceeds usize") + } + + fn has_at_least_k_qualifying_tuples(&self) -> bool { + let mut choices = vec![0; self.sets.len()]; + let mut qualifying = 0; + + loop { + let mut remaining_bound = self.bound; + for (set, &choice) in self.sets.iter().zip(&choices) { + remaining_bound = remaining_bound.saturating_sub(set[choice]); + } + if remaining_bound == 0 { + qualifying += 1; + if qualifying == self.k { + return true; + } + } + + let mut advanced = false; + for set_index in (0..choices.len()).rev() { + choices[set_index] += 1; + if choices[set_index] == self.sets[set_index].len() { + choices[set_index] = 0; + } else { + advanced = true; + break; + } + } + if !advanced { + return false; + } + } } } @@ -149,48 +198,31 @@ impl<'de> Deserialize<'de> for KthLargestMTuple { impl Problem for KthLargestMTuple { const NAME: &'static str = "KthLargestMTuple"; - type Value = Sum; + type Value = Or; fn variant() -> Vec<(&'static str, &'static str)> { crate::variant_params![] } fn dims(&self) -> Vec { - self.sets.iter().map(|s| s.len()).collect() + vec![] } - fn evaluate(&self, config: &[usize]) -> Sum { - if config.len() != self.num_sets() { - return Sum(0); - } - for (i, &choice) in config.iter().enumerate() { - if choice >= self.sets[i].len() { - return Sum(0); - } - } - let total: u64 = config - .iter() - .enumerate() - .map(|(i, &choice)| self.sets[i][choice]) - .sum(); - if total >= self.bound { - Sum(1) - } else { - Sum(0) - } + fn evaluate(&self, config: &[usize]) -> Or { + Or(config.is_empty() && self.has_at_least_k_qualifying_tuples()) } } // 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")] pub(crate) fn canonical_model_example_specs() -> Vec { // m=3, X_1={2,5,8}, X_2={3,6}, X_3={1,4,7}, B=12, K=14. - // 14 of 18 tuples have sum >= 12. The config [2,1,2] picks (8,6,7) with sum=21 >= 12. + // 14 of 18 tuples have sum >= 12, so the answer is YES at K=14. vec![crate::example_db::specs::ModelExampleSpec { id: "kth_largest_m_tuple", instance: Box::new(KthLargestMTuple::new( @@ -198,8 +230,8 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible subsequence length (min of string lengths)" }, - ], + fields: LongestCommonSubsequenceCreateSpec::FIELDS, } } @@ -45,6 +42,54 @@ pub struct LongestCommonSubsequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct LongestCommonSubsequenceCreateSpec { + /// Optional alphabet size; omitted values are inferred from the strings. + alphabet_size: Option, + /// Input strings over the shared alphabet. + #[create(codec = "character-rows")] + strings: Vec>, +} + +impl TryFrom for LongestCommonSubsequence { + type Error = String; + + fn try_from(spec: LongestCommonSubsequenceCreateSpec) -> Result { + if !spec.strings.iter().any(|string| !string.is_empty()) { + return Err("at least one input string must be non-empty".to_string()); + } + let inferred_alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 { + return Err("alphabet size must be positive".to_string()); + } + let max_length = spec.strings.iter().map(Vec::len).min().unwrap_or(0); + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl LongestCommonSubsequence { /// Create a new LongestCommonSubsequence instance. /// @@ -203,7 +248,7 @@ impl Problem for LongestCommonSubsequence { } crate::declare_variants! { - default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length", + default LongestCommonSubsequence => "(alphabet_size + 1) ^ max_length" create LongestCommonSubsequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/maximum_likelihood_ranking.rs b/src/models/misc/maximum_likelihood_ranking.rs index 89d178d46..d4c6361ee 100644 --- a/src/models/misc/maximum_likelihood_ranking.rs +++ b/src/models/misc/maximum_likelihood_ranking.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Maximum Likelihood Ranking", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a ranking minimizing total pairwise disagreement cost", fields: &[ diff --git a/src/models/misc/minimum_axiom_set.rs b/src/models/misc/minimum_axiom_set.rs index 705e155cf..d6cde9bd5 100644 --- a/src/models/misc/minimum_axiom_set.rs +++ b/src/models/misc/minimum_axiom_set.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Axiom Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find smallest axiom subset whose deductive closure equals the true sentences", fields: &[ diff --git a/src/models/misc/minimum_code_generation_one_register.rs b/src/models/misc/minimum_code_generation_one_register.rs index 58fe83a27..b7dde2573 100644 --- a/src/models/misc/minimum_code_generation_one_register.rs +++ b/src/models/misc/minimum_code_generation_one_register.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Code Generation (One Register)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-length instruction sequence for a one-register machine to evaluate an expression DAG", fields: &[ diff --git a/src/models/misc/minimum_code_generation_parallel_assignments.rs b/src/models/misc/minimum_code_generation_parallel_assignments.rs index 09f4595fd..7617ee15b 100644 --- a/src/models/misc/minimum_code_generation_parallel_assignments.rs +++ b/src/models/misc/minimum_code_generation_parallel_assignments.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Minimum Code Generation (Parallel Assignments)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find an ordering of parallel assignments minimizing backward dependencies", fields: &[ diff --git a/src/models/misc/minimum_code_generation_unlimited_registers.rs b/src/models/misc/minimum_code_generation_unlimited_registers.rs index e4144d608..4233734e3 100644 --- a/src/models/misc/minimum_code_generation_unlimited_registers.rs +++ b/src/models/misc/minimum_code_generation_unlimited_registers.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Minimum Code Generation (Unlimited Registers)", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-length instruction sequence for an unlimited-register machine with 2-address instructions to evaluate an expression DAG", fields: &[ diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index 453b80089..c3948e944 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -4,7 +4,7 @@ //! that identifies each object with minimum total external path length //! (sum of depths of all leaves). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -15,13 +15,10 @@ inventory::submit! { display_name: "Minimum Decision Tree", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find decision tree identifying objects with minimum total path length", - fields: &[ - FieldInfo { name: "test_matrix", type_name: "Vec>", description: "Binary matrix: test_matrix[j][i] = object i passes test j" }, - FieldInfo { name: "num_objects", type_name: "usize", description: "Number of objects to identify" }, - FieldInfo { name: "num_tests", type_name: "usize", description: "Number of available binary tests" }, - ], + fields: MinimumDecisionTreeCreateSpec::FIELDS, } } @@ -62,6 +59,55 @@ pub struct MinimumDecisionTree { num_tests: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumDecisionTreeCreateSpec { + /// Binary test matrix as JSON. + #[create(codec = "json")] + test_matrix: Vec>, + /// Number of objects. + num_objects: usize, + /// Number of tests. + num_tests: usize, +} + +impl TryFrom for MinimumDecisionTree { + type Error = String; + fn try_from(spec: MinimumDecisionTreeCreateSpec) -> Result { + if spec.num_objects < 2 { + return Err("num_objects must be at least 2".into()); + } + if spec.num_tests == 0 { + return Err("num_tests must be positive".into()); + } + if spec.test_matrix.len() != spec.num_tests { + return Err("test_matrix row count must equal num_tests".into()); + } + if spec + .test_matrix + .iter() + .any(|row| row.len() != spec.num_objects) + { + return Err("each test_matrix row must have num_objects columns".into()); + } + for a in 0..spec.num_objects { + for b in a + 1..spec.num_objects { + if !(0..spec.num_tests) + .any(|test| spec.test_matrix[test][a] != spec.test_matrix[test][b]) + { + return Err(format!( + "objects {a} and {b} are not distinguished by any test" + )); + } + } + } + Ok(Self { + test_matrix: spec.test_matrix, + num_objects: spec.num_objects, + num_tests: spec.num_tests, + }) + } +} + impl MinimumDecisionTree { /// Create a new MinimumDecisionTree problem. /// @@ -187,7 +233,7 @@ impl Problem for MinimumDecisionTree { } crate::declare_variants! { - default MinimumDecisionTree => "num_tests^num_objects", + default MinimumDecisionTree => "num_tests^num_objects" create MinimumDecisionTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index deff9704a..351b58f7a 100644 --- a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Minimum Discrete Planar Inverse Kinematics", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Pick one sampled absolute orientation per link, subject to consecutive-pair feasibility constraints, to minimize the squared distance from the end-effector to a target point", fields: &[ diff --git a/src/models/misc/minimum_disjunctive_normal_form.rs b/src/models/misc/minimum_disjunctive_normal_form.rs index a705a34e3..b4e3211ac 100644 --- a/src/models/misc/minimum_disjunctive_normal_form.rs +++ b/src/models/misc/minimum_disjunctive_normal_form.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Minimum Disjunctive Normal Form", aliases: &["MinDNF"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-term DNF formula equivalent to a Boolean function", fields: &[ diff --git a/src/models/misc/minimum_external_macro_data_compression.rs b/src/models/misc/minimum_external_macro_data_compression.rs index dd6fbbd0d..32d99b11e 100644 --- a/src/models/misc/minimum_external_macro_data_compression.rs +++ b/src/models/misc/minimum_external_macro_data_compression.rs @@ -25,6 +25,7 @@ inventory::submit! { display_name: "Minimum External Macro Data Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-cost compression using an external dictionary and compressed string with pointers", fields: &[ diff --git a/src/models/misc/minimum_fault_detection_test_set.rs b/src/models/misc/minimum_fault_detection_test_set.rs index 43efeae64..9ae36bb75 100644 --- a/src/models/misc/minimum_fault_detection_test_set.rs +++ b/src/models/misc/minimum_fault_detection_test_set.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Fault Detection Test Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum set of input-output paths covering all internal DAG vertices", fields: &[ diff --git a/src/models/misc/minimum_internal_macro_data_compression.rs b/src/models/misc/minimum_internal_macro_data_compression.rs index 15f76309e..34d902f3c 100644 --- a/src/models/misc/minimum_internal_macro_data_compression.rs +++ b/src/models/misc/minimum_internal_macro_data_compression.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Minimum Internal Macro Data Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find minimum-cost self-referencing compression of a string with embedded pointers", fields: &[ diff --git a/src/models/misc/minimum_register_sufficiency_for_loops.rs b/src/models/misc/minimum_register_sufficiency_for_loops.rs index fc6d19027..747ee6a2d 100644 --- a/src/models/misc/minimum_register_sufficiency_for_loops.rs +++ b/src/models/misc/minimum_register_sufficiency_for_loops.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Minimum Register Sufficiency for Loops", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign registers to loop variables minimizing register count, no two conflicting variables share a register", fields: &[ diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index f507094d5..94c7f7c16 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -8,7 +8,7 @@ //! - `MinimumTardinessSequencing` — unit-length tasks (`1|prec, pj=1|∑Uj`) //! - `MinimumTardinessSequencing` — arbitrary-length tasks (`1|prec|∑Uj`) -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, One, WeightElement}; use serde::{Deserialize, Serialize}; @@ -19,13 +19,10 @@ inventory::submit! { display_name: "Minimum Tardiness Sequencing", aliases: &[], dimensions: &[VariantDimension::new("weight", "One", &["One", "i32"])], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule tasks with precedence constraints and deadlines to minimize the number of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: MinimumTardinessSequencingOneCreateSpec::FIELDS, } } @@ -64,6 +61,64 @@ pub struct MinimumTardinessSequencing { precedences: Vec<(usize, usize)>, } +macro_rules! minimum_tardiness_create_spec { + ($name:ident, $weight:ty, $construct:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + lengths: Vec<$weight>, + deadlines: Vec, + precedences: Option>, + } + + impl TryFrom<$name> for MinimumTardinessSequencing<$weight> { + type Error = String; + + fn try_from(spec: $name) -> Result { + if spec.lengths.len() != spec.deadlines.len() { + return Err("lengths and deadlines must have the same length".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_tasks = spec.lengths.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_tasks || succ >= num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_tasks} tasks" + )); + } + $construct(spec.lengths, spec.deadlines, precedences) + } + } + }; +} + +minimum_tardiness_create_spec!( + MinimumTardinessSequencingOneCreateSpec, + One, + |lengths: Vec, deadlines, precedences| { + Ok(MinimumTardinessSequencing::new( + lengths.len(), + deadlines, + precedences, + )) + } +); +minimum_tardiness_create_spec!( + MinimumTardinessSequencingI32CreateSpec, + i32, + |lengths: Vec, deadlines, precedences| { + if lengths.iter().any(|&length| length <= 0) { + return Err("all task lengths must be positive".to_string()); + } + Ok(MinimumTardinessSequencing::with_lengths( + lengths, + deadlines, + precedences, + )) + } +); + impl MinimumTardinessSequencing { /// Create a new unit-length MinimumTardinessSequencing instance. /// @@ -247,8 +302,8 @@ impl Problem for MinimumTardinessSequencing { } crate::declare_variants! { - default MinimumTardinessSequencing => "2^num_tasks", - MinimumTardinessSequencing => "2^num_tasks", + default MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingOneCreateSpec, + MinimumTardinessSequencing => "2^num_tasks" create MinimumTardinessSequencingI32CreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_weight_and_or_graph.rs b/src/models/misc/minimum_weight_and_or_graph.rs index dc497a6f5..d1a2d2f75 100644 --- a/src/models/misc/minimum_weight_and_or_graph.rs +++ b/src/models/misc/minimum_weight_and_or_graph.rs @@ -3,7 +3,7 @@ //! Given a directed acyclic graph with AND/OR gates, find the minimum-weight //! solution subgraph from a designated source vertex. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Deserializer, Serialize}; @@ -14,15 +14,10 @@ inventory::submit! { display_name: "Minimum Weight AND/OR Graph", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find the minimum-weight solution subgraph from a source in a DAG with AND/OR gates", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the DAG" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Directed arcs (u, v)" }, - FieldInfo { name: "source", type_name: "usize", description: "Source vertex index" }, - FieldInfo { name: "gate_types", type_name: "Vec>", description: "Gate type per vertex: Some(true)=AND, Some(false)=OR, None=leaf" }, - FieldInfo { name: "arc_weights", type_name: "Vec", description: "Weight of each arc" }, - ], + fields: MinimumWeightAndOrGraphCreateSpec::FIELDS, } } @@ -78,6 +73,56 @@ pub struct MinimumWeightAndOrGraph { outgoing: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumWeightAndOrGraphCreateSpec { + /// Number of vertices in the DAG. + num_vertices: usize, + /// Directed arcs. + arcs: Vec<(usize, usize)>, + /// Source vertex. + source: usize, + /// Gate type per vertex. + gate_types: Vec>, + /// Arc weights; defaults to one per arc. + arc_weights: Option>, +} +impl TryFrom for MinimumWeightAndOrGraph { + type Error = String; + fn try_from(spec: MinimumWeightAndOrGraphCreateSpec) -> Result { + if spec.source >= spec.num_vertices { + return Err("source is outside the graph".to_string()); + } + if spec.gate_types.len() != spec.num_vertices { + return Err("gate_types length must equal num_vertices".to_string()); + } + if spec.gate_types[spec.source].is_none() { + return Err("source must be an AND or OR gate".to_string()); + } + if let Some(&(u, v)) = spec + .arcs + .iter() + .find(|&&(u, v)| u >= spec.num_vertices || v >= spec.num_vertices) + { + return Err(format!("arc ({u}, {v}) is out of bounds")); + } + let count = spec.arcs.len(); + let arc_weights = spec.arc_weights.unwrap_or_else(|| vec![1; count]); + if arc_weights.len() != count { + return Err(format!( + "arc_weights has {} entries, expected {count}", + arc_weights.len() + )); + } + Ok(Self::new( + spec.num_vertices, + spec.arcs, + spec.source, + spec.gate_types, + arc_weights, + )) + } +} + #[derive(Deserialize)] struct MinimumWeightAndOrGraphData { num_vertices: usize, @@ -295,7 +340,7 @@ impl Problem for MinimumWeightAndOrGraph { } crate::declare_variants! { - default MinimumWeightAndOrGraph => "2^num_arcs", + default MinimumWeightAndOrGraph => "2^num_arcs" create MinimumWeightAndOrGraphCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/multiprocessor_scheduling.rs b/src/models/misc/multiprocessor_scheduling.rs index 4339a99ce..7617024ef 100644 --- a/src/models/misc/multiprocessor_scheduling.rs +++ b/src/models/misc/multiprocessor_scheduling.rs @@ -4,7 +4,7 @@ //! can be assigned to identical processors such that no processor's //! total load exceeds a given deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Multiprocessor Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign tasks to processors so that no processor's load exceeds a deadline", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadline", type_name: "u64", description: "Global deadline D" }, - ], + fields: MultiprocessorSchedulingCreateSpec::FIELDS, } } @@ -63,6 +60,25 @@ pub struct MultiprocessorScheduling { deadline: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MultiprocessorSchedulingCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Number of identical processors. + num_processors: usize, + /// Global deadline. + deadline: u64, +} +impl TryFrom for MultiprocessorScheduling { + type Error = String; + fn try_from(spec: MultiprocessorSchedulingCreateSpec) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + Ok(Self::new(spec.lengths, spec.num_processors, spec.deadline)) + } +} + impl MultiprocessorScheduling { /// Create a new Multiprocessor Scheduling instance. /// @@ -134,7 +150,7 @@ impl Problem for MultiprocessorScheduling { } crate::declare_variants! { - default MultiprocessorScheduling => "2^num_tasks", + default MultiprocessorScheduling => "2^num_tasks" create MultiprocessorSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/non_liveness_free_petri_net.rs b/src/models/misc/non_liveness_free_petri_net.rs index 322a2e727..cd3584ec9 100644 --- a/src/models/misc/non_liveness_free_petri_net.rs +++ b/src/models/misc/non_liveness_free_petri_net.rs @@ -23,6 +23,7 @@ inventory::submit! { display_name: "Non-Liveness Free Petri Net", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a free-choice Petri net is not live (some transition can become permanently dead)", fields: &[ diff --git a/src/models/misc/numerical_3_dimensional_matching.rs b/src/models/misc/numerical_3_dimensional_matching.rs index cb4363647..db763f518 100644 --- a/src/models/misc/numerical_3_dimensional_matching.rs +++ b/src/models/misc/numerical_3_dimensional_matching.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Numerical 3-Dimensional Matching", aliases: &["N3DM"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition W∪X∪Y into m triples (one from each set) each summing to B", fields: &[ diff --git a/src/models/misc/numerical_matching_with_target_sums.rs b/src/models/misc/numerical_matching_with_target_sums.rs index 377c4438c..fd9857399 100644 --- a/src/models/misc/numerical_matching_with_target_sums.rs +++ b/src/models/misc/numerical_matching_with_target_sums.rs @@ -18,6 +18,7 @@ inventory::submit! { display_name: "Numerical Matching with Target Sums", aliases: &["NMTS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition X∪Y into m pairs (one from X, one from Y) with pair sums matching targets", fields: &[ diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 3db688a38..f5ff161e7 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -6,7 +6,7 @@ //! both machine capacity (one job at a time per machine) and job capacity //! (each job uses at most one machine at a time) constraints. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,12 +17,10 @@ inventory::submit! { display_name: "Open Shop Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize the makespan of an open-shop schedule", - fields: &[ - FieldInfo { name: "num_machines", type_name: "usize", description: "Number of machines m" }, - FieldInfo { name: "processing_times", type_name: "Vec>", description: "processing_times[j][i] = processing time of job j on machine i (n x m)" }, - ], + fields: OpenShopSchedulingCreateSpec::FIELDS, } } @@ -69,6 +67,31 @@ pub struct OpenShopScheduling { processing_times: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OpenShopSchedulingCreateSpec { + /// Number of machines m. + num_processors: usize, + /// Processing time of each job on each machine (n x m). + processing_times: Vec>, +} + +impl TryFrom for OpenShopScheduling { + type Error = String; + + fn try_from(spec: OpenShopSchedulingCreateSpec) -> Result { + for (job, times) in spec.processing_times.iter().enumerate() { + if times.len() != spec.num_processors { + return Err(format!( + "processing_times[{job}] has {} entries, expected {}", + times.len(), + spec.num_processors + )); + } + } + Ok(Self::new(spec.num_processors, spec.processing_times)) + } +} + impl OpenShopScheduling { /// Create a new Open Shop Scheduling instance. /// @@ -222,7 +245,7 @@ impl Problem for OpenShopScheduling { } crate::declare_variants! { - default OpenShopScheduling => "factorial(num_jobs)^num_machines", + default OpenShopScheduling => "factorial(num_jobs)^num_machines" create OpenShopSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/optimum_communication_spanning_tree.rs b/src/models/misc/optimum_communication_spanning_tree.rs index 6f2729b9a..c354d8acf 100644 --- a/src/models/misc/optimum_communication_spanning_tree.rs +++ b/src/models/misc/optimum_communication_spanning_tree.rs @@ -5,7 +5,7 @@ //! minimizes the total communication cost: sum_{u>", description: "Symmetric weight matrix w(i,j)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Symmetric requirement matrix r(i,j)" }, - ], + fields: OptimumCommunicationSpanningTreeCreateSpec::FIELDS, } } @@ -72,6 +69,49 @@ pub struct OptimumCommunicationSpanningTree { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct OptimumCommunicationSpanningTreeCreateSpec { + /// Number of vertices. + num_vertices: usize, + /// Symmetric weight matrix; defaults to unit off-diagonal weights. + edge_weights: Option>>, + /// Symmetric communication requirement matrix. + requirements: Vec>, +} +impl TryFrom for OptimumCommunicationSpanningTree { + type Error = String; + fn try_from(spec: OptimumCommunicationSpanningTreeCreateSpec) -> Result { + let n = spec.num_vertices; + if n < 2 { + return Err("must have at least two vertices".to_string()); + } + let edge_weights = spec.edge_weights.unwrap_or_else(|| { + (0..n) + .map(|i| (0..n).map(|j| i32::from(i != j)).collect()) + .collect() + }); + for (name, matrix) in [ + ("edge_weights", &edge_weights), + ("requirements", &spec.requirements), + ] { + if matrix.len() != n || matrix.iter().any(|row| row.len() != n) { + return Err(format!("{name} must be a {n} x {n} matrix")); + } + for (i, row) in matrix.iter().enumerate() { + if row[i] != 0 { + return Err(format!("{name} diagonal must be zero")); + } + for (j, &value) in row.iter().enumerate().skip(i + 1) { + if value != matrix[j][i] || value < 0 { + return Err(format!("{name} must be symmetric and nonnegative")); + } + } + } + } + Ok(Self::new(edge_weights, spec.requirements)) + } +} + impl OptimumCommunicationSpanningTree { /// Create a new OptimumCommunicationSpanningTree instance. /// @@ -312,7 +352,7 @@ impl Problem for OptimumCommunicationSpanningTree { } crate::declare_variants! { - default OptimumCommunicationSpanningTree => "2^num_edges", + default OptimumCommunicationSpanningTree => "2^num_edges" create OptimumCommunicationSpanningTreeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/paintshop.rs b/src/models/misc/paintshop.rs index b144ced5b..bcf21dc9a 100644 --- a/src/models/misc/paintshop.rs +++ b/src/models/misc/paintshop.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Paint Shop", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize color changes in paint shop sequence", fields: &[ diff --git a/src/models/misc/partially_ordered_knapsack.rs b/src/models/misc/partially_ordered_knapsack.rs index 405dab450..57e9b8fcf 100644 --- a/src/models/misc/partially_ordered_knapsack.rs +++ b/src/models/misc/partially_ordered_knapsack.rs @@ -4,7 +4,7 @@ //! an item requires including all its predecessors (downward-closed set). //! NP-complete in the strong sense (Garey & Johnson, A6 MP12). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Partially Ordered Knapsack", aliases: &["POK"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Select items to maximize total value subject to precedence constraints and weight capacity", - fields: &[ - FieldInfo { name: "weights", type_name: "Vec", description: "Item weights w(u) for each item" }, - FieldInfo { name: "values", type_name: "Vec", description: "Item values v(u) for each item" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (a, b) meaning a must be included before b" }, - FieldInfo { name: "capacity", type_name: "i64", description: "Knapsack capacity B" }, - ], + fields: PartiallyOrderedKnapsackCreateSpec::FIELDS, } } @@ -76,6 +72,69 @@ pub struct PartiallyOrderedKnapsack { predecessors: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PartiallyOrderedKnapsackCreateSpec { + weights: Vec, + values: Vec, + precedences: Option>, + capacity: i64, +} + +impl TryFrom for PartiallyOrderedKnapsack { + type Error = String; + + fn try_from(spec: PartiallyOrderedKnapsackCreateSpec) -> Result { + if spec.weights.len() != spec.values.len() { + return Err("weights and values must have the same length".to_string()); + } + if spec.capacity < 0 { + return Err("capacity must be non-negative".to_string()); + } + if let Some((index, weight)) = spec + .weights + .iter() + .enumerate() + .find(|(_, weight)| **weight < 0) + { + return Err(format!( + "weight[{index}] must be non-negative, got {weight}" + )); + } + if let Some((index, value)) = spec + .values + .iter() + .enumerate() + .find(|(_, value)| **value < 0) + { + return Err(format!("value[{index}] must be non-negative, got {value}")); + } + let precedences = spec.precedences.unwrap_or_default(); + let num_items = spec.weights.len(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= num_items || succ >= num_items) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {num_items} items" + )); + } + let predecessors = Self::compute_predecessors(&precedences, num_items); + if let Some(item) = predecessors + .iter() + .enumerate() + .find_map(|(item, preds)| preds.contains(&item).then_some(item)) + { + return Err(format!("precedences contain a cycle involving item {item}")); + } + Ok(Self::new( + spec.weights, + spec.values, + precedences, + spec.capacity, + )) + } +} + impl Serialize for PartiallyOrderedKnapsack { fn serialize(&self, serializer: S) -> Result { PartiallyOrderedKnapsackRaw { @@ -266,7 +325,7 @@ impl Problem for PartiallyOrderedKnapsack { } crate::declare_variants! { - default PartiallyOrderedKnapsack => "2^num_items", + default PartiallyOrderedKnapsack => "2^num_items" create PartiallyOrderedKnapsackCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/partition.rs b/src/models/misc/partition.rs index 1f42dddb0..bf9ebd1d8 100644 --- a/src/models/misc/partition.rs +++ b/src/models/misc/partition.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Partition", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a multiset of positive integers can be partitioned into two subsets of equal sum", fields: &[ diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index e5c887e07..15f726e6b 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -4,7 +4,7 @@ //! deadline D, determine whether all tasks can be scheduled to meet D while //! respecting precedences. NP-complete via reduction from 3SAT (Ullman, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,14 +14,10 @@ inventory::submit! { display_name: "Precedence Constrained Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule unit-length tasks on m processors by deadline D respecting precedence constraints", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks n = |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of processors m" }, - FieldInfo { name: "deadline", type_name: "usize", description: "Global deadline D" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (i, j) meaning task i must finish before task j starts" }, - ], + fields: PrecedenceConstrainedSchedulingCreateSpec::FIELDS, } } @@ -58,6 +54,43 @@ pub struct PrecedenceConstrainedScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrecedenceConstrainedSchedulingCreateSpec { + num_tasks: usize, + num_processors: usize, + deadline: usize, + precedences: Option>, +} + +impl TryFrom for PrecedenceConstrainedScheduling { + type Error = String; + + fn try_from(spec: PrecedenceConstrainedSchedulingCreateSpec) -> Result { + if spec.num_tasks > 0 && spec.num_processors == 0 { + return Err("num_processors must be positive when there are tasks".to_string()); + } + if spec.num_tasks > 0 && spec.deadline == 0 { + return Err("deadline must be positive when there are tasks".to_string()); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(pred, succ)| pred >= spec.num_tasks || succ >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadline, + precedences, + )) + } +} + impl PrecedenceConstrainedScheduling { /// Create a new Precedence Constrained Scheduling instance. /// @@ -157,7 +190,7 @@ impl Problem for PrecedenceConstrainedScheduling { } crate::declare_variants! { - default PrecedenceConstrainedScheduling => "2^num_tasks", + default PrecedenceConstrainedScheduling => "2^num_tasks" create PrecedenceConstrainedSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/preemptive_scheduling.rs b/src/models/misc/preemptive_scheduling.rs index cad0d78ee..2533ef869 100644 --- a/src/models/misc/preemptive_scheduling.rs +++ b/src/models/misc/preemptive_scheduling.rs @@ -5,7 +5,7 @@ //! `m` identical processors, subject to precedence constraints. //! The goal is to minimize the makespan (latest completion time). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Preemptive Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Minimize makespan for preemptive parallel-processor scheduling with precedence constraints", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (pred, succ) — pred must finish before succ starts" }, - ], + fields: PreemptiveSchedulingCreateSpec::FIELDS, } } @@ -68,6 +65,23 @@ pub struct PreemptiveScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PreemptiveSchedulingCreateSpec { + lengths: Vec, + num_processors: usize, + precedences: Option>, +} + +impl TryFrom for PreemptiveScheduling { + type Error = String; + + fn try_from(spec: PreemptiveSchedulingCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, spec.num_processors, &precedences)?; + Ok(Self::new(spec.lengths, spec.num_processors, precedences)) + } +} + #[derive(Deserialize)] struct PreemptiveSchedulingSerde { lengths: Vec, @@ -244,7 +258,7 @@ impl Problem for PreemptiveScheduling { } crate::declare_variants! { - default PreemptiveScheduling => "2^(num_tasks * num_tasks)", + default PreemptiveScheduling => "2^(num_tasks * num_tasks)" create PreemptiveSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/production_planning.rs b/src/models/misc/production_planning.rs index 914df0d3b..375a3592b 100644 --- a/src/models/misc/production_planning.rs +++ b/src/models/misc/production_planning.rs @@ -5,7 +5,7 @@ //! exists a feasible production plan that satisfies all demand without //! backlogging and stays within budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Or; use serde::{Deserialize, Serialize}; @@ -16,17 +16,10 @@ inventory::submit! { display_name: "Production Planning", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a multi-period production plan can satisfy all demand within a cost bound", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of planning periods n" }, - FieldInfo { name: "demands", type_name: "Vec", description: "Demand r_i for each period" }, - FieldInfo { name: "capacities", type_name: "Vec", description: "Production capacity c_i for each period" }, - FieldInfo { name: "setup_costs", type_name: "Vec", description: "Setup cost b_i incurred when x_i > 0" }, - FieldInfo { name: "production_costs", type_name: "Vec", description: "Per-unit production cost coefficient p_i" }, - FieldInfo { name: "inventory_costs", type_name: "Vec", description: "Per-unit inventory cost coefficient h_i" }, - FieldInfo { name: "cost_bound", type_name: "u64", description: "Total cost bound B" }, - ], + fields: ProductionPlanningCreateSpec::FIELDS, } } @@ -42,6 +35,63 @@ pub struct ProductionPlanning { cost_bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ProductionPlanningCreateSpec { + /// Number of planning periods. + num_periods: usize, + /// Demand per period. + demands: Vec, + /// Production capacity per period. + capacities: Vec, + /// Setup cost per period. + setup_costs: Vec, + /// Per-unit production cost per period. + production_costs: Vec, + /// Per-unit inventory cost per period. + inventory_costs: Vec, + /// Total cost bound. + cost_bound: u64, +} +impl TryFrom for ProductionPlanning { + type Error = String; + fn try_from(spec: ProductionPlanningCreateSpec) -> Result { + if spec.num_periods == 0 { + return Err("num_periods must be positive".to_string()); + } + for (name, len) in [ + ("demands", spec.demands.len()), + ("capacities", spec.capacities.len()), + ("setup_costs", spec.setup_costs.len()), + ("production_costs", spec.production_costs.len()), + ("inventory_costs", spec.inventory_costs.len()), + ] { + if len != spec.num_periods { + return Err(format!( + "{name} has {len} entries, expected {}", + spec.num_periods + )); + } + } + if spec.capacities.iter().any(|&capacity| { + usize::try_from(capacity) + .ok() + .and_then(|v| v.checked_add(1)) + .is_none() + }) { + return Err("capacities must fit in usize for dims()".to_string()); + } + Ok(Self::new( + spec.num_periods, + spec.demands, + spec.capacities, + spec.setup_costs, + spec.production_costs, + spec.inventory_costs, + spec.cost_bound, + )) + } +} + impl ProductionPlanning { pub fn new( num_periods: usize, @@ -185,7 +235,7 @@ impl Problem for ProductionPlanning { } crate::declare_variants! { - default ProductionPlanning => "(max_capacity + 1)^num_periods", + default ProductionPlanning => "(max_capacity + 1)^num_periods" create ProductionPlanningCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/rectilinear_picture_compression.rs b/src/models/misc/rectilinear_picture_compression.rs index 13243e40d..50f662755 100644 --- a/src/models/misc/rectilinear_picture_compression.rs +++ b/src/models/misc/rectilinear_picture_compression.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Rectilinear Picture Compression", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Cover all 1-entries of a binary matrix with at most K axis-aligned all-1 rectangles", fields: &[ diff --git a/src/models/misc/register_sufficiency.rs b/src/models/misc/register_sufficiency.rs index 3530cddeb..e843fedcf 100644 --- a/src/models/misc/register_sufficiency.rs +++ b/src/models/misc/register_sufficiency.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Register Sufficiency", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether a DAG computation can be performed using K or fewer registers", fields: &[ diff --git a/src/models/misc/resource_constrained_scheduling.rs b/src/models/misc/resource_constrained_scheduling.rs index 4a714371f..c12a38e13 100644 --- a/src/models/misc/resource_constrained_scheduling.rs +++ b/src/models/misc/resource_constrained_scheduling.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Resource Constrained Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule unit-length tasks on m processors with resource constraints and a deadline", fields: &[ diff --git a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs index 15c6d1895..46fbb2bfe 100644 --- a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -6,7 +6,7 @@ //! completion time. Within each processor, tasks are ordered by Smith's //! rule (non-decreasing length-to-weight ratio). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,13 +17,10 @@ inventory::submit! { display_name: "Scheduling to Minimize Weighted Completion Time", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign tasks to processors to minimize total weighted completion time (Smith's rule ordering)", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - ], + fields: SchedulingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -65,6 +62,34 @@ pub struct SchedulingToMinimizeWeightedCompletionTime { num_processors: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingToMinimizeWeightedCompletionTimeCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Number of identical processors. + num_processors: usize, +} +impl TryFrom + for SchedulingToMinimizeWeightedCompletionTime +{ + type Error = String; + fn try_from( + spec: SchedulingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + if spec.num_processors == 0 { + return Err("num_processors must be positive".to_string()); + } + let count = spec.lengths.len(); + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.num_processors)) + } +} + fn serialize_num_processors(v: &usize, s: S) -> Result { s.serialize_u64(*v as u64) } @@ -222,7 +247,7 @@ impl Problem for SchedulingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks", + default SchedulingToMinimizeWeightedCompletionTime => "num_processors^num_tasks" create SchedulingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/scheduling_with_individual_deadlines.rs b/src/models/misc/scheduling_with_individual_deadlines.rs index 391ca772b..e98cb534e 100644 --- a/src/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/models/misc/scheduling_with_individual_deadlines.rs @@ -4,7 +4,7 @@ //! determine whether they can be scheduled on `m` identical processors so that //! every task finishes by its own deadline. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Scheduling With Individual Deadlines", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether unit-length tasks can be scheduled on m processors while meeting individual deadlines", - fields: &[ - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "num_processors", type_name: "usize", description: "Number of identical processors m" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SchedulingWithIndividualDeadlinesCreateSpec::FIELDS, } } @@ -40,6 +36,46 @@ pub struct SchedulingWithIndividualDeadlines { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SchedulingWithIndividualDeadlinesCreateSpec { + /// Number of tasks. + num_tasks: usize, + /// Number of identical processors. + num_processors: usize, + /// Deadline for each task. + deadlines: Vec, + /// Precedence pairs. + precedences: Option>, +} +impl TryFrom for SchedulingWithIndividualDeadlines { + type Error = String; + fn try_from(spec: SchedulingWithIndividualDeadlinesCreateSpec) -> Result { + if spec.deadlines.len() != spec.num_tasks { + return Err(format!( + "deadlines has {} entries, expected {}", + spec.deadlines.len(), + spec.num_tasks + )); + } + let precedences = spec.precedences.unwrap_or_default(); + if let Some(&(pred, succ)) = precedences + .iter() + .find(|&&(p, s)| p >= spec.num_tasks || s >= spec.num_tasks) + { + return Err(format!( + "precedence ({pred}, {succ}) is out of range for {} tasks", + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_tasks, + spec.num_processors, + spec.deadlines, + precedences, + )) + } +} + impl SchedulingWithIndividualDeadlines { pub fn new( num_tasks: usize, @@ -145,7 +181,7 @@ impl Problem for SchedulingWithIndividualDeadlines { } crate::declare_variants! { - default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks", + default SchedulingWithIndividualDeadlines => "max_deadline^num_tasks" create SchedulingWithIndividualDeadlinesCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index 46627bfcf..ada08db48 100644 --- a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -4,7 +4,7 @@ //! a valid one-machine schedule that minimizes the maximum cumulative cost //! over all prefixes. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::de::Error as _; use serde::{Deserialize, Serialize}; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Maximum Cumulative Cost", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule tasks with precedence constraints to minimize the maximum cumulative cost prefix", - fields: &[ - FieldInfo { name: "costs", type_name: "Vec", description: "Task costs in schedule order-independent indexing" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingCumulativeCostCreateSpec::FIELDS, } } @@ -40,6 +38,30 @@ pub struct SequencingToMinimizeMaximumCumulativeCost { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingCumulativeCostCreateSpec { + /// Task costs. + #[create(codec = "comma-separated")] + costs: Vec, + /// Precedence arcs; omitted means no constraints. + #[create(codec = "arc-list")] + precedences: Option>, +} + +impl TryFrom for SequencingToMinimizeMaximumCumulativeCost { + type Error = String; + fn try_from(spec: SequencingCumulativeCostCreateSpec) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + if let Some(message) = precedence_validation_error(&precedences, spec.costs.len()) { + return Err(message); + } + Ok(Self { + costs: spec.costs, + precedences, + }) + } +} + #[derive(Debug, Deserialize)] struct SequencingToMinimizeMaximumCumulativeCostUnchecked { costs: Vec, @@ -165,7 +187,7 @@ impl Problem for SequencingToMinimizeMaximumCumulativeCost { } crate::declare_variants! { - default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)", + default SequencingToMinimizeMaximumCumulativeCost => "factorial(num_tasks)" create SequencingCumulativeCostCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index 379dac87d..2b16cf9d4 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -4,7 +4,7 @@ //! Garey & Johnson, 1979) where tasks with processing times, weights, //! and deadlines must be scheduled to minimize the total weight of tardy tasks. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -15,13 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Tardy Task Weight", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule tasks with lengths, weights, and deadlines to minimize total weight of tardy tasks", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - ], + fields: SequencingToMinimizeTardyTaskWeightCreateSpec::FIELDS, } } @@ -44,6 +41,32 @@ pub struct SequencingToMinimizeTardyTaskWeight { deadlines: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeTardyTaskWeightCreateSpec { + /// Processing time for each task. + lengths: Vec, + /// Task weights; defaults to one per task. + weights: Option>, + /// Deadline for each task. + deadlines: Vec, +} +impl TryFrom + for SequencingToMinimizeTardyTaskWeight +{ + type Error = String; + fn try_from(spec: SequencingToMinimizeTardyTaskWeightCreateSpec) -> Result { + let count = spec.lengths.len(); + if spec.deadlines.len() != count { + return Err("deadlines length must equal lengths length".to_string()); + } + let weights = spec.weights.unwrap_or_else(|| vec![1; count]); + if weights.len() != count { + return Err("weights length must equal lengths length".to_string()); + } + Ok(Self::new(spec.lengths, weights, spec.deadlines)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeTardyTaskWeightSerde { lengths: Vec, @@ -166,7 +189,7 @@ impl Problem for SequencingToMinimizeTardyTaskWeight { } crate::declare_variants! { - default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)", + default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)" create SequencingToMinimizeTardyTaskWeightCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 6a62e11eb..d02e32dd9 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -10,7 +10,7 @@ //! Optimal Linear Arrangement, which uses zero-length edge jobs instead //! of padding them to unit length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -21,13 +21,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Weighted Completion Time", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule tasks with lengths, weights, and precedence constraints to minimize total weighted completion time", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing time l(t) for each task" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, - FieldInfo { name: "precedences", type_name: "Vec<(usize, usize)>", description: "Precedence pairs (predecessor, successor)" }, - ], + fields: SequencingToMinimizeWeightedCompletionTimeCreateSpec::FIELDS, } } @@ -46,6 +43,27 @@ pub struct SequencingToMinimizeWeightedCompletionTime { precedences: Vec<(usize, usize)>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedCompletionTimeCreateSpec { + lengths: Vec, + weights: Vec, + precedences: Option>, +} + +impl TryFrom + for SequencingToMinimizeWeightedCompletionTime +{ + type Error = String; + + fn try_from( + spec: SequencingToMinimizeWeightedCompletionTimeCreateSpec, + ) -> Result { + let precedences = spec.precedences.unwrap_or_default(); + Self::validate(&spec.lengths, &spec.weights, &precedences)?; + Ok(Self::new(spec.lengths, spec.weights, precedences)) + } +} + #[derive(Deserialize)] struct SequencingToMinimizeWeightedCompletionTimeSerde { lengths: Vec, @@ -215,7 +233,7 @@ impl Problem for SequencingToMinimizeWeightedCompletionTime { } crate::declare_variants! { - default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)", + default SequencingToMinimizeWeightedCompletionTime => "factorial(num_tasks)" create SequencingToMinimizeWeightedCompletionTimeCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 946ae2140..c3c5edc3f 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -5,7 +5,7 @@ //! total weighted tardiness is at most a given bound. //! Corresponds to scheduling notation `1 || sum w_j T_j`. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -15,14 +15,10 @@ inventory::submit! { display_name: "Sequencing to Minimize Weighted Tardiness", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule jobs on one machine so total weighted tardiness is at most K", - fields: &[ - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing times l_j for each job" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Tardiness weights w_j for each job" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines d_j for each job" }, - FieldInfo { name: "bound", type_name: "u64", description: "Upper bound K on total weighted tardiness" }, - ], + fields: SequencingToMinimizeWeightedTardinessCreateSpec::FIELDS, } } @@ -63,6 +59,39 @@ pub struct SequencingToMinimizeWeightedTardiness { bound: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingToMinimizeWeightedTardinessCreateSpec { + /// Processing times for each job. + lengths: Vec, + /// Tardiness weights for each job. + weights: Vec, + /// Deadlines for each job. + deadlines: Vec, + /// Upper bound on total weighted tardiness. + bound: u64, +} +impl TryFrom + for SequencingToMinimizeWeightedTardiness +{ + type Error = String; + fn try_from( + spec: SequencingToMinimizeWeightedTardinessCreateSpec, + ) -> Result { + if spec.lengths.len() != spec.weights.len() { + return Err("weights length must equal lengths length".to_string()); + } + if spec.lengths.len() != spec.deadlines.len() { + return Err("deadlines length must equal lengths length".to_string()); + } + Ok(Self::new( + spec.lengths, + spec.weights, + spec.deadlines, + spec.bound, + )) + } +} + impl SequencingToMinimizeWeightedTardiness { /// Create a new weighted tardiness scheduling instance. /// @@ -159,7 +188,7 @@ impl Problem for SequencingToMinimizeWeightedTardiness { } crate::declare_variants! { - default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)", + default SequencingToMinimizeWeightedTardiness => "factorial(num_tasks)" create SequencingToMinimizeWeightedTardinessCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs index 3b14e6bcf..98f67f2e8 100644 --- a/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs +++ b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Sequencing with Deadlines and Set-Up Times", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Determine whether all tasks can be scheduled on a single machine by their deadlines given compiler-switch setup penalties", fields: &[ diff --git a/src/models/misc/sequencing_with_release_times_and_deadlines.rs b/src/models/misc/sequencing_with_release_times_and_deadlines.rs index 35c7c9607..b418549ea 100644 --- a/src/models/misc/sequencing_with_release_times_and_deadlines.rs +++ b/src/models/misc/sequencing_with_release_times_and_deadlines.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Sequencing with Release Times and Deadlines", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Single-machine scheduling feasibility: can all tasks be scheduled within their release-deadline windows without overlap?", fields: &[ diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index cc391444a..53d4d4462 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -4,7 +4,7 @@ //! determine whether all tasks can be scheduled non-overlappingly such that each //! task runs entirely within its allowed time window. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Sequencing Within Intervals", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Schedule tasks non-overlappingly within their time windows", - fields: &[ - FieldInfo { name: "release_times", type_name: "Vec", description: "Release time r(t) for each task" }, - FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, - FieldInfo { name: "lengths", type_name: "Vec", description: "Processing length l(t) for each task" }, - ], + fields: SequencingWithinIntervalsCreateSpec::FIELDS, } } @@ -63,6 +60,36 @@ pub struct SequencingWithinIntervals { lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SequencingWithinIntervalsCreateSpec { + /// Release times. + release_times: Vec, + /// Deadlines. + deadlines: Vec, + /// Processing lengths. + lengths: Vec, +} +impl TryFrom for SequencingWithinIntervals { + type Error = String; + fn try_from(spec: SequencingWithinIntervalsCreateSpec) -> Result { + if spec.release_times.len() != spec.deadlines.len() { + return Err("release_times and deadlines must have the same length".to_string()); + } + if spec.release_times.len() != spec.lengths.len() { + return Err("release_times and lengths must have the same length".to_string()); + } + for index in 0..spec.release_times.len() { + let finish = spec.release_times[index] + .checked_add(spec.lengths[index]) + .ok_or_else(|| format!("task {index} release time plus length overflows u64"))?; + if finish > spec.deadlines[index] { + return Err(format!("task {index} has an empty time window")); + } + } + Ok(Self::new(spec.release_times, spec.deadlines, spec.lengths)) + } +} + impl SequencingWithinIntervals { /// Create a new SequencingWithinIntervals problem. /// @@ -173,7 +200,7 @@ impl Problem for SequencingWithinIntervals { } crate::declare_variants! { - default SequencingWithinIntervals => "2^num_tasks", + default SequencingWithinIntervals => "2^num_tasks" create SequencingWithinIntervalsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/shortest_common_supersequence.rs b/src/models/misc/shortest_common_supersequence.rs index b6d6bafee..cc878de3c 100644 --- a/src/models/misc/shortest_common_supersequence.rs +++ b/src/models/misc/shortest_common_supersequence.rs @@ -12,7 +12,7 @@ //! lengths (the worst case where no overlap exists). This problem is NP-hard //! (Maier, 1978). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -23,13 +23,10 @@ inventory::submit! { display_name: "Shortest Common Supersequence", aliases: &["SCS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a shortest common supersequence for a set of strings", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the alphabet" }, - FieldInfo { name: "strings", type_name: "Vec>", description: "Input strings over the alphabet {0, ..., alphabet_size-1}" }, - FieldInfo { name: "max_length", type_name: "usize", description: "Maximum possible supersequence length (sum of all string lengths)" }, - ], + fields: ShortestCommonSupersequenceCreateSpec::FIELDS, } } @@ -65,6 +62,48 @@ pub struct ShortestCommonSupersequence { max_length: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ShortestCommonSupersequenceCreateSpec { + /// Input strings; the alphabet and maximum length are inferred from them. + #[create(codec = "semicolon-separated")] + strings: Vec>, +} + +impl TryFrom for ShortestCommonSupersequence { + type Error = String; + + fn try_from(spec: ShortestCommonSupersequenceCreateSpec) -> Result { + if spec.strings.is_empty() { + return Err("must have at least one string".to_string()); + } + + let alphabet_size = spec + .strings + .iter() + .flatten() + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let max_length = spec.strings.iter().try_fold(0_usize, |total, string| { + total + .checked_add(string.len()) + .ok_or_else(|| "maximum supersequence length overflows usize".to_string()) + })?; + + Ok(Self { + alphabet_size, + strings: spec.strings, + max_length, + }) + } +} + impl ShortestCommonSupersequence { /// Create a new ShortestCommonSupersequence instance. /// @@ -179,7 +218,7 @@ impl Problem for ShortestCommonSupersequence { } crate::declare_variants! { - default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length", + default ShortestCommonSupersequence => "(alphabet_size + 1) ^ max_length" create ShortestCommonSupersequenceCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/shortest_common_superstring.rs b/src/models/misc/shortest_common_superstring.rs index 9aabc97d2..82c8ec804 100644 --- a/src/models/misc/shortest_common_superstring.rs +++ b/src/models/misc/shortest_common_superstring.rs @@ -27,6 +27,7 @@ inventory::submit! { display_name: "Shortest Common Superstring", aliases: &["SCSS"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a shortest string that contains every input string as a contiguous substring", fields: &[ diff --git a/src/models/misc/square_tiling.rs b/src/models/misc/square_tiling.rs index fe27d4a3a..e61313878 100644 --- a/src/models/misc/square_tiling.rs +++ b/src/models/misc/square_tiling.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "Square Tiling", aliases: &["WangTiling"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Place colored square tiles on an N x N grid with matching edge colors", fields: &[ diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index e1b835d28..bcc136adb 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -4,7 +4,7 @@ //! walk that traverses every required arc in some order and minimizes the //! total route length. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -17,15 +17,10 @@ inventory::submit! { display_name: "Stacker Crane", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a closed walk that traverses each required directed arc and minimizes total length", - fields: &[ - FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices in the mixed graph" }, - FieldInfo { name: "arcs", type_name: "Vec<(usize, usize)>", description: "Required directed arcs that must be traversed" }, - FieldInfo { name: "edges", type_name: "Vec<(usize, usize)>", description: "Undirected edges available for connector paths" }, - FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Nonnegative lengths of the required directed arcs" }, - FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Nonnegative lengths of the undirected connector edges" }, - ], + fields: StackerCraneCreateSpec::FIELDS, } } @@ -46,6 +41,83 @@ pub struct StackerCrane { edge_lengths: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StackerCraneCreateSpec { + /// Required directed arcs. + #[create(codec = "arc-list")] + arcs: Vec<(usize, usize)>, + /// Undirected connector edges. + #[create(name = "graph", codec = "edge-list")] + edges: Vec<(usize, usize)>, + /// Vertex count, needed to preserve isolated vertices. + num_vertices: Option, + /// Required-arc lengths; defaults to one per arc. + #[create(codec = "comma-separated")] + arc_lengths: Option>, + /// Connector-edge lengths; defaults to one per edge. + #[create(codec = "comma-separated")] + edge_lengths: Option>, +} + +impl TryFrom for StackerCrane { + type Error = String; + + fn try_from(spec: StackerCraneCreateSpec) -> Result { + if spec.arcs.is_empty() { + return Err("arcs must be non-empty".to_string()); + } + if spec.edges.is_empty() && spec.num_vertices.is_none() { + return Err("num_vertices is required for an empty graph".to_string()); + } + for (index, &(u, v)) in spec.edges.iter().enumerate() { + if u == v { + return Err(format!("graph edge {index} is a self-loop at vertex {u}")); + } + } + let inferred_arcs = inferred_vertex_count(&spec.arcs)?; + let inferred_edges = inferred_vertex_count(&spec.edges)?; + let num_vertices = match spec.num_vertices { + Some(count) => count, + None if inferred_arcs == inferred_edges => inferred_arcs, + None => { + return Err(format!( + "directed and undirected inputs infer different vertex counts ({inferred_arcs} and {inferred_edges}); provide num_vertices" + )) + } + }; + if num_vertices < inferred_arcs || num_vertices < inferred_edges { + return Err(format!( + "num_vertices {num_vertices} is too small for the provided endpoints" + )); + } + let arc_lengths = spec.arc_lengths.unwrap_or_else(|| vec![1; spec.arcs.len()]); + let edge_lengths = spec + .edge_lengths + .unwrap_or_else(|| vec![1; spec.edges.len()]); + Self::try_new( + num_vertices, + spec.arcs, + spec.edges, + arc_lengths, + edge_lengths, + ) + } +} + +fn inferred_vertex_count(pairs: &[(usize, usize)]) -> Result { + pairs + .iter() + .flat_map(|&(u, v)| [u, v]) + .max() + .map(|vertex| { + vertex + .checked_add(1) + .ok_or("vertex count overflows usize".to_string()) + }) + .transpose() + .map(|count| count.unwrap_or(0)) +} + impl StackerCrane { /// Create a new Stacker Crane instance. /// @@ -267,7 +339,7 @@ impl Problem for StackerCrane { } crate::declare_variants! { - default StackerCrane => "num_vertices^2 * 2^num_arcs", + default StackerCrane => "num_vertices^2 * 2^num_arcs" create StackerCraneCreateSpec, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/models/misc/staff_scheduling.rs b/src/models/misc/staff_scheduling.rs index 7db6f75be..eae9d161b 100644 --- a/src/models/misc/staff_scheduling.rs +++ b/src/models/misc/staff_scheduling.rs @@ -4,7 +4,7 @@ //! worker budget, determine whether workers can be assigned to schedules so that //! all requirements are met without exceeding the budget. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,14 +14,10 @@ inventory::submit! { display_name: "Staff Scheduling", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign workers to schedule patterns to satisfy per-period staffing requirements within a worker budget", - fields: &[ - FieldInfo { name: "shifts_per_schedule", type_name: "usize", description: "Required number of active periods in each schedule pattern" }, - FieldInfo { name: "schedules", type_name: "Vec>", description: "Binary schedule patterns available to workers" }, - FieldInfo { name: "requirements", type_name: "Vec", description: "Minimum staffing requirement for each period" }, - FieldInfo { name: "num_workers", type_name: "u64", description: "Maximum number of workers available" }, - ], + fields: StaffSchedulingCreateSpec::FIELDS, } } @@ -38,6 +34,50 @@ pub struct StaffScheduling { num_workers: u64, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StaffSchedulingCreateSpec { + /// Required number of active periods in each schedule pattern. + k: usize, + /// Binary schedule patterns available to workers. + schedules: Vec>, + /// Minimum staffing requirement for each period. + requirements: Vec, + /// Maximum number of workers available. + num_workers: u64, +} + +impl TryFrom for StaffScheduling { + type Error = String; + + fn try_from(spec: StaffSchedulingCreateSpec) -> Result { + if spec.num_workers >= usize::MAX as u64 { + return Err("num_workers must be smaller than usize::MAX".to_string()); + } + for (schedule_index, schedule) in spec.schedules.iter().enumerate() { + if schedule.len() != spec.requirements.len() { + return Err(format!( + "schedules[{schedule_index}] has {} periods, expected {}", + schedule.len(), + spec.requirements.len() + )); + } + let active_periods = schedule.iter().filter(|&&active| active).count(); + if active_periods != spec.k { + return Err(format!( + "schedules[{schedule_index}] has {active_periods} active periods, expected {}", + spec.k + )); + } + } + Ok(Self::new( + spec.k, + spec.schedules, + spec.requirements, + spec.num_workers, + )) + } +} + impl StaffScheduling { /// Create a new Staff Scheduling instance. /// @@ -173,7 +213,7 @@ impl Problem for StaffScheduling { } crate::declare_variants! { - default StaffScheduling => "(num_workers + 1)^num_schedules", + default StaffScheduling => "(num_workers + 1)^num_schedules" create StaffSchedulingCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/string_to_string_correction.rs b/src/models/misc/string_to_string_correction.rs index 30f02a3d0..0e9df2528 100644 --- a/src/models/misc/string_to_string_correction.rs +++ b/src/models/misc/string_to_string_correction.rs @@ -14,7 +14,7 @@ //! //! This problem is NP-complete (Wagner, 1975). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -24,14 +24,10 @@ inventory::submit! { display_name: "String-to-String Correction", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Derive target string from source using at most K deletions and adjacent swaps", - fields: &[ - FieldInfo { name: "alphabet_size", type_name: "usize", description: "Size of the finite alphabet" }, - FieldInfo { name: "source", type_name: "Vec", description: "Source string (symbol indices)" }, - FieldInfo { name: "target", type_name: "Vec", description: "Target string (symbol indices)" }, - FieldInfo { name: "bound", type_name: "usize", description: "Maximum number of operations allowed" }, - ], + fields: StringToStringCorrectionCreateSpec::FIELDS, } } @@ -77,6 +73,59 @@ pub struct StringToStringCorrection { bound: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct StringToStringCorrectionCreateSpec { + /// Optional alphabet size; omitted values are inferred from both strings. + alphabet_size: Option, + /// Source string. + #[create(codec = "comma-separated")] + source_string: Vec, + /// Target string. + #[create(codec = "comma-separated")] + target_string: Vec, + /// Maximum number of correction operations. + bound: usize, +} + +impl TryFrom for StringToStringCorrection { + type Error = String; + + fn try_from(spec: StringToStringCorrectionCreateSpec) -> Result { + let inferred_alphabet_size = spec + .source_string + .iter() + .chain(&spec.target_string) + .copied() + .max() + .map(|symbol| { + symbol + .checked_add(1) + .ok_or_else(|| "inferred alphabet size overflows usize".to_string()) + }) + .transpose()? + .unwrap_or(0); + let alphabet_size = spec.alphabet_size.unwrap_or(inferred_alphabet_size); + if alphabet_size < inferred_alphabet_size { + return Err(format!( + "alphabet size {alphabet_size} is smaller than inferred alphabet size {inferred_alphabet_size}" + )); + } + if alphabet_size == 0 && (!spec.source_string.is_empty() || !spec.target_string.is_empty()) + { + return Err( + "alphabet size must be positive when either string is non-empty".to_string(), + ); + } + + Ok(Self { + alphabet_size, + source: spec.source_string, + target: spec.target_string, + bound: spec.bound, + }) + } +} + impl StringToStringCorrection { /// Create a new StringToStringCorrection instance. /// @@ -191,7 +240,7 @@ impl Problem for StringToStringCorrection { } crate::declare_variants! { - default StringToStringCorrection => "(2 * source_length + 1) ^ bound", + default StringToStringCorrection => "(2 * source_length + 1) ^ bound" create StringToStringCorrectionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/subset_product.rs b/src/models/misc/subset_product.rs index b82136ed4..478cfc203 100644 --- a/src/models/misc/subset_product.rs +++ b/src/models/misc/subset_product.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Subset Product", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a subset of positive integers whose product equals exactly a target value", fields: &[ diff --git a/src/models/misc/subset_sum.rs b/src/models/misc/subset_sum.rs index d0346613f..a151d418d 100644 --- a/src/models/misc/subset_sum.rs +++ b/src/models/misc/subset_sum.rs @@ -19,6 +19,7 @@ inventory::submit! { display_name: "Subset Sum", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Find a subset of positive integers that sums to exactly a target value", fields: &[ diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs index 050042bd1..e93e75537 100644 --- a/src/models/misc/sum_of_squares_partition.rs +++ b/src/models/misc/sum_of_squares_partition.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Sum of Squares Partition", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition positive integers into K groups minimizing the sum of squared group sums", fields: &[ diff --git a/src/models/misc/three_partition.rs b/src/models/misc/three_partition.rs index 9131544d4..9f903de08 100644 --- a/src/models/misc/three_partition.rs +++ b/src/models/misc/three_partition.rs @@ -3,7 +3,7 @@ //! Given 3m positive integers that each lie strictly between B/4 and B/2, //! determine whether they can be partitioned into m triples that all sum to B. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Or; use serde::de::Error as _; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "3-Partition", aliases: &["3Partition", "3-Partition"], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Partition 3m bounded positive integers into m triples whose sums all equal B", - fields: &[ - FieldInfo { name: "sizes", type_name: "Vec", description: "Positive integer sizes s(a) for each element a in A" }, - FieldInfo { name: "bound", type_name: "u64", description: "Target sum B for each triple" }, - ], + fields: ThreePartitionCreateSpec::FIELDS, } } @@ -135,19 +133,30 @@ impl ThreePartition { } } -#[derive(Deserialize)] -struct ThreePartitionData { +#[derive(Deserialize, crate::CreateSpec)] +struct ThreePartitionCreateSpec { + /// Positive integer sizes for the elements to partition. + #[create(codec = "comma-separated")] sizes: Vec, + /// Target sum for each triple. bound: u64, } +impl TryFrom for ThreePartition { + type Error = String; + + fn try_from(spec: ThreePartitionCreateSpec) -> Result { + Self::try_new(spec.sizes, spec.bound) + } +} + impl<'de> Deserialize<'de> for ThreePartition { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let data = ThreePartitionData::deserialize(deserializer)?; - Self::try_new(data.sizes, data.bound).map_err(D::Error::custom) + let spec = ThreePartitionCreateSpec::deserialize(deserializer)?; + Self::try_from(spec).map_err(D::Error::custom) } } @@ -176,7 +185,7 @@ impl Problem for ThreePartition { } crate::declare_variants! { - default ThreePartition => "3^num_elements", + default ThreePartition => "3^num_elements" create ThreePartitionCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index ba290ce40..627dc1db7 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -4,7 +4,7 @@ //! respecting availability, per-period exclusivity, and exact pairwise work //! requirements. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,16 +14,10 @@ inventory::submit! { display_name: "Timetable Design", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Misc, module_path: module_path!(), description: "Assign craftsmen to tasks over work periods subject to availability and exact pairwise requirements", - fields: &[ - FieldInfo { name: "num_periods", type_name: "usize", description: "Number of work periods |H|" }, - FieldInfo { name: "num_craftsmen", type_name: "usize", description: "Number of craftsmen |C|" }, - FieldInfo { name: "num_tasks", type_name: "usize", description: "Number of tasks |T|" }, - FieldInfo { name: "craftsman_avail", type_name: "Vec>", description: "Availability matrix A(c) for craftsmen (|C| x |H|)" }, - FieldInfo { name: "task_avail", type_name: "Vec>", description: "Availability matrix A(t) for tasks (|T| x |H|)" }, - FieldInfo { name: "requirements", type_name: "Vec>", description: "Required work periods R(c,t) for each craftsman-task pair (|C| x |T|)" }, - ], + fields: TimetableDesignCreateSpec::FIELDS, } } @@ -42,6 +36,92 @@ pub struct TimetableDesign { requirements: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct TimetableDesignCreateSpec { + /// Number of work periods. + num_periods: usize, + /// Number of craftsmen. + num_craftsmen: usize, + /// Number of tasks. + num_tasks: usize, + /// Craftsman availability matrix. + craftsman_avail: Vec>, + /// Task availability matrix. + task_avail: Vec>, + /// Required work periods for each craftsman-task pair. + requirements: Vec>, +} +impl TryFrom for TimetableDesign { + type Error = String; + fn try_from(spec: TimetableDesignCreateSpec) -> Result { + if spec.craftsman_avail.len() != spec.num_craftsmen { + return Err(format!( + "craftsman_avail has {} rows, expected {}", + spec.craftsman_avail.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .craftsman_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "craftsman_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.task_avail.len() != spec.num_tasks { + return Err(format!( + "task_avail has {} rows, expected {}", + spec.task_avail.len(), + spec.num_tasks + )); + } + if let Some((index, row)) = spec + .task_avail + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_periods) + { + return Err(format!( + "task_avail row {index} has {} periods, expected {}", + row.len(), + spec.num_periods + )); + } + if spec.requirements.len() != spec.num_craftsmen { + return Err(format!( + "requirements has {} rows, expected {}", + spec.requirements.len(), + spec.num_craftsmen + )); + } + if let Some((index, row)) = spec + .requirements + .iter() + .enumerate() + .find(|(_, row)| row.len() != spec.num_tasks) + { + return Err(format!( + "requirements row {index} has {} tasks, expected {}", + row.len(), + spec.num_tasks + )); + } + Ok(Self::new( + spec.num_periods, + spec.num_craftsmen, + spec.num_tasks, + spec.craftsman_avail, + spec.task_avail, + spec.requirements, + )) + } +} + impl TimetableDesign { /// Create a new Timetable Design instance. /// @@ -158,7 +238,6 @@ impl TimetableDesign { ((craftsman * self.num_tasks) + task) * self.num_periods + period } - #[cfg(feature = "ilp-solver")] pub(crate) fn solve_via_required_assignments(&self) -> Option> { #[derive(Clone)] struct PairRequirement { @@ -356,7 +435,7 @@ impl Problem for TimetableDesign { } crate::declare_variants! { - default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)", + default TimetableDesign => "2^(num_craftsmen * num_tasks * num_periods)" create TimetableDesignCreateSpec, } #[cfg(any(test, feature = "example-db"))] diff --git a/src/models/set/comparative_containment.rs b/src/models/set/comparative_containment.rs index c1ad0629f..07e06af21 100644 --- a/src/models/set/comparative_containment.rs +++ b/src/models/set/comparative_containment.rs @@ -4,7 +4,7 @@ //! whether there exists a subset of the universe whose containment weight //! in the first family is at least its containment weight in the second. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{One, WeightElement}; use num_traits::Zero; @@ -23,15 +23,10 @@ inventory::submit! { display_name: "Comparative Containment", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["One", "i32", "f64"])], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Compare containment-weight sums for two set families over a shared universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe X" }, - FieldInfo { name: "r_sets", type_name: "Vec>", description: "First set family R over X" }, - FieldInfo { name: "s_sets", type_name: "Vec>", description: "Second set family S over X" }, - FieldInfo { name: "r_weights", type_name: "Vec", description: "Positive weights for sets in R" }, - FieldInfo { name: "s_weights", type_name: "Vec", description: "Positive weights for sets in S" }, - ], + fields: ComparativeContainmentI32CreateSpec::FIELDS, } } @@ -50,6 +45,88 @@ pub struct ComparativeContainment { s_weights: Vec, } +macro_rules! comparative_containment_create_spec { + ($name:ident, $weight:ty, $one:expr) => { + #[derive(Debug, Deserialize, crate::CreateSpec)] + struct $name { + /// Size of the common universe. + universe_size: usize, + /// First set family. + #[create(codec = "semicolon-separated")] + r_sets: Vec>, + /// Second set family. + #[create(codec = "semicolon-separated")] + s_sets: Vec>, + /// Positive weights for the first family; defaults to one. + #[create(codec = "comma-separated")] + r_weights: Option>, + /// Positive weights for the second family; defaults to one. + #[create(codec = "comma-separated")] + s_weights: Option>, + } + + impl TryFrom<$name> for ComparativeContainment<$weight> { + type Error = String; + fn try_from(spec: $name) -> Result { + validate_create_set_family("R", spec.universe_size, &spec.r_sets)?; + validate_create_set_family("S", spec.universe_size, &spec.s_sets)?; + let r_weights = spec + .r_weights + .unwrap_or_else(|| vec![$one; spec.r_sets.len()]); + let s_weights = spec + .s_weights + .unwrap_or_else(|| vec![$one; spec.s_sets.len()]); + validate_create_weights("R", spec.r_sets.len(), &r_weights)?; + validate_create_weights("S", spec.s_sets.len(), &s_weights)?; + Ok(ComparativeContainment { + universe_size: spec.universe_size, + r_sets: spec.r_sets, + s_sets: spec.s_sets, + r_weights, + s_weights, + }) + } + } + }; +} + +fn validate_create_set_family( + label: &str, + universe_size: usize, + sets: &[Vec], +) -> Result<(), String> { + for (set_index, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe_size { + return Err(format!("{label} set {set_index} contains element {element} outside universe of size {universe_size}")); + } + } + } + Ok(()) +} + +fn validate_create_weights( + label: &str, + count: usize, + weights: &[W], +) -> Result<(), String> { + if weights.len() != count { + return Err(format!("number of {label} sets and weights must match")); + } + for (index, weight) in weights.iter().enumerate() { + if weight.to_sum().partial_cmp(&W::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err(format!( + "{label} weight at index {index} must be finite and positive" + )); + } + } + Ok(()) +} + +comparative_containment_create_spec!(ComparativeContainmentI32CreateSpec, i32, 1_i32); +comparative_containment_create_spec!(ComparativeContainmentF64CreateSpec, f64, 1.0_f64); +comparative_containment_create_spec!(ComparativeContainmentOneCreateSpec, One, One); + impl ComparativeContainment { /// Create a new instance with unit weights. pub fn new(universe_size: usize, r_sets: Vec>, s_sets: Vec>) -> Self @@ -200,9 +277,9 @@ where } crate::declare_variants! { - ComparativeContainment => "2^universe_size", - default ComparativeContainment => "2^universe_size", - ComparativeContainment => "2^universe_size", + ComparativeContainment => "2^universe_size" create ComparativeContainmentOneCreateSpec, + default ComparativeContainment => "2^universe_size" create ComparativeContainmentI32CreateSpec, + ComparativeContainment => "2^universe_size" create ComparativeContainmentF64CreateSpec, } fn validate_set_family(label: &str, universe_size: usize, sets: &[Vec]) { diff --git a/src/models/set/consecutive_sets.rs b/src/models/set/consecutive_sets.rs index 1e50f29dc..6d354b3b4 100644 --- a/src/models/set/consecutive_sets.rs +++ b/src/models/set/consecutive_sets.rs @@ -16,6 +16,7 @@ inventory::submit! { display_name: "Consecutive Sets", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if a string exists where each subset's elements appear consecutively", fields: &[ diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index c65dc0c67..9cc04deff 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -4,7 +4,7 @@ //! subsets of X, determine if C contains an exact cover -- a subcollection of //! q disjoint triples covering every element exactly once. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -15,12 +15,10 @@ inventory::submit! { display_name: "Exact Cover by 3-Sets", aliases: &["X3C"], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if a collection of 3-element subsets contains an exact cover", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of universe X (must be divisible by 3)" }, - FieldInfo { name: "subsets", type_name: "Vec<[usize; 3]>", description: "Collection C of 3-element subsets of X" }, - ], + fields: ExactCoverBy3SetsCreateSpec::FIELDS, } } @@ -61,6 +59,40 @@ pub struct ExactCoverBy3Sets { subsets: Vec<[usize; 3]>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ExactCoverBy3SetsCreateSpec { + universe_size: usize, + #[create(codec = "semicolon-separated")] + subsets: Vec<[usize; 3]>, +} + +impl TryFrom for ExactCoverBy3Sets { + type Error = String; + fn try_from(mut spec: ExactCoverBy3SetsCreateSpec) -> Result { + if !spec.universe_size.is_multiple_of(3) { + return Err("universe_size must be divisible by 3".into()); + } + for (index, subset) in spec.subsets.iter_mut().enumerate() { + if subset[0] == subset[1] || subset[0] == subset[2] || subset[1] == subset[2] { + return Err(format!("subset {index} contains duplicate elements")); + } + if let Some(&element) = subset + .iter() + .find(|&&element| element >= spec.universe_size) + { + return Err(format!( + "subset {index} contains out-of-range element {element}" + )); + } + subset.sort(); + } + Ok(Self { + universe_size: spec.universe_size, + subsets: spec.subsets, + }) + } +} + impl ExactCoverBy3Sets { /// Create a new X3C problem. /// @@ -207,7 +239,7 @@ impl Problem for ExactCoverBy3Sets { } crate::declare_variants! { - default ExactCoverBy3Sets => "2^universe_size", + default ExactCoverBy3Sets => "2^universe_size" create ExactCoverBy3SetsCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/integer_knapsack.rs b/src/models/set/integer_knapsack.rs index aba6cb1f6..f522ac072 100644 --- a/src/models/set/integer_knapsack.rs +++ b/src/models/set/integer_knapsack.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Integer Knapsack", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Select items with integer multiplicities to maximize total value subject to capacity constraint", fields: &[ diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index fb199d5a2..2b5eb139e 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -3,7 +3,7 @@ //! The Set Packing problem asks for a maximum weight collection of //! pairwise disjoint sets. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Max, One, WeightElement}; use num_traits::Zero; @@ -16,12 +16,10 @@ inventory::submit! { display_name: "Maximum Set Packing", aliases: &[], dimensions: &[VariantDimension::new("weight", "One", &["One", "i32", "f64"])], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find maximum weight collection of disjoint sets", - fields: &[ - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of sets over a universe" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MaximumSetPackingCreateSpec::::FIELDS, } } @@ -61,6 +59,29 @@ pub struct MaximumSetPacking { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MaximumSetPackingCreateSpec { + /// Collection of sets over a universe. + subsets: Vec>, + /// Weight for each set. + weights: Vec, +} + +impl TryFrom> for MaximumSetPacking { + type Error = String; + + fn try_from(spec: MaximumSetPackingCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + Ok(Self::with_weights(spec.subsets, spec.weights)) + } +} + impl MaximumSetPacking { /// Create a new Set Packing problem with unit weights. pub fn new(sets: Vec>) -> Self @@ -166,9 +187,9 @@ where } crate::declare_variants! { - default MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", - MaximumSetPacking => "2^num_sets", + default MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, + MaximumSetPacking => "2^num_sets" create MaximumSetPackingCreateSpec, } /// Check if a selection forms a valid set packing (pairwise disjoint). diff --git a/src/models/set/minimum_cardinality_key.rs b/src/models/set/minimum_cardinality_key.rs index 7aa90ddaf..01cafecef 100644 --- a/src/models/set/minimum_cardinality_key.rs +++ b/src/models/set/minimum_cardinality_key.rs @@ -14,6 +14,7 @@ inventory::submit! { display_name: "Minimum Cardinality Key", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a candidate key of minimum cardinality in a relational system", fields: &[ diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index e7b0d47d2..04fef79f8 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -3,7 +3,7 @@ //! The Minimum Hitting Set problem asks for a minimum-size subset of universe //! elements that intersects every set in a collection. -use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, ProblemSizeFieldEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -14,12 +14,10 @@ inventory::submit! { display_name: "Minimum Hitting Set", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a minimum-size subset of universe elements that hits every set", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U that must each be hit" }, - ], + fields: MinimumHittingSetCreateSpec::FIELDS, } } @@ -40,6 +38,30 @@ pub struct MinimumHittingSet { sets: Vec>, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumHittingSetCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U that must each be hit. + subsets: Vec>, +} + +impl TryFrom for MinimumHittingSet { + type Error = String; + + fn try_from(spec: MinimumHittingSetCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets)) + } +} + impl MinimumHittingSet { /// Create a new Minimum Hitting Set instance. /// @@ -144,7 +166,7 @@ impl Problem for MinimumHittingSet { } crate::declare_variants! { - default MinimumHittingSet => "2^universe_size", + default MinimumHittingSet => "2^universe_size" create MinimumHittingSetCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 4387375a5..fb0aea942 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -3,7 +3,7 @@ //! The Set Covering problem asks for a minimum weight collection of sets //! that covers all elements in the universe. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::Problem; use crate::types::{Min, WeightElement}; use num_traits::Zero; @@ -16,13 +16,10 @@ inventory::submit! { display_name: "Minimum Set Covering", aliases: &[], dimensions: &[VariantDimension::new("weight", "i32", &["i32"])], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find minimum weight collection covering the universe", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the universe U" }, - FieldInfo { name: "sets", type_name: "Vec>", description: "Collection of subsets of U" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Weight for each set" }, - ], + fields: MinimumSetCoveringCreateSpec::FIELDS, } } @@ -68,6 +65,43 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumSetCoveringCreateSpec { + /// Size of the universe U. + universe_size: usize, + /// Collection of subsets of U. + subsets: Vec>, + /// Weight for each subset. + weights: Vec, +} + +impl TryFrom for MinimumSetCovering { + type Error = String; + + fn try_from(spec: MinimumSetCoveringCreateSpec) -> Result { + if spec.subsets.len() != spec.weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + spec.weights.len(), + spec.subsets.len() + )); + } + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::with_weights( + spec.universe_size, + spec.subsets, + spec.weights, + )) + } +} + impl MinimumSetCovering { /// Create a new Set Covering problem with unit weights. pub fn new(universe_size: usize, sets: Vec>) -> Self @@ -171,7 +205,7 @@ where } crate::declare_variants! { - default MinimumSetCovering => "2^num_sets", + default MinimumSetCovering => "2^num_sets" create MinimumSetCoveringCreateSpec, } /// Check if a selection of sets forms a valid set cover. diff --git a/src/models/set/prime_attribute_name.rs b/src/models/set/prime_attribute_name.rs index 96a9741e1..ccd9c9ed7 100644 --- a/src/models/set/prime_attribute_name.rs +++ b/src/models/set/prime_attribute_name.rs @@ -3,7 +3,7 @@ //! Given a set of attributes A, a collection of functional dependencies F on A, //! and a query attribute x, determine if x belongs to any candidate key of . -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -13,13 +13,10 @@ inventory::submit! { display_name: "Prime Attribute Name", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if an attribute belongs to any candidate key under functional dependencies", - fields: &[ - FieldInfo { name: "num_attributes", type_name: "usize", description: "Number of attributes" }, - FieldInfo { name: "dependencies", type_name: "Vec<(Vec, Vec)>", description: "Functional dependencies (lhs, rhs) pairs" }, - FieldInfo { name: "query_attribute", type_name: "usize", description: "The query attribute index" }, - ], + fields: PrimeAttributeNameCreateSpec::FIELDS, } } @@ -70,6 +67,51 @@ pub struct PrimeAttributeName { query_attribute: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct PrimeAttributeNameCreateSpec { + /// Number of attributes. + universe_size: usize, + /// Functional dependencies (lhs, rhs) pairs. + dependencies: Vec<(Vec, Vec)>, + /// The query attribute index. + query_attribute: usize, +} + +impl TryFrom for PrimeAttributeName { + type Error = String; + + fn try_from(spec: PrimeAttributeNameCreateSpec) -> Result { + if spec.query_attribute >= spec.universe_size { + return Err(format!( + "query_attribute {} is outside universe of size {}", + spec.query_attribute, spec.universe_size + )); + } + for (dependency_index, (lhs, rhs)) in spec.dependencies.iter().enumerate() { + if lhs.is_empty() { + return Err(format!( + "dependencies[{dependency_index}] has an empty left side" + )); + } + if let Some(&attribute) = lhs + .iter() + .chain(rhs) + .find(|&&attribute| attribute >= spec.universe_size) + { + return Err(format!( + "dependencies[{dependency_index}] contains attribute {attribute} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new( + spec.universe_size, + spec.dependencies, + spec.query_attribute, + )) + } +} + impl PrimeAttributeName { /// Create a new Prime Attribute Name problem. /// @@ -205,7 +247,7 @@ impl Problem for PrimeAttributeName { } crate::declare_variants! { - default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes", + default PrimeAttributeName => "2^num_attributes * num_dependencies * num_attributes" create PrimeAttributeNameCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/rooted_tree_storage_assignment.rs b/src/models/set/rooted_tree_storage_assignment.rs index b4138f5af..287e3ecfc 100644 --- a/src/models/set/rooted_tree_storage_assignment.rs +++ b/src/models/set/rooted_tree_storage_assignment.rs @@ -11,6 +11,7 @@ inventory::submit! { display_name: "Rooted Tree Storage Assignment", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Does there exist a rooted tree whose subset path extensions cost at most K?", fields: &[ diff --git a/src/models/set/set_basis.rs b/src/models/set/set_basis.rs index e1b9f92bf..8620e2961 100644 --- a/src/models/set/set_basis.rs +++ b/src/models/set/set_basis.rs @@ -4,7 +4,7 @@ //! determine whether there exist `k` basis sets such that every target set //! can be reconstructed as a union of some subcollection of the basis. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::traits::Problem; use serde::{Deserialize, Serialize}; @@ -14,13 +14,10 @@ inventory::submit! { display_name: "Set Basis", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine whether a collection of sets admits a basis of size k under union", - fields: &[ - FieldInfo { name: "universe_size", type_name: "usize", description: "Size of the ground set S" }, - FieldInfo { name: "collection", type_name: "Vec>", description: "Collection C of target subsets of S" }, - FieldInfo { name: "k", type_name: "usize", description: "Required number of basis sets" }, - ], + fields: SetBasisCreateSpec::FIELDS, } } @@ -40,6 +37,32 @@ pub struct SetBasis { k: usize, } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct SetBasisCreateSpec { + /// Size of the ground set S. + universe_size: usize, + /// Collection C of target subsets of S. + subsets: Vec>, + /// Required number of basis sets. + k: usize, +} + +impl TryFrom for SetBasis { + type Error = String; + + fn try_from(spec: SetBasisCreateSpec) -> Result { + for (set_index, set) in spec.subsets.iter().enumerate() { + if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { + return Err(format!( + "subsets[{set_index}] contains element {element} outside universe of size {}", + spec.universe_size + )); + } + } + Ok(Self::new(spec.universe_size, spec.subsets, spec.k)) + } +} + impl SetBasis { /// Create a new Set Basis instance. /// @@ -171,7 +194,7 @@ impl Problem for SetBasis { } crate::declare_variants! { - default SetBasis => "2^(basis_size * universe_size)", + default SetBasis => "2^(basis_size * universe_size)" create SetBasisCreateSpec, } #[cfg(feature = "example-db")] diff --git a/src/models/set/set_splitting.rs b/src/models/set/set_splitting.rs index e63053aa7..72bebeed1 100644 --- a/src/models/set/set_splitting.rs +++ b/src/models/set/set_splitting.rs @@ -13,6 +13,7 @@ inventory::submit! { display_name: "Set Splitting", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Partition a universe into two parts so that every subset is non-monochromatic", fields: &[ diff --git a/src/models/set/three_dimensional_matching.rs b/src/models/set/three_dimensional_matching.rs index fcad38548..ab0d9a856 100644 --- a/src/models/set/three_dimensional_matching.rs +++ b/src/models/set/three_dimensional_matching.rs @@ -15,6 +15,7 @@ inventory::submit! { display_name: "Three-Dimensional Matching", aliases: &["3DM"], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a perfect matching in a tripartite hypergraph", fields: &[ diff --git a/src/models/set/three_matroid_intersection.rs b/src/models/set/three_matroid_intersection.rs index 75959467c..0b7cb93ea 100644 --- a/src/models/set/three_matroid_intersection.rs +++ b/src/models/set/three_matroid_intersection.rs @@ -13,6 +13,7 @@ inventory::submit! { display_name: "Three-Matroid Intersection", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Find a common independent set of size K in three partition matroids", fields: &[ diff --git a/src/models/set/two_dimensional_consecutive_sets.rs b/src/models/set/two_dimensional_consecutive_sets.rs index de247c110..a34a2b1aa 100644 --- a/src/models/set/two_dimensional_consecutive_sets.rs +++ b/src/models/set/two_dimensional_consecutive_sets.rs @@ -17,6 +17,7 @@ inventory::submit! { display_name: "2-Dimensional Consecutive Sets", aliases: &[], dimensions: &[], + category: crate::registry::ProblemCategory::Set, module_path: module_path!(), description: "Determine if alphabet can be partitioned into ordered groups with intersection and consecutiveness constraints", fields: &[ diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 000000000..30b136bee --- /dev/null +++ b/src/random.rs @@ -0,0 +1,237 @@ +//! Shared deterministic building blocks for model-owned random generators. + +use crate::registry::ConstructionError; +use crate::topology::SimpleGraph; +use serde::Deserialize; + +/// Inputs shared by models generated from an Erdős–Rényi simple graph. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct SimpleGraphRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent probability of including each possible edge (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Inputs shared by integer-lattice graph generators. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct IntegerGeometryRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Inputs shared by unit-disk graph generators. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct UnitDiskRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Disk radius used to derive edges (default: 1.0). + pub radius: Option, + /// Seed for reproducible generation. + pub seed: Option, +} + +/// Random simple-graph inputs with a required clique size. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct CliqueRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Required clique size. + pub k: usize, +} + +impl CliqueRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } +} + +/// Random simple-graph inputs with optional source and sink vertices. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct EndpointRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Source vertex (default: 0). + pub source: Option, + /// Sink vertex (default: the final vertex). + pub sink: Option, +} + +/// Random simple-graph inputs with an optional runtime color count. +#[derive(Debug, Deserialize, crate::CreateSpec)] +pub struct ColoringRandomSpec { + /// Number of graph vertices. + pub num_vertices: usize, + /// Independent edge probability (default: 0.5). + pub edge_prob: Option, + /// Seed for reproducible generation. + pub seed: Option, + /// Runtime color count (default: 3). + pub k: Option, +} + +impl ColoringRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } +} + +impl EndpointRandomSpec { + /// Generate the graph using the common graph inputs. + pub fn graph(&self) -> Result { + SimpleGraphRandomSpec { + num_vertices: self.num_vertices, + edge_prob: self.edge_prob, + seed: self.seed, + } + .graph() + } + + /// Validate and return distinct source and sink vertices. + pub fn endpoints(&self) -> Result<(usize, usize), String> { + if self.num_vertices < 2 { + return Err("num_vertices must be at least 2".to_string()); + } + let source = self.source.unwrap_or(0); + let sink = self.sink.unwrap_or(self.num_vertices - 1); + if source >= self.num_vertices || sink >= self.num_vertices { + return Err(format!( + "source and sink must be below num_vertices ({})", + self.num_vertices + )); + } + if source == sink { + return Err("source and sink must be distinct".to_string()); + } + Ok((source, sink)) + } +} + +impl SimpleGraphRandomSpec { + /// Generate the requested graph after validating its probability. + pub fn graph(&self) -> Result { + let edge_prob = self.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + return Err(format!( + "edge_prob must be between 0 and 1, got {edge_prob}" + )); + } + Ok(create_random_graph(self.num_vertices, edge_prob, self.seed)) + } +} + +/// Implement a typed, model-owned random generator using a typed input spec. +#[macro_export] +macro_rules! impl_random_generate { + ($target:ty, $spec:ty, |$input:ident| $body:block) => { + impl $crate::registry::RandomGenerate for $target { + const INPUTS: &'static [$crate::registry::CreateInputInfo] = + <$spec as $crate::registry::CreateSpec>::INPUTS; + + fn generate( + data: serde_json::Value, + ) -> Result { + $crate::registry::validate_create_inputs(Self::INPUTS, &data)?; + let $input: $spec = <$spec as $crate::registry::CreateSpec>::deserialize_inputs( + data, + ) + .map_err(|error| { + $crate::registry::ConstructionError::InvalidInput(error.to_string()) + })?; + let generate = || -> Result { $body }; + let result = generate(); + result.map_err($crate::registry::ConstructionError::Conversion) + } + } + }; +} + +/// LCG PRNG step returning a uniform value in `[0, 1)`. +pub fn lcg_step(state: &mut u64) -> f64 { + *state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (*state >> 33) as f64 / (1u64 << 31) as f64 +} + +/// Initialize LCG state from a seed or the current time. +pub fn lcg_init(seed: Option) -> u64 { + seed.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos() as u64 + }) +} + +/// Generate an Erdős–Rényi simple graph. +pub fn create_random_graph(num_vertices: usize, edge_prob: f64, seed: Option) -> SimpleGraph { + let mut state = lcg_init(seed); + let edges = (0..num_vertices) + .flat_map(|u| ((u + 1)..num_vertices).map(move |v| (u, v))) + .filter(|_| lcg_step(&mut state) < edge_prob) + .collect(); + SimpleGraph::new(num_vertices, edges) +} + +/// Generate unique integer positions on a square grid. +pub fn create_random_int_positions(num_vertices: usize, seed: Option) -> Vec<(i32, i32)> { + let mut state = lcg_init(seed); + let grid_size = (num_vertices as f64).sqrt().ceil() as i32 + 1; + let capacity = (grid_size * grid_size) as usize; + lcg_choose(&mut state, capacity, num_vertices) + .expect("grid capacity exceeds the requested position count") + .into_iter() + .map(|index| (index as i32 / grid_size, index as i32 % grid_size)) + .collect() +} + +/// Generate float positions in `[0, sqrt(N)]²`. +pub fn create_random_float_positions(num_vertices: usize, seed: Option) -> Vec<(f64, f64)> { + let mut state = lcg_init(seed); + let side = (num_vertices as f64).sqrt(); + (0..num_vertices) + .map(|_| (lcg_step(&mut state) * side, lcg_step(&mut state) * side)) + .collect() +} + +/// Choose `k` distinct sorted indices from `0..n`. +pub fn lcg_choose(state: &mut u64, n: usize, k: usize) -> Result, ConstructionError> { + if k > n { + return Err(ConstructionError::Conversion(format!( + "cannot choose {k} elements from {n}" + ))); + } + let mut indices = (0..n).collect::>(); + for i in 0..k { + let j = i + (lcg_step(state) * (n - i) as f64) as usize % (n - i); + indices.swap(i, j); + } + let mut chosen = indices[..k].to_vec(); + chosen.sort_unstable(); + Ok(chosen) +} diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index 19483463a..037bfc723 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::fmt; use crate::traits::Problem; +use crate::types::Aggregate; /// Format a metric for CLI- and registry-facing dynamic dispatch. /// @@ -38,12 +39,16 @@ pub trait DynProblem: Any { fn variant_map(&self) -> BTreeMap; /// Return the number of variables. fn num_variables_dyn(&self) -> usize; + /// Whether the aggregate value admits representative witness configurations. + fn supports_witnesses_dyn(&self) -> bool; + /// Return the aggregate identity in the CLI-facing metric format. + fn aggregate_identity_dyn(&self) -> String; } impl DynProblem for T where T: Problem + Serialize + 'static, - T::Value: fmt::Display + Serialize, + T::Value: Aggregate + fmt::Display + Serialize, { fn evaluate_dyn(&self, config: &[usize]) -> String { format_metric(&self.evaluate(config)) @@ -76,6 +81,14 @@ where fn num_variables_dyn(&self) -> usize { self.num_variables() } + + fn supports_witnesses_dyn(&self) -> bool { + T::Value::supports_witnesses() + } + + fn aggregate_identity_dyn(&self) -> String { + format_metric(&T::Value::identity()) + } } /// Function pointer type for brute-force value solve dispatch. diff --git a/src/registry/info.rs b/src/registry/info.rs index 919670a8e..d39ca69c7 100644 --- a/src/registry/info.rs +++ b/src/registry/info.rs @@ -125,7 +125,7 @@ pub struct ProblemInfo { pub canonical_reduction_from: Option<&'static str>, /// Wikipedia or reference URL. pub reference_url: Option<&'static str>, - /// Struct field descriptions for schema export. + /// Construction input descriptions for schema export. pub fields: &'static [FieldInfo], } @@ -181,7 +181,7 @@ impl ProblemInfo { self } - /// Builder method to set struct field descriptions. + /// Builder method to set construction input descriptions. pub const fn with_fields(mut self, fields: &'static [FieldInfo]) -> Self { self.fields = fields; self @@ -206,10 +206,10 @@ impl fmt::Display for ProblemInfo { } } -/// Description of a struct field for JSON schema export. +/// Description of a problem construction input for schema export. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldInfo { - /// Field name as it appears in the Rust struct. + /// Input name supplied when constructing the problem. pub name: &'static str, /// Type name (e.g., `Vec`, `UnGraph<(), ()>`). pub type_name: &'static str, diff --git a/src/registry/mod.rs b/src/registry/mod.rs index d253d4c4a..c5a220a27 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -56,13 +56,33 @@ pub use info::{ComplexityClass, FieldInfo, ProblemInfo, ProblemMetadata}; pub use problem_ref::{parse_catalog_problem_ref, require_graph_variant, ProblemRef}; pub use problem_type::{find_problem_type, find_problem_type_by_alias, problem_types, ProblemType}; pub use schema::{ - collect_schemas, declared_size_fields, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson, - ProblemSizeFieldEntry, VariantDimension, + collect_schemas, declared_size_fields, FieldInfoJson, ParseProblemCategoryError, + ProblemCategory, ProblemSchemaEntry, ProblemSchemaJson, ProblemSizeFieldEntry, + VariantDimension, }; pub use variant::{ - find_variant_by_alias, find_variant_entry, validate_variant_aliases, VariantEntry, + find_variant_by_alias, find_variant_entry, validate_create_inputs, + validate_direct_create_inputs, validate_variant_aliases, variant_entries, ConstructProblemFn, + ConstructionError, CreateInputCodec, CreateInputInfo, CreateSpec, RandomGenerate, + RandomRegistration, VariantEntry, }; +/// Construct a problem from normalized construction inputs using the exact +/// registered problem name and variant. +pub fn construct_dyn( + name: &str, + variant: &BTreeMap, + data: serde_json::Value, +) -> Result, ConstructionError> { + let entry = find_variant_entry(name, variant).ok_or_else(|| { + ConstructionError::UnregisteredVariant { + name: name.to_string(), + variant: variant.clone(), + } + })?; + (entry.construct_fn)(data) +} + use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/problem_type.rs b/src/registry/problem_type.rs index 5337873c2..509ecff45 100644 --- a/src/registry/problem_type.rs +++ b/src/registry/problem_type.rs @@ -1,6 +1,6 @@ //! Problem type catalog: runtime lookup by name, alias, and variant validation. -use super::schema::{ProblemSchemaEntry, VariantDimension}; +use super::schema::{ProblemCategory, ProblemSchemaEntry, VariantDimension}; use super::FieldInfo; use std::collections::BTreeMap; @@ -17,8 +17,10 @@ pub struct ProblemType { pub dimensions: &'static [VariantDimension], /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], + /// Explicit structural model category. + pub category: ProblemCategory, } impl ProblemType { @@ -31,6 +33,7 @@ impl ProblemType { dimensions: entry.dimensions, description: entry.description, fields: entry.fields, + category: entry.category, } } diff --git a/src/registry/schema.rs b/src/registry/schema.rs index 00f202917..fa2fcbd44 100644 --- a/src/registry/schema.rs +++ b/src/registry/schema.rs @@ -2,6 +2,73 @@ use super::FieldInfo; use serde::Serialize; +use std::fmt; +use std::str::FromStr; + +/// Structural category used to organize problem implementations and catalog output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ProblemCategory { + Algebraic, + Formula, + Graph, + Misc, + Set, +} + +impl ProblemCategory { + pub const ALL: [Self; 5] = [ + Self::Algebraic, + Self::Formula, + Self::Graph, + Self::Misc, + Self::Set, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Algebraic => "algebraic", + Self::Formula => "formula", + Self::Graph => "graph", + Self::Misc => "misc", + Self::Set => "set", + } + } +} + +impl fmt::Display for ProblemCategory { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Error returned when a catalog category is not one of the five supported values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseProblemCategoryError(String); + +impl fmt::Display for ParseProblemCategoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let expected = ProblemCategory::ALL.map(ProblemCategory::as_str).join(", "); + write!( + formatter, + "unknown problem category `{}`; expected one of: {expected}", + self.0, + ) + } +} + +impl std::error::Error for ParseProblemCategoryError {} + +impl FromStr for ProblemCategory { + type Err = ParseProblemCategoryError; + + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|category| category.as_str() == value) + .ok_or_else(|| ParseProblemCategoryError(value.to_string())) + } +} /// A declared variant dimension for a problem type. /// @@ -33,6 +100,22 @@ impl VariantDimension { } /// A registered problem schema entry for static inventory registration. +/// +/// Category is required rather than inferred from source location: +/// +/// ```compile_fail +/// use problemreductions::registry::ProblemSchemaEntry; +/// +/// let _schema = ProblemSchemaEntry { +/// name: "Example", +/// display_name: "Example", +/// aliases: &[], +/// dimensions: &[], +/// module_path: module_path!(), +/// description: "Example schema", +/// fields: &[], +/// }; +/// ``` pub struct ProblemSchemaEntry { /// Problem name (e.g., "MaximumIndependentSet"). pub name: &'static str, @@ -42,11 +125,13 @@ pub struct ProblemSchemaEntry { pub aliases: &'static [&'static str], /// Declared variant dimensions with defaults and allowed values. pub dimensions: &'static [VariantDimension], + /// Explicit structural category shown in catalog output. + pub category: ProblemCategory, /// Module path from `module_path!()` (e.g., "problemreductions::models::graph::maximum_independent_set"). pub module_path: &'static str, /// Human-readable description. pub description: &'static str, - /// Struct fields. + /// Inputs accepted when constructing this problem. pub fields: &'static [FieldInfo], } @@ -55,7 +140,7 @@ inventory::collect!(ProblemSchemaEntry); /// Optional static size-field metadata for problem types. /// /// This is used when a problem has meaningful size fields even before it -/// participates in any reduction overhead expressions. +/// participates in any reduction size expressions. pub struct ProblemSizeFieldEntry { /// Problem name (e.g., "MaximumIndependentSet"). pub name: &'static str, @@ -72,7 +157,9 @@ pub struct ProblemSchemaJson { pub name: String, /// Problem description. pub description: String, - /// Struct fields. + /// Structural catalog category. + pub category: ProblemCategory, + /// Inputs accepted when constructing this problem. pub fields: Vec, } @@ -94,6 +181,7 @@ pub fn collect_schemas() -> Vec { .map(|entry| ProblemSchemaJson { name: entry.name.to_string(), description: entry.description.to_string(), + category: entry.category, fields: entry .fields .iter() diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 254fd0539..bfa8c4460 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -4,6 +4,190 @@ use std::any::Any; use std::collections::BTreeMap; use crate::registry::dyn_problem::{DynProblem, SolveValueFn, SolveWitnessFn}; +use crate::registry::FieldInfo; + +/// Reusable syntax used to transport one construction input. +/// +/// `Auto` asks a frontend to choose the codec from `type_name`. The explicit +/// variants are for Rust types whose compact external syntax is ambiguous. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CreateInputCodec { + /// Infer the transport syntax from the Rust value type. + #[default] + Auto, + /// A single scalar value. + Scalar, + /// A JSON value. + Json, + /// Comma-separated values. + CommaSeparated, + /// Semicolon-separated rows or groups. + SemicolonSeparated, + /// Undirected edges such as `0-1,1-2`. + EdgeList, + /// Directed arcs such as `0>1,1>2`. + ArcList, + /// Bipartite-local edges such as `0-0,0-1`. + BipartiteEdgeList, + /// Equality-linked index pairs such as `2=5;4=3`. + EqualityPairList, + /// Functional dependencies such as `0,1:2;2:3,4`. + FunctionalDependencyList, + /// Semicolon-separated character strings sharing one inferred alphabet. + CharacterRows, +} + +/// A user-facing input accepted when constructing a problem instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CreateInputInfo { + /// Input name in snake_case. Frontends may render it in their native style. + pub name: &'static str, + /// Concrete Rust value type accepted by the construction spec. + pub type_name: &'static str, + /// Human-readable input description. + pub description: &'static str, + /// Whether the input must be present. + pub required: bool, + /// Reusable transport syntax for this input. + pub codec: CreateInputCodec, +} + +impl CreateInputInfo { + /// Promote catalog field metadata into a required construction input. + pub const fn from_field(field: FieldInfo) -> Self { + Self { + name: field.name, + type_name: field.type_name, + description: field.description, + required: true, + codec: CreateInputCodec::Auto, + } + } +} + +/// Static construction-input metadata generated from a typed create spec. +pub trait CreateSpec { + /// Construction-facing field metadata used by the problem catalog. + const FIELDS: &'static [FieldInfo]; + /// Inputs accepted by this construction spec. + const INPUTS: &'static [CreateInputInfo]; + + /// Deserialize normalized construction inputs into the typed specification. + fn deserialize_inputs(data: serde_json::Value) -> Result + where + Self: Sized + serde::de::DeserializeOwned, + { + serde_json::from_value(data) + } +} + +/// Failure while validating or applying a model construction contract. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConstructionError { + /// No concrete variant matches the requested problem reference. + #[error("no registered variant for `{name}` with variant {variant:?}")] + UnregisteredVariant { + /// Canonical problem name. + name: String, + /// Exact requested variant. + variant: BTreeMap, + }, + /// Construction values must be supplied as a named JSON object. + #[error("construction inputs must be a JSON object")] + ExpectedObject, + /// A construction contract declared the same input more than once. + #[error("construction input `{0}` is declared more than once")] + DuplicateInput(String), + /// The caller supplied values outside the declared construction contract. + #[error("unknown construction input(s): {}", .0.join(", "))] + UnknownInputs(Vec), + /// The caller omitted required construction values. + #[error("missing required construction input(s): {}", .0.join(", "))] + MissingInputs(Vec), + /// Normalized values could not be deserialized into the direct model or create spec. + #[error("invalid construction input: {0}")] + InvalidInput(String), + /// A typed create spec failed to convert into the problem model. + #[error("problem construction failed: {0}")] + Conversion(String), +} + +/// Type-erased problem constructor used by dynamic frontends. +pub type ConstructProblemFn = + fn(serde_json::Value) -> Result, ConstructionError>; + +/// Random-generation contract for one concrete problem variant. +#[derive(Clone, Copy)] +pub struct RandomRegistration { + /// Inputs accepted by the generator. + pub inputs: &'static [CreateInputInfo], + /// Generate a concrete problem from normalized inputs. + pub generate: ConstructProblemFn, +} + +/// A concrete problem type that can generate itself from typed random inputs. +pub trait RandomGenerate: DynProblem + Sized { + /// Inputs accepted by this model's random generator. + const INPUTS: &'static [CreateInputInfo]; + + /// Generate a concrete problem from normalized random inputs. + fn generate(data: serde_json::Value) -> Result; +} + +/// Validate normalized values against a typed construction contract. +pub fn validate_create_inputs( + inputs: &[CreateInputInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract( + inputs.iter().map(|input| (input.name, input.required)), + data, + ) +} + +/// Validate the direct-construction path backed by catalog field metadata. +/// +/// Direct models have no separate create DTO, so every catalog field is a +/// required construction input. +pub fn validate_direct_create_inputs( + fields: &[FieldInfo], + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + validate_input_contract(fields.iter().map(|field| (field.name, true)), data) +} + +fn validate_input_contract<'a>( + inputs: impl IntoIterator, + data: &serde_json::Value, +) -> Result<(), ConstructionError> { + let object = data.as_object().ok_or(ConstructionError::ExpectedObject)?; + let mut declared = BTreeMap::new(); + for (name, required) in inputs { + if declared.insert(name, required).is_some() { + return Err(ConstructionError::DuplicateInput(name.to_string())); + } + } + + let unknown = object + .keys() + .filter(|name| !declared.contains_key(name.as_str())) + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(ConstructionError::UnknownInputs(unknown)); + } + + let missing = declared + .into_iter() + .filter(|(name, required)| *required && !object.contains_key(*name)) + .map(|(name, _)| name.to_string()) + .collect::>(); + if !missing.is_empty() { + return Err(ConstructionError::MissingInputs(missing)); + } + + Ok(()) +} /// A registered problem variant entry. /// @@ -28,6 +212,13 @@ pub struct VariantEntry { /// specific reduction-graph node, not just to a canonical problem name. The CLI /// resolver tries variant-level aliases first and falls back to problem-level. pub aliases: &'static [&'static str], + /// Custom construction inputs. `None` means the catalog schema fields are + /// also the construction inputs through the direct path. + pub create_inputs: Option<&'static [CreateInputInfo]>, + /// Construct a validated concrete problem from normalized construction data. + pub construct_fn: ConstructProblemFn, + /// Model-owned random generator for this exact variant. + pub random: Option, /// Factory: deserialize JSON into a boxed dynamic problem. pub factory: fn(serde_json::Value) -> Result, serde_json::Error>, /// Serialize: downcast `&dyn Any` and serialize to JSON. @@ -53,6 +244,11 @@ impl VariantEntry { } } +/// Return every registered concrete problem variant. +pub fn variant_entries() -> Vec<&'static VariantEntry> { + inventory::iter::().collect() +} + /// Find a variant entry by exact problem name and exact variant map. /// /// No alias resolution or default fallback. Both `name` and `variant` must match exactly. diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index 18a58090a..bcf0c17b3 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -25,22 +25,23 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { } /// One-hot decode: for each vertex v, output the unique c with x_{v,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&c| target_solution[v * n + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices * num_vertices + num_arcs * num_vertices + num_arcs + 2 * num_vertices", - num_constraints = "num_vertices + num_vertices + num_arcs * num_vertices + num_arcs + 1 + 2 * num_vertices + 2 * num_vertices * num_vertices + num_arcs", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for AcyclicPartition { @@ -178,7 +179,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index 6d616877d..c7cb50648 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -1,458 +1,7 @@ -//! Analysis utilities for the reduction graph. -//! -//! Detects primitive reduction rules that are dominated by composite paths, -//! using asymptotic normalization plus monomial-dominance comparison. -//! -//! This analysis is **sound but incomplete**: it reports `Dominated` only when -//! the symbolic comparison is trustworthy, and `Unknown` when metadata is too -//! weak to compare safely. - -use crate::canonical::canonical_form; -use crate::expr::Expr; -use crate::rules::graph::{ReductionGraph, ReductionPath}; -use crate::rules::registry::ReductionOverhead; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -/// Result of comparing one primitive rule against one composite path. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ComparisonStatus { - /// Composite is equal or better on all common fields. - Dominated, - /// Composite is worse on at least one common field. - NotDominated, - /// Cannot decide: expression not normalizable or path not trustworthy. - Unknown, -} - -/// A primitive reduction rule proven dominated by a composite path. -#[derive(Debug, Clone)] -pub struct DominatedRule { - pub source_name: &'static str, - pub source_variant: BTreeMap, - pub target_name: &'static str, - pub target_variant: BTreeMap, - pub primitive_overhead: ReductionOverhead, - pub dominating_path: ReductionPath, - pub composed_overhead: ReductionOverhead, - pub comparable_fields: Vec, -} - -impl DominatedRule { - pub fn source_display(&self) -> String { - format_problem_variant(self.source_name, &self.source_variant) - } - - pub fn target_display(&self) -> String { - format_problem_variant(self.target_name, &self.target_variant) - } -} - -impl fmt::Display for DominatedRule { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", self.source_display(), self.target_display()) - } -} - -/// A candidate comparison that could not be decided soundly. -#[derive(Debug, Clone)] -pub struct UnknownComparison { - pub source_name: &'static str, - pub source_variant: BTreeMap, - pub target_name: &'static str, - pub target_variant: BTreeMap, - pub candidate_path: ReductionPath, - pub reason: String, -} - -impl UnknownComparison { - pub fn source_display(&self) -> String { - format_problem_variant(self.source_name, &self.source_variant) - } - - pub fn target_display(&self) -> String { - format_problem_variant(self.target_name, &self.target_variant) - } -} - -impl fmt::Display for UnknownComparison { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", self.source_display(), self.target_display()) - } -} - -pub fn format_problem_variant(name: &str, variant: &BTreeMap) -> String { - if variant.is_empty() { - return name.to_string(); - } - - let vars = variant - .iter() - .map(|(k, v)| format!("{k}: {v:?}")) - .collect::>() - .join(", "); - format!("{name} {{{vars}}}") -} - -// ────────── Polynomial normalization ────────── - -/// A monomial: coefficient × ∏(variable ^ exponent). -#[derive(Debug, Clone)] -struct Monomial { - coeff: f64, - /// Variable name → exponent. Only non-zero exponents stored. - vars: BTreeMap<&'static str, f64>, -} - -impl Monomial { - fn constant(c: f64) -> Self { - Self { - coeff: c, - vars: BTreeMap::new(), - } - } - - fn variable(name: &'static str) -> Self { - let mut vars = BTreeMap::new(); - vars.insert(name, 1.0); - Self { coeff: 1.0, vars } - } - - /// Multiply two monomials. - fn mul(&self, other: &Monomial) -> Monomial { - let coeff = self.coeff * other.coeff; - let mut vars = self.vars.clone(); - for (&v, &e) in &other.vars { - *vars.entry(v).or_insert(0.0) += e; - } - Monomial { coeff, vars } - } -} - -/// A polynomial (sum of monomials) in normal form. -#[derive(Debug, Clone)] -struct NormalizedPoly { - terms: Vec, -} - -impl NormalizedPoly { - fn add(mut self, other: NormalizedPoly) -> NormalizedPoly { - self.terms.extend(other.terms); - self - } - - fn mul(&self, other: &NormalizedPoly) -> NormalizedPoly { - let mut terms = Vec::new(); - for a in &self.terms { - for b in &other.terms { - terms.push(a.mul(b)); - } - } - NormalizedPoly { terms } - } +//! Topology analysis utilities for the reduction graph. - /// True if any monomial has a negative coefficient. - fn has_negative_coefficients(&self) -> bool { - self.terms.iter().any(|m| m.coeff < -1e-15) - } -} - -/// Normalize an expression into a sum of monomials. -/// -/// Supports: constants, variables, addition, multiplication, -/// and powers with non-negative constant exponents. -/// Returns `Err` for exp, log, sqrt, division, and negative exponents. -fn normalize_polynomial(expr: &Expr) -> Result { - match expr { - Expr::Const(c) => Ok(NormalizedPoly { - terms: vec![Monomial::constant(*c)], - }), - Expr::Var(v) => Ok(NormalizedPoly { - terms: vec![Monomial::variable(v)], - }), - Expr::Add(a, b) => { - let pa = normalize_polynomial(a)?; - let pb = normalize_polynomial(b)?; - Ok(pa.add(pb)) - } - Expr::Mul(a, b) => { - let pa = normalize_polynomial(a)?; - let pb = normalize_polynomial(b)?; - Ok(pa.mul(&pb)) - } - Expr::Pow(base, exp) => { - if let Expr::Const(c) = exp.as_ref() { - if *c < 0.0 { - return Err(format!("negative exponent: {c}")); - } - let pb = normalize_polynomial(base)?; - // Single monomial: multiply exponents - if pb.terms.len() == 1 { - let m = &pb.terms[0]; - let coeff = m.coeff.powf(*c); - let vars: BTreeMap<_, _> = m.vars.iter().map(|(&v, &e)| (v, e * c)).collect(); - return Ok(NormalizedPoly { - terms: vec![Monomial { coeff, vars }], - }); - } - // Multi-term polynomial raised to non-negative integer power - let n = *c as usize; - if c.fract().abs() < 1e-10 { - if n == 0 { - return Ok(NormalizedPoly { - terms: vec![Monomial::constant(1.0)], - }); - } - let mut result = pb.clone(); - for _ in 1..n { - result = result.mul(&pb); - } - return Ok(result); - } - Err(format!( - "non-integer power of multi-term polynomial: ({base})^{c}" - )) - } else { - Err(format!("variable exponent: ({base})^({exp})")) - } - } - Expr::Exp(_) => Err("exp() not supported".into()), - Expr::Log(_) => Err("log() not supported".into()), - Expr::Sqrt(_) => Err("sqrt() not supported".into()), - Expr::Factorial(_) => Err("factorial() not supported".into()), - } -} - -fn prepare_expr_for_comparison(expr: &Expr) -> Expr { - canonical_form(expr).unwrap_or_else(|_| expr.clone()) -} - -// ────────── Monomial-dominance comparison ────────── - -/// Check if monomial `small` is asymptotically dominated by monomial `big`. -/// -/// True iff for every variable in `small`, `big` has at least as large an exponent. -/// This means `small` grows no faster than `big` as all variables → ∞. -fn monomial_dominated_by(small: &Monomial, big: &Monomial) -> bool { - for (&var, &exp_small) in &small.vars { - let exp_big = big.vars.get(var).copied().unwrap_or(0.0); - if exp_small > exp_big + 1e-10 { - return false; - } - } - true -} - -/// Check if polynomial `a` is asymptotically ≤ polynomial `b`. -/// -/// True iff every positive-coefficient monomial in `a` is dominated by -/// some positive-coefficient monomial in `b`. -fn poly_leq(a: &NormalizedPoly, b: &NormalizedPoly) -> bool { - let b_positive: Vec<&Monomial> = b.terms.iter().filter(|m| m.coeff > 1e-15).collect(); - - for a_term in &a.terms { - if a_term.coeff <= 1e-15 { - continue; // zero or negative — can only make `a` smaller - } - let dominated = b_positive - .iter() - .any(|b_term| monomial_dominated_by(a_term, b_term)); - if !dominated { - return false; - } - } - true -} - -// ────────── Overhead comparison ────────── - -/// Compare two overheads across all common fields. -/// -/// Returns `Dominated` if composite ≤ primitive on all common fields. -/// Returns `NotDominated` if composite is worse on any common field. -/// Returns `Unknown` if any common field's expressions cannot be normalized -/// into a comparable polynomial form or contain negative coefficients. -pub fn compare_overhead( - primitive: &ReductionOverhead, - composite: &ReductionOverhead, -) -> ComparisonStatus { - let comp_map: std::collections::HashMap<&str, &Expr> = composite - .output_size - .iter() - .map(|(name, expr)| (*name, expr)) - .collect(); - - let mut any_common = false; - - for (field, prim_expr) in &primitive.output_size { - let Some(comp_expr) = comp_map.get(field) else { - continue; - }; - any_common = true; - - let primitive_prepared = prepare_expr_for_comparison(prim_expr); - let composite_prepared = prepare_expr_for_comparison(comp_expr); - - if primitive_prepared == composite_prepared { - continue; - } - - let primitive_poly = match normalize_polynomial(&primitive_prepared) { - Ok(p) => p, - Err(_) => return ComparisonStatus::Unknown, - }; - let composite_poly = match normalize_polynomial(&composite_prepared) { - Ok(p) => p, - Err(_) => return ComparisonStatus::Unknown, - }; - - // Reject expressions with negative coefficients - if primitive_poly.has_negative_coefficients() || composite_poly.has_negative_coefficients() - { - return ComparisonStatus::Unknown; - } - - // Check: composite ≤ primitive on this field - if !poly_leq(&composite_poly, &primitive_poly) { - return ComparisonStatus::NotDominated; - } - } - - if any_common { - ComparisonStatus::Dominated - } else { - ComparisonStatus::NotDominated - } -} - -// ────────── Main analysis ────────── - -/// Find all primitive reduction rules dominated by composite paths. -/// -/// Returns a tuple of: -/// - `Vec`: rules proven dominated by a composite path -/// - `Vec`: candidates that could not be decided -/// -/// For each primitive rule (direct edge), enumerates all alternative paths, -/// validates trustworthiness, composes overheads, and compares. -/// Keeps only the best (shortest) dominating path per primitive rule. -/// -/// Note: iterates the graph's coalesced edges rather than raw `inventory` entries. -/// This is sound because `test_no_duplicate_primitive_rules_per_variant_pair` guards -/// the invariant that at most one registration exists per (source_variant, target_variant) pair. -pub fn find_dominated_rules( - graph: &ReductionGraph, -) -> (Vec, Vec) { - const MAX_PATHS_PER_EDGE: usize = 1024; - const MAX_INTERMEDIATE_NODES: usize = 6; - - let mut dominated = Vec::new(); - let mut unknown = Vec::new(); - - for edge_info in all_edges(graph) { - let paths = graph.find_paths_up_to_mode_bounded( - edge_info.source_name, - &edge_info.source_variant, - edge_info.target_name, - &edge_info.target_variant, - crate::rules::graph::ReductionMode::Witness, - MAX_PATHS_PER_EDGE, - Some(MAX_INTERMEDIATE_NODES), - ); - - let mut best_dominating: Option<(ReductionPath, ReductionOverhead, Vec)> = None; - - for path in paths { - if path.len() <= 1 { - continue; // skip the direct edge itself - } - - let composed = graph.compose_path_overhead(&path); - - match compare_overhead(&edge_info.overhead, &composed) { - ComparisonStatus::Dominated => { - let comparable_fields = common_fields(&edge_info.overhead, &composed); - let is_better = match &best_dominating { - None => true, - Some((best_path, _, _)) => path.len() < best_path.len(), - }; - if is_better { - best_dominating = Some((path, composed, comparable_fields)); - } - } - ComparisonStatus::Unknown => { - unknown.push(UnknownComparison { - source_name: edge_info.source_name, - source_variant: edge_info.source_variant.clone(), - target_name: edge_info.target_name, - target_variant: edge_info.target_variant.clone(), - candidate_path: path, - reason: "expression comparison returned Unknown".into(), - }); - } - ComparisonStatus::NotDominated => {} - } - } - - if let Some((path, composed, fields)) = best_dominating { - dominated.push(DominatedRule { - source_name: edge_info.source_name, - source_variant: edge_info.source_variant.clone(), - target_name: edge_info.target_name, - target_variant: edge_info.target_variant.clone(), - primitive_overhead: edge_info.overhead.clone(), - dominating_path: path, - composed_overhead: composed, - comparable_fields: fields, - }); - } - } - - // Deterministic output - dominated.sort_by(|a, b| { - ( - format_problem_variant(a.source_name, &a.source_variant), - format_problem_variant(a.target_name, &a.target_variant), - a.dominating_path.len(), - ) - .cmp(&( - format_problem_variant(b.source_name, &b.source_variant), - format_problem_variant(b.target_name, &b.target_variant), - b.dominating_path.len(), - )) - }); - unknown.sort_by(|a, b| { - ( - format_problem_variant(a.source_name, &a.source_variant), - format_problem_variant(a.target_name, &a.target_variant), - ) - .cmp(&( - format_problem_variant(b.source_name, &b.source_variant), - format_problem_variant(b.target_name, &b.target_variant), - )) - }); - - (dominated, unknown) -} - -/// Fields present in both overheads. -fn common_fields(a: &ReductionOverhead, b: &ReductionOverhead) -> Vec { - let b_fields: std::collections::HashSet<&str> = b.output_size.iter().map(|(n, _)| *n).collect(); - a.output_size - .iter() - .filter(|&(f, _)| b_fields.contains(f)) - .map(|(f, _)| f.to_string()) - .collect() -} - -/// Collect all edges from the reduction graph. -fn all_edges(graph: &ReductionGraph) -> Vec { - let mut edges = Vec::new(); - for name in graph.problem_types() { - edges.extend(graph.outgoing_reductions(name)); - } - edges -} +use crate::rules::graph::ReductionGraph; +use std::collections::{BTreeMap, BTreeSet}; // ────────── Topology checks ────────── diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 955fd772d..8ff95cd64 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -24,15 +24,23 @@ impl ReductionResult for ReductionBCBSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", - num_constraints = "num_vertices * num_vertices", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for BalancedCompleteBipartiteSubgraph { diff --git a/src/rules/bicliquecover_bmf.rs b/src/rules/bicliquecover_bmf.rs index 93f9e93fe..d68a9dec1 100644 --- a/src/rules/bicliquecover_bmf.rs +++ b/src/rules/bicliquecover_bmf.rs @@ -36,13 +36,18 @@ impl ReductionResult for ReductionBicliqueCoverToBMF { /// Map a BMF config (B row-major, C row-major) to a BicliqueCover /// config (vertex-major) via the inverse transpose. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - config_bmf_to_bc(target_solution, self.m, self.n, self.k) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(config_bmf_to_bc(target_solution, self.m, self.n, self.k)) } } #[reduction( - overhead = { + size = exact { rows = "left_size", cols = "right_size", rank = "rank", diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 4be442b96..29bc741df 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -24,15 +24,23 @@ impl ReductionResult for ReductionBiconnAugToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_candidates].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_candidates].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_potential_edges + 2 * num_vertices * num_vertices * (num_edges + num_potential_edges)", - num_constraints = "1 + 2 * num_vertices * num_vertices * num_potential_edges + num_vertices * num_vertices * num_vertices", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for BiconnectivityAugmentation { @@ -212,7 +220,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/binpacking_ilp.rs b/src/rules/binpacking_ilp.rs index 49dc2f51e..307975791 100644 --- a/src/rules/binpacking_ilp.rs +++ b/src/rules/binpacking_ilp.rs @@ -9,6 +9,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::BinPacking; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing BinPacking to ILP. @@ -36,26 +37,21 @@ impl ReductionResult for ReductionBPToILP { /// Extract solution from ILP back to BinPacking. /// /// For each item i, find the unique bin j where x_{ij} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let mut assignment = vec![0usize; n]; - for i in 0..n { - for j in 0..n { - if target_solution[i * n + j] == 1 { - assignment[i] = j; - break; - } - } - } - assignment + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.n, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_items * num_items + num_items", num_constraints = "2 * num_items", - } + }, )] impl ReduceTo> for BinPacking { type Result = ReductionBPToILP; diff --git a/src/rules/bmf_bicliquecover.rs b/src/rules/bmf_bicliquecover.rs index bafa9b304..7791b67d6 100644 --- a/src/rules/bmf_bicliquecover.rs +++ b/src/rules/bmf_bicliquecover.rs @@ -75,13 +75,18 @@ impl ReductionResult for ReductionBMFToBicliqueCover { } /// Map a BicliqueCover config (vertex-major) back to a BMF config (B row-major, then C row-major). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - config_bc_to_bmf(target_solution, self.m, self.n, self.k) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(config_bc_to_bmf(target_solution, self.m, self.n, self.k)) } } #[reduction( - overhead = { + size = exact { num_vertices = "rows + cols", num_edges = "rows * cols", rank = "rank", diff --git a/src/rules/bmf_ilp.rs b/src/rules/bmf_ilp.rs index 2764ef419..15d5bc063 100644 --- a/src/rules/bmf_ilp.rs +++ b/src/rules/bmf_ilp.rs @@ -25,18 +25,25 @@ impl ReductionResult for ReductionBMFToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Extract B (m x k) then C (k x n) — first m*k + k*n variables - let total = self.m * self.k + self.k * self.n; - target_solution[..total].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Extract B (m x k) then C (k x n) — first m*k + k*n variables + let total = self.m * self.k + self.k * self.n; + target_solution[..total].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "rows * rank + rank * cols + rows * rank * cols + rows * cols", num_constraints = "3 * rows * rank * cols + rank * rows * cols + rows * cols + rows * cols", - } + }, )] impl ReduceTo> for BMF { type Result = ReductionBMFToILP; diff --git a/src/rules/bottlenecktravelingsalesman_ilp.rs b/src/rules/bottlenecktravelingsalesman_ilp.rs index a67dda8e2..adaa4252b 100644 --- a/src/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/rules/bottlenecktravelingsalesman_ilp.rs @@ -10,6 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BottleneckTravelingSalesman; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::Graph; @@ -35,42 +36,44 @@ impl ReductionResult for ReductionBTSPToILP { } /// Extract: decode tour from x variables, then mark selected edges. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // Decode tour: for each position p, find vertex v with x_{v,p} = 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; - } - } - } + Ok({ + let n = self.num_vertices; - // Map tour to edge selection - let mut edge_selection = vec![0usize; self.source_edges.len()]; - for p in 0..n { - let u = tour[p]; - let v = tour[(p + 1) % n]; - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; - } + let tour = one_hot_decode(target_solution, n, n, 0)?; + + // Map tour to edge selection + let mut edge_selection = vec![0usize; self.source_edges.len()]; + for p in 0..n { + let u = tour[p]; + let v = tour[(p + 1) % n]; + let edge = self + .source_edges + .iter() + .position(|&(a, b)| (a == u && b == v) || (a == v && b == u)) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target tour uses absent source edge ({u}, {v})" + )) + })?; + edge_selection[edge] = 1; } - } - edge_selection + edge_selection + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2 + 2 * num_edges * num_vertices + 1", num_constraints = "2 * num_vertices + num_vertices^2 + 2 * num_edges * num_vertices + 6 * num_edges * num_vertices + num_vertices + 2 * num_edges * num_vertices", - } + }, )] impl ReduceTo> for BottleneckTravelingSalesman { type Result = ReductionBTSPToILP; diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 7688ddff6..8603df195 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -7,6 +7,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BoundedComponentSpanningForest; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; @@ -26,23 +27,23 @@ impl ReductionResult for ReductionBCSFToILP { } /// One-hot decode: for each vertex v, output the unique component c with x_{v,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let k = self.k; - (0..n) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.k, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "3 * num_vertices * max_components + 2 * max_components + 2 * num_edges * max_components", - num_constraints = "num_vertices + max_components + max_components + 2 * max_components + num_vertices * max_components + 4 * num_vertices * max_components + 4 * num_edges * max_components + num_vertices * max_components", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for BoundedComponentSpanningForest { @@ -203,7 +204,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/capacityassignment_ilp.rs b/src/rules/capacityassignment_ilp.rs index 798646c85..1a1f78d6e 100644 --- a/src/rules/capacityassignment_ilp.rs +++ b/src/rules/capacityassignment_ilp.rs @@ -34,23 +34,26 @@ impl ReductionResult for ReductionCAToILP { } /// Extract solution: for each link l, find the unique capacity c where x_{l,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_capacities = self.num_capacities; - (0..self.num_links) - .map(|l| { - (0..num_capacities) - .find(|&c| target_solution[l * num_capacities + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_links, + self.num_capacities, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_links * num_capacities", num_constraints = "num_links + 1", - } + }, )] impl ReduceTo> for CapacityAssignment { type Result = ReductionCAToILP; diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index fcd26f97a..60719502b 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -36,11 +36,18 @@ impl ReductionResult for ReductionCircuitToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_variables - .iter() - .map(|name| target_solution[self.variable_map[name]]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.source_variables + .iter() + .map(|name| target_solution[self.variable_map[name]]) + .collect() + }) } } @@ -171,9 +178,12 @@ impl ILPBuilder { } #[reduction( - overhead = { + size = exact { num_vars = "num_variables + num_assignments", - num_constraints = "num_variables + num_assignments", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for CircuitSAT { diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index b0cbb6760..6dd57f2bc 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -4,6 +4,7 @@ use crate::models::formula::{ Assignment, BooleanExpr, BooleanOp, CNFClause, CircuitSAT, Satisfiability, }; use crate::reduction; +use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; use std::collections::HashMap; @@ -33,21 +34,26 @@ struct TseitinEncoding { struct TseitinEncoder { source_var_ids: HashMap, clauses: Vec, - next_var: i32, + variables: SatVariableAllocator, } impl TseitinEncoder { fn new(source: &CircuitSAT) -> Self { + let mut variables = SatVariableAllocator::new("CircuitSAT -> Satisfiability", 0) + .unwrap_or_else(|message| panic!("{message}")); + let source_ids = variables + .allocate_many(source.num_variables()) + .unwrap_or_else(|message| panic!("{message}")); let source_var_ids = source .variable_names() .iter() - .enumerate() - .map(|(index, name)| (name.clone(), index as i32 + 1)) + .zip(source_ids) + .map(|(name, variable)| (name.clone(), variable)) .collect(); Self { source_var_ids, clauses: Vec::new(), - next_var: source.num_variables() as i32 + 1, + variables, } } @@ -57,7 +63,7 @@ impl TseitinEncoder { } TseitinEncoding { - num_vars: (self.next_var - 1) as usize, + num_vars: self.variables.num_vars(), clauses: self.clauses, } } @@ -152,9 +158,9 @@ impl TseitinEncoder { } fn allocate_auxiliary_var(&mut self) -> i32 { - let var = self.next_var; - self.next_var += 1; - var + self.variables + .allocate() + .unwrap_or_else(|message| panic!("{message}")) } fn push_equivalence(&mut self, left: i32, right: i32) { @@ -293,17 +299,18 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution - .iter() - .take(self.source_var_count) - .copied() - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_var_count].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "tseitin_num_vars", num_clauses = "tseitin_num_clauses", } @@ -350,7 +357,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Satisfiability example must be satisfiable"); crate::example_db::specs::assemble_rule_example( diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index da080d921..d5ee2476b 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -196,16 +196,17 @@ impl ReductionResult for ReductionCircuitToSG { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_variables + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(self + .source_variables .iter() - .map(|var| { - self.variable_map - .get(var) - .and_then(|&idx| target_solution.get(idx).copied()) - .unwrap_or(0) - }) - .collect() + .map(|variable| target_solution[self.variable_map[variable]]) + .collect()) } } @@ -413,9 +414,9 @@ where } #[reduction( - overhead = { - num_spins = "num_assignments * num_variables", - num_interactions = "num_assignments * num_variables", + size = unavailable { + num_spins = "the exact gadget size depends on Boolean expression node counts and operator kinds absent from the source size vector", + num_interactions = "the exact coupling count depends on Boolean expression node counts and operator kinds absent from the source size vector", } )] impl ReduceTo> for CircuitSAT { diff --git a/src/rules/closeststring_ilp.rs b/src/rules/closeststring_ilp.rs index 222c60186..ed1f3ebfa 100644 --- a/src/rules/closeststring_ilp.rs +++ b/src/rules/closeststring_ilp.rs @@ -50,27 +50,39 @@ impl ReductionResult for ReductionClosestStringToILP { /// Decode the integer ILP assignment into the source center config. /// /// For every position `j`, choose the unique alphabet symbol `a` with - /// `x_{j, a} = 1`. If the target assignment is missing or none of the - /// per-position `x_{j, *}` variables are set to 1, we fall back to symbol - /// `0` so the returned vector still has the expected length; partial / - /// infeasible ILP solutions are the caller's responsibility. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + /// `x_{j, a} = 1`. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let q = self.alphabet_size; - (0..self.string_length) - .map(|j| { - (0..q) - .find(|&a| target_solution.get(j * q + a).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + let mut center = Vec::with_capacity(self.string_length); + for position in 0..self.string_length { + let block = &target_solution[position * q..(position + 1) * q]; + let mut selected = block.iter().enumerate().filter(|(_, value)| **value == 1); + let symbol = selected.next().map(|(symbol, _)| symbol).ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "center position {position} has no selected symbol" + )) + })?; + if selected.next().is_some() || block.iter().any(|&value| value > 1) { + return Err(crate::rules::ExtractionError::invalid(format!( + "center position {position} is not one-hot" + ))); + } + center.push(symbol); + } + Ok(center) } } #[reduction( - overhead = { + size = exact { num_vars = "alphabet_size * string_length + 1", num_constraints = "string_length + num_strings", - } + }, )] impl ReduceTo> for ClosestString { type Result = ReductionClosestStringToILP; diff --git a/src/rules/closestsubstring_ilp.rs b/src/rules/closestsubstring_ilp.rs index dccc61963..e11fad2d6 100644 --- a/src/rules/closestsubstring_ilp.rs +++ b/src/rules/closestsubstring_ilp.rs @@ -70,48 +70,59 @@ impl ReductionResult for ReductionClosestSubstringToILP { /// first `ell` entries are the center symbols, the remaining `n` entries /// are per-string window starts. For each center position `r`, we pick the /// unique alphabet symbol `a` with `x_{r, a} = 1`; for each input string - /// `s_i`, we pick the unique window start `p` with `y_{i, p} = 1`. When no - /// indicator is set to 1 in some block (which only happens on partial / - /// infeasible ILP solutions), we fall back to 0 so the returned vector - /// still has the expected shape. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + /// `s_i`, we pick the unique window start `p` with `y_{i, p} = 1`. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let q = self.alphabet_size; let ell = self.substring_length; let y_base = q * ell; - let mut out = Vec::with_capacity(ell + self.window_counts.len()); - // Center symbols. - for r in 0..ell { - let symbol = (0..q) - .find(|&a| target_solution.get(r * q + a).copied().unwrap_or(0) == 1) - .unwrap_or(0); - out.push(symbol); + for position in 0..ell { + let block = &target_solution[position * q..(position + 1) * q]; + out.push(decode_one_hot(block, "center position", position)?); } - - // Window starts. - for (i, &w_i) in self.window_counts.iter().enumerate() { - let start = (0..w_i) - .find(|&p| { - target_solution - .get(y_base + self.window_offsets[i] + p) - .copied() - .unwrap_or(0) - == 1 - }) - .unwrap_or(0); - out.push(start); + for (string, &window_count) in self.window_counts.iter().enumerate() { + let start = y_base + self.window_offsets[string]; + out.push(decode_one_hot( + &target_solution[start..start + window_count], + "string window", + string, + )?); } - out + Ok(out) } } +fn decode_one_hot( + block: &[usize], + block_name: &str, + block_index: usize, +) -> crate::rules::ExtractionResult { + let mut selected = block.iter().enumerate().filter(|(_, value)| **value == 1); + let index = selected.next().map(|(index, _)| index).ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "{block_name} {block_index} has no selected value" + )) + })?; + if selected.next().is_some() || block.iter().any(|&value| value > 1) { + return Err(crate::rules::ExtractionError::invalid(format!( + "{block_name} {block_index} is not one-hot" + ))); + } + Ok(index) +} + #[reduction( - overhead = { + size = exact { num_vars = "alphabet_size * substring_length + total_num_windows + 1", num_constraints = "substring_length + num_strings + total_num_windows + 1", - } + }, )] impl ReduceTo> for ClosestSubstring { type Result = ReductionClosestSubstringToILP; diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index bfc4b6c73..97b55eb27 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -31,24 +31,25 @@ impl ReductionResult for ReductionCVPToQUBO { } /// Reconstruct the source configuration offsets from the encoded QUBO bits. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.encodings - .iter() - .map(|encoding| { - encoding - .weights - .iter() - .enumerate() - .map(|(offset, weight)| { - target_solution - .get(encoding.start + offset) - .copied() - .unwrap_or(0) - * weight - }) - .sum() - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.encodings + .iter() + .map(|encoding| { + encoding + .weights + .iter() + .enumerate() + .map(|(offset, weight)| target_solution[encoding.start + offset] * weight) + .sum() + }) + .collect() + }) } } @@ -111,7 +112,9 @@ fn at_times_target(problem: &ClosestVectorProblem) -> Vec { .collect() } -#[reduction(overhead = { num_vars = "num_encoding_bits" })] +#[reduction(size = exact { + num_vars = "num_encoding_bits", +})] impl ReduceTo> for ClosestVectorProblem { type Result = ReductionCVPToQUBO; diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index 659eb0d38..82519a0f5 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -18,12 +18,6 @@ pub struct ReductionClusteringToILP { num_clusters: usize, } -impl ReductionClusteringToILP { - fn var_index(&self, element: usize, cluster: usize) -> usize { - element * self.num_clusters + cluster - } -} - impl ReductionResult for ReductionClusteringToILP { type Source = Clustering; type Target = ILP; @@ -32,24 +26,28 @@ impl ReductionResult for ReductionClusteringToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_elements) - .map(|element| { - (0..self.num_clusters) - .find(|&cluster| { - let idx = self.var_index(element, cluster); - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_elements, + self.num_clusters, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_elements * num_clusters", - num_constraints = "num_elements + num_elements * (num_elements - 1) / 2 * num_clusters", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for Clustering { diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 8fa5095c8..d792a07b7 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -10,6 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::KColoring; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; use crate::variant::{KValue, K1, K2, K3, K4, KN}; @@ -28,13 +29,6 @@ pub struct ReductionKColoringToILP { _phantom: std::marker::PhantomData<(K, G)>, } -impl ReductionKColoringToILP { - /// Get the variable index for vertex v with color c. - fn var_index(&self, vertex: usize, color: usize) -> usize { - vertex * self.num_colors + color - } -} - impl ReductionResult for ReductionKColoringToILP where G: Graph + crate::variant::VariantParam, @@ -50,18 +44,13 @@ where /// /// The ILP solution has num_vertices * K binary variables. /// For each vertex, we find which color has value 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| { - let var_idx = self.var_index(v, c); - var_idx < target_solution.len() && target_solution[var_idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) } } @@ -112,9 +101,9 @@ fn reduce_kcoloring_to_ilp( // Register only the KN variant in the reduction graph #[reduction( - overhead = { - num_vars = "num_vertices^2", - num_constraints = "num_vertices + num_vertices * num_edges", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for KColoring { diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index e85c498f8..8eca8bc33 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -11,6 +11,7 @@ use crate::models::algebraic::QUBO; use crate::models::graph::KColoring; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; use crate::variant::{KValue, K2, K3, KN}; @@ -33,15 +34,13 @@ impl ReductionResult for ReductionKColoringToQUBO { } /// Decode one-hot: for each vertex, find which color bit is 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) } } @@ -105,7 +104,9 @@ fn reduce_kcoloring_to_qubo( // Register only the KN variant in the reduction graph #[reduction( - overhead = { num_vars = "num_vertices^2" } + size = exact { + num_vars = "num_vertices * num_colors", + } )] impl ReduceTo> for KColoring { type Result = ReductionKColoringToQUBO; diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index f63ff9770..d9205a4f4 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -24,16 +24,23 @@ impl ReductionResult for ReductionCBMToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Decode the column permutation from x_{c,p} + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_cols * num_cols + num_rows * num_cols + num_rows * num_cols", - num_constraints = "num_cols + num_cols + num_rows * num_cols + num_rows + num_rows * num_cols + 1", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for ConsecutiveBlockMinimization { diff --git a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs index 41a475898..42a159e75 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -25,16 +25,21 @@ impl ReductionResult for ReductionCOMAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_cols * num_cols + 5 * num_rows * num_cols", num_constraints = "num_cols + num_cols + num_rows * num_cols + 2 * num_rows + num_rows + 3 * num_rows * num_cols + 4 * num_rows * num_cols + 1", - } + }, )] impl ReduceTo> for ConsecutiveOnesMatrixAugmentation { type Result = ReductionCOMAToILP; @@ -187,7 +192,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index 0703410b1..bc949eee1 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -22,16 +22,26 @@ impl ReductionResult for ReductionCOSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Output the selection bits s_c (first num_cols variables) - target_solution[..self.num_cols].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Output the selection bits s_c (first num_cols variables) + target_solution[..self.num_cols].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_cols + num_cols * bound + 5 * num_rows * bound", - num_constraints = "1 + num_cols + bound + num_rows * bound + 2 * num_rows + num_rows + 3 * num_rows * bound + 4 * num_rows * bound", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for ConsecutiveOnesSubmatrix { @@ -211,7 +221,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index a900f93de..242c183fc 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -90,31 +90,47 @@ impl ReductionResult for ReductionCDFTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut source_solution = Vec::with_capacity(self.source.num_assignment_variables()); - for object in 0..self.source.num_objects() { - for (attribute, &domain_size) in self.source.attribute_domains().iter().enumerate() { - let value = (0..domain_size) - .find(|&candidate| { - target_solution - .get(self.assignment_var_index(object, attribute, candidate)) - .copied() - .unwrap_or(0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut source_solution = Vec::with_capacity(self.source.num_assignment_variables()); + for object in 0..self.source.num_objects() { + for (attribute, &domain_size) in self.source.attribute_domains().iter().enumerate() + { + let mut selected = (0..domain_size).filter(|&candidate| { + target_solution[self.assignment_var_index(object, attribute, candidate)] == 1 - }) - .unwrap_or(0); - source_solution.push(value); + }); + let value = match (selected.next(), selected.next()) { + (Some(value), None) => value, + (None, _) => { + return Err(crate::rules::ExtractionError::invalid(format!( + "object {object}, attribute {attribute} has no selected value" + ))) + } + (Some(_), Some(_)) => { + return Err(crate::rules::ExtractionError::invalid(format!( + "object {object}, attribute {attribute} has multiple selected values" + ))) + } + }; + source_solution.push(value); + } } - } - source_solution + source_solution + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_assignment_indicators + num_auxiliary_frequency_indicators", num_constraints = "num_assignment_variables + num_known_values + num_frequency_cells + 3 * num_auxiliary_frequency_indicators", - } + }, )] impl ReduceTo> for ConsistencyOfDatabaseFrequencyTables { type Result = ReductionCDFTToILP; diff --git a/src/rules/cost.rs b/src/rules/cost.rs deleted file mode 100644 index 7678d4d87..000000000 --- a/src/rules/cost.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Cost functions for reduction path optimization. - -use crate::rules::registry::ReductionOverhead; -use crate::types::ProblemSize; - -/// User-defined cost function for path optimization. -pub trait PathCostFn { - /// Compute cost of taking an edge given current problem size. - fn edge_cost(&self, overhead: &ReductionOverhead, current_size: &ProblemSize) -> f64; -} - -/// Minimize a single output field. -pub struct Minimize(pub &'static str); - -impl PathCostFn for Minimize { - fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { - overhead.evaluate_output_size(size).get(self.0).unwrap_or(0) as f64 - } -} - -/// Minimize number of reduction steps. -pub struct MinimizeSteps; - -impl PathCostFn for MinimizeSteps { - fn edge_cost(&self, _overhead: &ReductionOverhead, _size: &ProblemSize) -> f64 { - 1.0 - } -} - -/// Minimize total output size (sum of all output field values). -/// -/// Prefers reduction paths that produce smaller intermediate and final problems. -/// Breaks ties that `MinimizeSteps` cannot resolve (e.g., two 2-step paths -/// where one produces 144 ILP variables and the other 1,332). -pub struct MinimizeOutputSize; - -impl PathCostFn for MinimizeOutputSize { - fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { - let output = overhead.evaluate_output_size(size); - output.total() as f64 - } -} - -/// Minimize steps first, then use output size as tiebreaker. -/// -/// Each edge has a primary cost of `STEP_WEIGHT` (ensuring fewer-step paths -/// always win) plus a small overhead-based cost that breaks ties between -/// equal-step paths. -pub struct MinimizeStepsThenOverhead; - -impl PathCostFn for MinimizeStepsThenOverhead { - fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { - // Use a large step weight to ensure step count dominates. - // The overhead tiebreaker uses log1p to compress the range, - // keeping it far smaller than STEP_WEIGHT for any realistic problem size. - const STEP_WEIGHT: f64 = 1e9; - let output = overhead.evaluate_output_size(size); - let overhead_tiebreaker = (1.0 + output.total() as f64).ln(); - STEP_WEIGHT + overhead_tiebreaker - } -} - -/// Custom cost function from closure. -pub struct CustomCost(pub F); - -impl f64> PathCostFn for CustomCost { - fn edge_cost(&self, overhead: &ReductionOverhead, size: &ProblemSize) -> f64 { - (self.0)(overhead, size) - } -} - -#[cfg(test)] -#[path = "../unit_tests/rules/cost.rs"] -mod tests; diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 807b03c34..9246d6333 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -24,12 +24,20 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { num_vertices = "num_vertices", num_edges = "num_edges" })] +#[reduction(size = unavailable { + num_vertices = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", + num_edges = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", +})] impl ReduceTo> for Decision> { diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 26af00e85..c90907454 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -24,13 +24,18 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 963e3f5e1..d263963ae 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -14,7 +14,7 @@ use std::collections::BTreeSet; #[derive(Debug, Clone)] enum ConstructionKind { FixedYes { source_cover: Vec }, - FixedNo { num_source_vertices: usize }, + FixedNo, Theorem(TheoremConstruction), } @@ -182,47 +182,55 @@ impl TheoremConstruction { witness } - fn extract_solution( + fn decode_solution( &self, target_problem: &HamiltonianCircuit, target_solution: &[usize], - ) -> Vec { - let mut source_cover = vec![0; self.num_source_vertices]; - if !target_problem.evaluate(target_solution).0 { - return source_cover; - } - - let mut positions = vec![usize::MAX; target_solution.len()]; - for (idx, &vertex) in target_solution.iter().enumerate() { - if vertex >= positions.len() || positions[vertex] != usize::MAX { - return vec![0; self.num_source_vertices]; + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut source_cover = vec![0; self.num_source_vertices]; + if !target_problem.evaluate(target_solution).0 { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not a Hamiltonian circuit", + )); } - positions[vertex] = idx; - } - let len = target_solution.len(); - let touches_selector = |vertex: usize| { - let idx = positions[vertex]; - let prev = target_solution[(idx + len - 1) % len]; - let next = target_solution[(idx + 1) % len]; - prev < self.selector_count || next < self.selector_count - }; + let mut positions = vec![usize::MAX; target_solution.len()]; + for (idx, &vertex) in target_solution.iter().enumerate() { + if vertex >= positions.len() || positions[vertex] != usize::MAX { + return Err(crate::rules::ExtractionError::invalid( + "target circuit contains an invalid or repeated vertex", + )); + } + positions[vertex] = idx; + } - for vertex in self.active_vertices() { - let Some((start, end)) = self.path_endpoints(vertex) else { - continue; + let len = target_solution.len(); + let touches_selector = |vertex: usize| { + let idx = positions[vertex]; + let prev = target_solution[(idx + len - 1) % len]; + let next = target_solution[(idx + 1) % len]; + prev < self.selector_count || next < self.selector_count }; - if touches_selector(start) && touches_selector(end) { - source_cover[vertex] = 1; + + for vertex in self.active_vertices() { + let Some((start, end)) = self.path_endpoints(vertex) else { + continue; + }; + if touches_selector(start) && touches_selector(end) { + source_cover[vertex] = 1; + } } - } - let selected_count = source_cover.iter().filter(|&&x| x == 1).count(); - if selected_count != self.selector_count || !self.covers_all_edges(&source_cover) { - return vec![0; self.num_source_vertices]; - } + let selected_count = source_cover.iter().filter(|&&x| x == 1).count(); + if selected_count != self.selector_count || !self.covers_all_edges(&source_cover) { + return Err(crate::rules::ExtractionError::invalid( + "target circuit does not encode a source vertex cover of the required size", + )); + } - source_cover + source_cover + }) } } @@ -239,7 +247,7 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { fn build_target_witness(&self, source_cover: &[usize]) -> Vec { match &self.construction { ConstructionKind::FixedYes { .. } => vec![0, 1, 2], - ConstructionKind::FixedNo { .. } => Vec::new(), + ConstructionKind::FixedNo => Vec::new(), ConstructionKind::Theorem(construction) => { construction.build_target_witness(source_cover) } @@ -255,22 +263,33 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match &self.construction { - ConstructionKind::FixedYes { source_cover } => { - if self.target.evaluate(target_solution).0 { - source_cover.clone() - } else { - vec![0; source_cover.len()] + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + match &self.construction { + ConstructionKind::FixedYes { source_cover } => { + if self.target.evaluate(target_solution).0 { + source_cover.clone() + } else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not the fixed Hamiltonian circuit", + )); + } + } + ConstructionKind::FixedNo => { + return Err(crate::rules::ExtractionError::invalid( + "the fixed negative target instance has no extractable witness", + )) + } + ConstructionKind::Theorem(construction) => { + construction.decode_solution(&self.target, target_solution)? } } - ConstructionKind::FixedNo { - num_source_vertices, - } => vec![0; *num_source_vertices], - ConstructionKind::Theorem(construction) => { - construction.extract_solution(&self.target, target_solution) - } - } + }) } } @@ -289,7 +308,7 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { } #[reduction( - overhead = { + size = exact { num_vertices = "12 * num_edges + k", num_edges = "16 * num_edges - num_vertices + 2 * k * num_vertices", } @@ -309,9 +328,7 @@ impl ReduceTo> for Decision> for Decision Vec { - let n = self.num_vertices; - // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 - let perm = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&perm) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_vertices; + // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 + let perm = one_hot_decode(target_solution, n, n, 0)?; + permutation_to_lehmer(&perm) + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2", num_constraints = "3 * num_vertices + (num_vertices - 1) * (num_vertices^2 - num_arcs)", - } + }, )] impl ReduceTo> for DirectedHamiltonianPath { type Result = ReductionDirectedHamiltonianPathToILP; diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index 86f625769..d5d063dd7 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -37,15 +37,23 @@ impl ReductionResult for ReductionD2CIFToILP { } /// Extract flow solution: all 2*|A| variables directly encode the flow. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..2 * self.num_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..2 * self.num_arcs].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "2 * num_arcs", - num_constraints = "num_arcs + 2 * num_vertices + 2", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for DirectedTwoCommodityIntegralFlow { diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 1c4b7f5df..2cbf4b091 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -34,27 +34,37 @@ impl ReductionResult for ReductionDCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Mark an edge selected iff some orientation carries flow for some commodity. - let m = self.edges.len(); - let mut result = vec![0usize; m]; - for k in 0..self.num_commodities { - for e in 0..m { - let fwd = target_solution[k * self.num_edge_vars_per_commodity + 2 * e]; - let rev = target_solution[k * self.num_edge_vars_per_commodity + 2 * e + 1]; - if fwd == 1 || rev == 1 { - result[e] = 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Mark an edge selected iff some orientation carries flow for some commodity. + let m = self.edges.len(); + let mut result = vec![0usize; m]; + for k in 0..self.num_commodities { + for e in 0..m { + let fwd = target_solution[k * self.num_edge_vars_per_commodity + 2 * e]; + let rev = target_solution[k * self.num_edge_vars_per_commodity + 2 * e + 1]; + if fwd == 1 || rev == 1 { + result[e] = 1; + } } } - } - result + result + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_pairs * 2 * num_edges", - num_constraints = "num_pairs * num_vertices + num_pairs * num_edges + num_edges + num_vertices", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for DisjointConnectingPaths { diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index 5701d0039..4018a9265 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -68,67 +68,59 @@ impl ReductionResult for ReductionEulerianPathToILP { /// /// Reads the unique active start arc (`s_a = 1`) and walks the active /// successor relation (`y_{a,b} = 1`) one step at a time, producing an arc - /// permutation of length `m`. If the assignment is malformed (no start, - /// no successor mid-walk, or revisits an arc) we fall back to the identity - /// ordering `0..m` in release builds; debug builds trip a - /// `debug_assert!` to surface the caller bug. Callers must independently - /// check feasibility on the source side via - /// `EulerianPath::is_valid_solution`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let m = self.num_arcs; - if m == 0 { - return Vec::new(); - } - let fallback: Vec = (0..m).collect(); + /// permutation of length `m`. Malformed assignments return an extraction + /// error instead of fabricating an ordering. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // Find the unique active start arc. - let mut current = match (0..m) - .find(|&a| target_solution.get(self.s_idx(a)).copied().unwrap_or(0) == 1) - { - Some(a) => a, - None => { - debug_assert!( - false, - "EulerianPath -> ILP extract_solution: malformed assignment, no active start arc (expected exactly one s_a = 1)", - ); - return fallback; + Ok({ + let m = self.num_arcs; + if m == 0 { + return Ok(Vec::new()); } - }; - // Walk the active successor relation, recording each visited arc. - let mut order = Vec::with_capacity(m); - let mut visited = vec![false; m]; - order.push(current); - visited[current] = true; + // Find the unique active start arc. + let mut current = match (0..m).find(|&a| target_solution[self.s_idx(a)] == 1) { + Some(a) => a, + None => { + return Err(crate::rules::ExtractionError::invalid( + "ILP witness has no active Eulerian-path start arc", + )); + } + }; - for _ in 1..m { - let next = self - .pairs - .iter() - .enumerate() - .find(|&(k, &(a, _))| { - a == current && target_solution.get(k).copied().unwrap_or(0) == 1 - }) - .map(|(_, &(_, b))| b); + // Walk the active successor relation, recording each visited arc. + let mut order = Vec::with_capacity(m); + let mut visited = vec![false; m]; + order.push(current); + visited[current] = true; - match next { - Some(b) if !visited[b] => { - order.push(b); - visited[b] = true; - current = b; - } - _ => { - debug_assert!( - false, - "EulerianPath -> ILP extract_solution: malformed assignment at arc {} (expected exactly one active successor y_{{{},b}} = 1 leading to an unvisited arc)", - current, - current, - ); - return fallback; + for _ in 1..m { + let next = self + .pairs + .iter() + .enumerate() + .find(|&(k, &(a, _))| a == current && target_solution[k] == 1) + .map(|(_, &(_, b))| b); + + match next { + Some(b) if !visited[b] => { + order.push(b); + visited[b] = true; + current = b; + } + _ => { + return Err(crate::rules::ExtractionError::invalid(format!( + "ILP witness has no unvisited successor for arc {current}", + ))); + } } } - } - order + order + }) } } @@ -148,9 +140,9 @@ fn compatible_pairs(arcs: &[(usize, usize)]) -> Vec<(usize, usize)> { } #[reduction( - overhead = { - num_vars = "3 * num_arcs + num_arcs * num_arcs", - num_constraints = "5 * num_arcs + 2 * num_arcs * num_arcs + 2", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for EulerianPath { diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index c931682fc..5c857ed78 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -18,14 +18,22 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vars = "num_sets", -})] +#[reduction( + size = exact { num_variables = "num_sets" }, + unavailable = { + num_equations = "the source size vector does not track per-element incidence degrees", + } +)] impl ReduceTo for ExactCoverBy3Sets { type Result = ReductionX3CToAlgebraicEquationsOverGF2; diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 27a9d654a..f146aed90 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -58,29 +58,29 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { /// 2..2+m (right after the forced-center path edges). For a YES-instance, /// the optimal target witness selects exactly q of these edges, which /// correspond to the q chosen subsets. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let m = self.source_num_subsets; - let root_to_set_offset = 2; - (0..m) - .map(|i| { - usize::from( - target_solution - .get(root_to_set_offset + i) - .copied() - .unwrap_or(0) - == 1, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let m = self.source_num_subsets; + let root_to_set_offset = 2; + (0..m) + .map(|i| usize::from(target_solution[root_to_set_offset + i] == 1)) + .collect() + }) } } -#[reduction(overhead = { - num_vertices = "num_subsets + universe_size + 3", - num_edges = "2 + 4 * num_subsets + num_subsets * (num_subsets - 1) / 2", - weight_bound = "4 * universe_size / 3 + num_subsets + 2", - diameter_bound = "4", -})] +#[reduction( + size = exact { + num_vertices = "num_subsets + universe_size + 3", + num_edges = "2 + 4 * num_subsets + num_subsets * (num_subsets - 1) / 2", + weight_bound = "4 * universe_size / 3 + num_subsets + 2", + diameter_bound = "4", + })] impl ReduceTo> for ExactCoverBy3Sets { type Result = ReductionX3CToBoundedDiameterSpanningTree; @@ -132,7 +132,12 @@ impl ReduceTo> for ExactCoverBy3Se } } - let weight_bound: i32 = (4 * q + m + 2) as i32; + let weight_bound = q + .checked_mul(4) + .and_then(|value| value.checked_add(m)) + .and_then(|value| value.checked_add(2)) + .and_then(|value| i64::try_from(value).ok()) + .expect("ExactCoverBy3Sets -> BoundedDiameterSpanningTree weight bound must fit i64"); let diameter_bound: usize = 4; let graph = SimpleGraph::new(num_vertices, edges); diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index e7a81a0d3..dd09755cc 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -21,16 +21,21 @@ impl ReductionResult for ReductionX3CToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_subsets", num_constraints = "universe_size + 1", - } + }, )] impl ReduceTo> for ExactCoverBy3Sets { type Result = ReductionX3CToILP; diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 8718485c6..490710eba 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -29,14 +29,20 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { /// The configuration is identity (same binary selection vector). /// A packing of q disjoint 3-sets over a 3q-element universe is necessarily /// an exact cover, so no additional checking is needed. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_sets = "num_subsets", -})] +#[reduction( + size = exact { + num_sets = "num_subsets", + })] impl ReduceTo> for ExactCoverBy3Sets { type Result = ReductionXC3SToMaximumSetPacking; diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index a09c2ebbd..e0d1adca9 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -29,19 +29,27 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { /// For YES-instances, every optimal target witness of value q consists only of /// q set-sentences, which form an exact cover. For NO-instances, the extracted /// vector may be non-satisfying, which is expected for an `Or -> Min` rule. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let set_offset = self.source_universe_size; - (0..self.source_num_subsets) - .map(|j| usize::from(target_solution.get(set_offset + j).copied().unwrap_or(0) > 0)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let set_offset = self.source_universe_size; + (0..self.source_num_subsets) + .map(|j| usize::from(target_solution[set_offset + j] > 0)) + .collect() + }) } } -#[reduction(overhead = { - num_sentences = "universe_size + num_subsets", - num_true_sentences = "universe_size + num_subsets", - num_implications = "4 * num_subsets", -})] +#[reduction( + size = exact { + num_sentences = "universe_size + num_subsets", + num_true_sentences = "universe_size + num_subsets", + num_implications = "4 * num_subsets", + })] impl ReduceTo for ExactCoverBy3Sets { type Result = ReductionXC3SToMinimumAxiomSet; diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index 427c9d01f..2a1a137ed 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -24,17 +24,23 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vertices = "num_subsets + universe_size + 1", - num_arcs = "3 * num_subsets + universe_size", - num_inputs = "num_subsets", - num_outputs = "1", -})] +#[reduction( + size = exact { + num_vertices = "num_subsets + universe_size + 1", + num_arcs = "3 * num_subsets + universe_size", + num_inputs = "num_subsets", + num_outputs = "1", + })] impl ReduceTo for ExactCoverBy3Sets { type Result = ReductionXC3SToMinimumFaultDetectionTestSet; diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 68684bc5e..e793a172e 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -33,16 +33,23 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { /// /// StaffScheduling config[j] = number of workers assigned to schedule j. /// XC3S config[j] = 1 if subset j is selected, 0 otherwise. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution - .iter() - .map(|&count| if count > 0 { 1 } else { 0 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + target_solution + .iter() + .map(|&count| if count > 0 { 1 } else { 0 }) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_periods = "universe_size", num_schedules = "num_subsets", num_workers = "universe_size / 3", diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 3b6aa896e..c03fb90bf 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -26,8 +26,13 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -53,9 +58,10 @@ fn assigned_primes(universe_size: usize) -> Vec { } } -#[reduction(overhead = { - num_elements = "num_sets", -})] +#[reduction( + size = exact { + num_elements = "num_sets", + })] impl ReduceTo for ExactCoverBy3Sets { type Result = ReductionX3CToSubsetProduct; diff --git a/src/rules/expectedretrievalcost_ilp.rs b/src/rules/expectedretrievalcost_ilp.rs index 23b285509..c8dc4784b 100644 --- a/src/rules/expectedretrievalcost_ilp.rs +++ b/src/rules/expectedretrievalcost_ilp.rs @@ -17,6 +17,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ExpectedRetrievalCost; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Compute the latency distance between sectors on a circular device. @@ -65,26 +66,21 @@ impl ReductionResult for ReductionERCToILP { } /// Extract solution: for each record r, find the unique sector s where x_{r,s} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_sectors = self.num_sectors; - (0..self.num_records) - .map(|r| { - (0..num_sectors) - .find(|&s| { - let idx = r * num_sectors + s; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_records, self.num_sectors, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_records * num_sectors + num_records^2 * num_sectors^2", num_constraints = "num_records + 3 * num_records^2 * num_sectors^2", - } + }, )] impl ReduceTo> for ExpectedRetrievalCost { type Result = ReductionERCToILP; diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index b000c7c8e..7ae48c8ec 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -42,34 +42,34 @@ impl ReductionResult for ReductionFactoringToCircuit { /// /// Returns a configuration where the first m bits are the first factor p, /// and the next n bits are the second factor q. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let var_names = self.target.variable_names(); - - // Build a map from variable name to its value - let var_map: std::collections::HashMap<&str, usize> = var_names - .iter() - .enumerate() - .map(|(i, name)| (name.as_str(), target_solution.get(i).copied().unwrap_or(0))) - .collect(); - - // Extract p bits - let p_bits: Vec = self - .p_vars - .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) - .collect(); - - // Extract q bits - let q_bits: Vec = self - .q_vars - .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) - .collect(); - - // Concatenate p and q bits - let mut result = p_bits; - result.extend(q_bits); - result + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let var_names = self.target.variable_names(); + + // Build a map from variable name to its value + let var_map: std::collections::HashMap<&str, usize> = var_names + .iter() + .enumerate() + .map(|(i, name)| (name.as_str(), target_solution[i])) + .collect(); + + self.p_vars + .iter() + .chain(&self.q_vars) + .map(|name| { + var_map.get(name.as_str()).copied().ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target circuit does not contain factor variable {name}" + )) + }) + }) + .collect::>>()? + }) } } @@ -175,10 +175,11 @@ fn build_multiplier_cell( (assignments, ancillas) } -#[reduction(overhead = { - num_variables = "6 * num_bits_first * num_bits_second + num_bits_first + num_bits_second", - num_assignments = "6 * num_bits_first * num_bits_second + num_bits_first + num_bits_second", -})] +#[reduction( + size = exact { + num_variables = "6 * num_bits_first * num_bits_second + num_bits_first + num_bits_second", + num_assignments = "6 * num_bits_first * num_bits_second + num_bits_first + num_bits_second", + })] impl ReduceTo for Factoring { type Result = ReductionFactoringToCircuit; diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index a3bffb0e9..303e19fa2 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -75,27 +75,34 @@ impl ReductionResult for ReductionFactoringToILP { /// The first m variables are p_i (first factor bits). /// The next n variables are q_j (second factor bits). /// Returns concatenated bit vector [p_0, ..., p_{m-1}, q_0, ..., q_{n-1}]. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Extract p bits (first factor) - let p_bits: Vec = (0..self.m) - .map(|i| target_solution.get(self.p_var(i)).copied().unwrap_or(0)) - .collect(); - - // Extract q bits (second factor) - let q_bits: Vec = (0..self.n) - .map(|j| target_solution.get(self.q_var(j)).copied().unwrap_or(0)) - .collect(); - - // Concatenate p and q bits - let mut result = p_bits; - result.extend(q_bits); - result + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Extract p bits (first factor) + let p_bits: Vec = (0..self.m) + .map(|i| target_solution[self.p_var(i)]) + .collect(); + + // Extract q bits (second factor) + let q_bits: Vec = (0..self.n) + .map(|j| target_solution[self.q_var(j)]) + .collect(); + + // Concatenate p and q bits + let mut result = p_bits; + result.extend(q_bits); + result + }) } } -#[reduction(overhead = { - num_vars = "num_bits_first * num_bits_second", - num_constraints = "num_bits_first * num_bits_second", +#[reduction(size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for Factoring { type Result = ReductionFactoringToILP; diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index def32c86d..aa77f03d3 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -29,15 +29,21 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } -#[reduction(overhead = { - num_vars = "2 * num_vertices + num_vertices * (num_vertices - 1) / 2", - num_constraints = "3 * num_vertices * (num_vertices - 1) / 2 + 3 * num_vertices + 2 * num_arcs + 2 * num_same_register_pairs", -})] +#[reduction( + size = exact { + num_vars = "2 * num_vertices + num_vertices * (num_vertices - 1) / 2", + num_constraints = "3 * num_vertices * (num_vertices - 1) / 2 + 3 * num_vertices + 2 * num_arcs + 2 * num_same_register_pairs", + },)] impl ReduceTo> for FeasibleRegisterAssignment { type Result = ReductionFeasibleRegisterAssignmentToILP; diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index 7f15251e2..60145ab3b 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -53,27 +53,38 @@ impl ReductionResult for ReductionFSSToILP { /// Extract solution: sort jobs by final-machine completion time C_{j,m-1}, /// then convert permutation to Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_jobs; - let m = self.num_machines; - let c_offset = self.num_order_vars; - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| { - let idx = c_offset + j * m + (m - 1); - (target_solution.get(idx).copied().unwrap_or(0), j) - }); - let perm = permutation_to_lehmer(&jobs); - Self::encode_schedule_as_lehmer(&jobs) - .into_iter() - .zip(perm) - .map(|(lehmer, _)| lehmer) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_jobs; + let m = self.num_machines; + let c_offset = self.num_order_vars; + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| { + let idx = c_offset + j * m + (m - 1); + (target_solution[idx], j) + }); + let perm = permutation_to_lehmer(&jobs); + Self::encode_schedule_as_lehmer(&jobs) + .into_iter() + .zip(perm) + .map(|(lehmer, _)| lehmer) + .collect() + }) } } -#[reduction(overhead = { - num_vars = "num_jobs * (num_jobs - 1) / 2 + num_jobs * num_processors", - num_constraints = "num_jobs * (num_jobs - 1) / 2 + num_jobs + num_jobs * (num_processors - 1) + num_jobs * (num_jobs - 1) * num_processors + num_jobs", +#[reduction( + size = exact { + num_vars = "num_jobs * (num_jobs - 1) / 2 + num_jobs * num_processors", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for FlowShopScheduling { type Result = ReductionFSSToILP; diff --git a/src/rules/graph.rs b/src/rules/graph.rs index ef8a27ff2..6de195665 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1,31 +1,28 @@ //! Runtime reduction graph for discovering and executing reduction paths. //! //! The graph uses variant-level nodes: each node is a unique `(problem_name, variant)` pair. -//! Nodes are built in two phases: -//! 1. From `VariantEntry` inventory (with complexity metadata) -//! 2. From `ReductionEntry` inventory (fallback for backwards compatibility) +//! Nodes come from `VariantEntry` inventory, and `ReductionEntry` inventory supplies edges. //! //! Edges come exclusively from `#[reduction]` registrations via `inventory::iter::`. //! //! This module implements: //! - Variant-level graph construction from `VariantEntry` and `ReductionEntry` inventory -//! - Dijkstra's algorithm with custom cost functions for optimal paths +//! - Symbolic path composition and concrete path execution //! - JSON export for documentation and visualization -use crate::rules::cost::PathCostFn; use crate::rules::registry::{ - AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, + AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionSizeContract, + SizeContractError, }; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; use crate::types::ProblemSize; -use ordered_float::OrderedFloat; use petgraph::algo::all_simple_paths; use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex}; use petgraph::visit::EdgeRef; use serde::Serialize; use std::any::Any; -use std::cmp::Reverse; -use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::rc::Rc; /// A source/target pair from the reduction graph, returned by /// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`]. @@ -35,17 +32,23 @@ pub struct ReductionEdgeInfo { pub source_variant: BTreeMap, pub target_name: &'static str, pub target_variant: BTreeMap, - pub overhead: ReductionOverhead, + pub size_contract: Result, pub capabilities: EdgeCapabilities, } -/// Internal edge data combining overhead and executable reduce function. +/// Internal edge data combining explicit size contracts and executable reduction functions. #[derive(Clone)] pub(crate) struct ReductionEdgeData { - pub overhead: ReductionOverhead, + pub size_contract: Result, pub reduce_fn: Option, pub reduce_aggregate_fn: Option, - pub capabilities: EdgeCapabilities, + pub turing: bool, +} + +impl ReductionEdgeData { + fn capabilities(&self) -> EdgeCapabilities { + EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing) + } } /// JSON-serializable representation of the reduction graph. @@ -78,8 +81,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). @@ -93,13 +96,13 @@ struct VariantRef { variant: BTreeMap, } -/// A single output field in the reduction overhead. +/// One explicitly classified target size field in graph export. #[derive(Debug, Clone, Serialize)] -pub(crate) struct OverheadFieldJson { - /// Output field name (e.g., "num_vars"). +pub(crate) struct SizeFieldJson { pub(crate) field: String, - /// Formula as a human-readable string (e.g., "num_vertices"). - pub(crate) formula: String, + pub(crate) contract: &'static str, + pub(crate) formula: Option, + pub(crate) reason: Option, } /// An edge in the reduction graph JSON. @@ -109,8 +112,9 @@ pub(crate) struct EdgeJson { pub(crate) source: usize, /// Index into the `nodes` array for the target problem variant. pub(crate) target: usize, - /// Reduction overhead: output size as expressions of input size. - pub(crate) overhead: Vec, + /// Symbolic or unavailable target-size fields. + pub(crate) size_fields: Vec, + pub(crate) size_contract_error: Option, /// Relative rustdoc path for the reduction module. pub(crate) doc_path: String, /// Whether the edge supports witness/config workflows. @@ -128,6 +132,84 @@ pub struct ReductionPath { pub steps: Vec, } +/// A selected concrete path batch could not be executed. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ExecutePathsError { + #[error("concrete path {path_index} is empty")] + EmptyPath { path_index: usize }, + #[error("concrete path {path_index} contains no reduction edge")] + NoEdges { path_index: usize }, + #[error("concrete path {path_index} starts at a different source node")] + DifferentSource { path_index: usize }, + #[error("concrete path {path_index} references unknown node {problem} {variant:?}")] + UnknownNode { + path_index: usize, + problem: String, + variant: BTreeMap, + }, + #[error("concrete path {path_index} has no registered edge from {source_problem} to {target_problem}")] + MissingEdge { + path_index: usize, + source_problem: String, + target_problem: String, + }, + #[error("concrete path {path_index} edge {source_problem} -> {target_problem} is not witness-executable")] + NotWitnessExecutable { + path_index: usize, + source_problem: String, + target_problem: String, + }, +} + +/// Why symbolic size propagation could not be completed for a path. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PathSizeError { + #[error("cannot compose an empty reduction path")] + EmptyPath, + #[error("reduction path references unknown node {problem} {variant:?}")] + UnknownNode { + problem: String, + variant: BTreeMap, + }, + #[error( + "reduction path contains no registered edge from {source_problem} to {target_problem}" + )] + MissingEdge { + source_problem: String, + target_problem: String, + }, + #[error("reduction step {step} ({source_problem} -> {target_problem}) is a multi-query reduction without a query-cost model")] + TuringEdge { + step: usize, + source_problem: String, + target_problem: String, + }, + #[error("reduction step {step} ({source_problem} -> {target_problem}) has an invalid size contract: {error}")] + InvalidContract { + step: usize, + source_problem: String, + target_problem: String, + #[source] + error: Box, + }, + #[error("reduction step {step} ({source_problem} -> {target_problem}) has no symbolic size transform")] + Unavailable { + step: usize, + source_problem: String, + target_problem: String, + }, + #[error( + "cannot compose reduction step {step} ({source_problem} -> {target_problem}): {error}" + )] + Step { + step: usize, + source_problem: String, + target_problem: String, + #[source] + error: Box, + }, +} + impl ReductionPath { /// Number of edges (reductions) in the path. pub fn len(&self) -> usize { @@ -183,7 +265,7 @@ impl std::fmt::Display for ReductionPath { } /// A node in a variant-level reduction path. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize)] pub struct ReductionStep { /// Problem name (e.g., "MaximumIndependentSet"). pub name: String, @@ -206,20 +288,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 { @@ -278,7 +346,6 @@ pub struct NeighborTree { /// /// The graph supports: /// - Auto-discovery of reductions from `inventory::iter::` -/// - Dijkstra with custom cost functions /// - Path finding by problem type or by name pub struct ReductionGraph { /// Graph with node indices as node data, edge weights as ReductionEdgeData. @@ -353,37 +420,25 @@ impl ReductionGraph { let source_variant = Self::variant_to_map(&entry.source_variant()); let target_variant = Self::variant_to_map(&entry.target_variant()); - // Nodes should already exist from Phase 1. - // Fall back to creating them with empty complexity for backwards compatibility. - let src_idx = ensure_node( - entry.source_name, - source_variant, - "", - &mut nodes, - &mut graph, - &mut node_index, - &mut name_to_nodes, - ); - let dst_idx = ensure_node( - entry.target_name, - target_variant, - "", - &mut nodes, - &mut graph, - &mut node_index, - &mut name_to_nodes, - ); + let src_idx = node_index[&VariantRef { + name: entry.source_name.to_string(), + variant: source_variant, + }]; + let dst_idx = node_index[&VariantRef { + name: entry.target_name.to_string(), + variant: target_variant, + }]; - let overhead = entry.overhead(); + let size_contract = entry.size_contract(); if graph.find_edge(src_idx, dst_idx).is_none() { graph.add_edge( src_idx, dst_idx, ReductionEdgeData { - overhead, + size_contract, reduce_fn: entry.reduce_fn, reduce_aggregate_fn: entry.reduce_aggregate_fn, - capabilities: entry.capabilities, + turing: entry.turing, }, ); } @@ -424,12 +479,31 @@ impl ReductionGraph { fn edge_supports_mode(edge: &ReductionEdgeData, mode: ReductionMode) -> bool { match mode { - ReductionMode::Witness => edge.capabilities.witness, - ReductionMode::Aggregate => edge.capabilities.aggregate, - ReductionMode::Turing => edge.capabilities.turing, + ReductionMode::Witness => edge.reduce_fn.is_some(), + ReductionMode::Aggregate => edge.reduce_aggregate_fn.is_some(), + ReductionMode::Turing => edge.turing, } } + fn ordered_outgoing_edges( + &self, + node: NodeIndex, + mode: ReductionMode, + ) -> Vec<(NodeIndex, EdgeIndex)> { + let mut edges: Vec<_> = self + .graph + .edges(node) + .filter(|edge| Self::edge_supports_mode(edge.weight(), mode)) + .map(|edge| (edge.target(), edge.id())) + .collect(); + edges.sort_by(|a, b| { + let a = &self.nodes[self.graph[a.0]]; + let b = &self.nodes[self.graph[b.0]]; + (a.name, &a.variant).cmp(&(b.name, &b.variant)) + }); + edges + } + fn node_path_supports_mode(&self, node_path: &[NodeIndex], mode: ReductionMode) -> bool { node_path.windows(2).all(|pair| { self.graph @@ -438,112 +512,6 @@ impl ReductionGraph { }) } - /// Find the cheapest path between two specific problem variants. - /// - /// Uses Dijkstra's algorithm on the variant-level graph from the exact - /// source variant node to the exact target variant node. - pub fn find_cheapest_path( - &self, - source: &str, - source_variant: &BTreeMap, - target: &str, - target_variant: &BTreeMap, - input_size: &ProblemSize, - cost_fn: &C, - ) -> Option { - self.find_cheapest_path_mode( - source, - source_variant, - target, - target_variant, - ReductionMode::Witness, - input_size, - cost_fn, - ) - } - - /// Find the cheapest path between two specific problem variants while - /// requiring a specific edge capability. - #[allow(clippy::too_many_arguments)] - pub fn find_cheapest_path_mode( - &self, - source: &str, - source_variant: &BTreeMap, - target: &str, - target_variant: &BTreeMap, - mode: ReductionMode, - input_size: &ProblemSize, - cost_fn: &C, - ) -> Option { - let src = self.lookup_node(source, source_variant)?; - let dst = self.lookup_node(target, target_variant)?; - let node_path = self.dijkstra(src, dst, mode, input_size, cost_fn)?; - Some(self.node_path_to_reduction_path(&node_path)) - } - - /// Core Dijkstra search on node indices. - fn dijkstra( - &self, - src: NodeIndex, - dst: NodeIndex, - mode: ReductionMode, - input_size: &ProblemSize, - cost_fn: &C, - ) -> Option> { - let mut costs: HashMap = HashMap::new(); - let mut sizes: HashMap = HashMap::new(); - let mut prev: HashMap = HashMap::new(); - let mut heap = BinaryHeap::new(); - - costs.insert(src, 0.0); - sizes.insert(src, input_size.clone()); - heap.push(Reverse((OrderedFloat(0.0), src))); - - while let Some(Reverse((cost, node))) = heap.pop() { - if node == dst { - let mut path = vec![dst]; - let mut current = dst; - while current != src { - let &prev_node = prev.get(¤t)?; - path.push(prev_node); - current = prev_node; - } - path.reverse(); - return Some(path); - } - - if cost.0 > *costs.get(&node).unwrap_or(&f64::INFINITY) { - continue; - } - - let current_size = match sizes.get(&node) { - Some(s) => s.clone(), - None => continue, - }; - - for edge_ref in self.graph.edges(node) { - if !Self::edge_supports_mode(edge_ref.weight(), mode) { - continue; - } - let overhead = &edge_ref.weight().overhead; - let next = edge_ref.target(); - - let edge_cost = cost_fn.edge_cost(overhead, ¤t_size); - let new_cost = cost.0 + edge_cost; - let new_size = overhead.evaluate_output_size(¤t_size); - - if new_cost < *costs.get(&next).unwrap_or(&f64::INFINITY) { - costs.insert(next, new_cost); - sizes.insert(next, new_size); - prev.insert(next, node); - heap.push(Reverse((OrderedFloat(new_cost), next))); - } - } - } - - None - } - /// Convert a node index path to a `ReductionPath`. fn node_path_to_reduction_path(&self, node_path: &[NodeIndex]) -> ReductionPath { let steps = node_path @@ -679,19 +647,44 @@ impl ReductionGraph { None => return vec![], }; - let paths: Vec> = all_simple_paths::< - Vec, - _, - std::hash::RandomState, - >(&self.graph, src, dst, 0, max_intermediate_nodes) - .take(limit) - .collect(); + if limit == 0 { + return Vec::new(); + } + // Enumerate simple paths breadth-first. Each level is already lexicographic + // because both the preceding level and every outgoing edge list are ordered + // by canonical node identity. Completed paths therefore arrive in the exact + // public order: fewest nodes first, then canonical node identity. Stop immediately + // after `limit` results instead of traversing every simple path. + let max_intermediate = + max_intermediate_nodes.unwrap_or_else(|| self.graph.node_count().saturating_sub(2)); + let max_nodes = max_intermediate.saturating_add(2); + let mut frontier = vec![vec![src]]; + let mut paths = Vec::with_capacity(limit); + + while !frontier.is_empty() && frontier[0].len() < max_nodes { + let mut next_frontier = Vec::new(); + for path in frontier { + let current = path[path.len() - 1]; + for (next, _) in self.ordered_outgoing_edges(current, mode) { + if path.contains(&next) { + continue; + } + let mut extended = path.clone(); + extended.push(next); + if next == dst { + paths.push(self.node_path_to_reduction_path(&extended)); + if paths.len() == limit { + return paths; + } + } else { + next_frontier.push(extended); + } + } + } + frontier = next_frontier; + } paths - .iter() - .filter(|p| self.node_path_supports_mode(p, mode)) - .map(|p| self.node_path_to_reduction_path(p)) - .collect() } /// Check if a direct reduction exists from S to T. @@ -784,50 +777,101 @@ impl ReductionGraph { self.nodes.len() } - /// Get the per-edge overhead expressions along a reduction path. - /// - /// Returns one `ReductionOverhead` per edge (i.e., `path.steps.len() - 1` items). - /// - /// Panics if any step in the path does not correspond to an edge in the graph. - pub fn path_overheads(&self, path: &ReductionPath) -> Vec { + /// Return the symbolic size transform for every edge of a path. + pub fn path_size_transforms( + &self, + path: &ReductionPath, + ) -> Result, PathSizeError> { if path.steps.len() <= 1 { - return vec![]; + return Ok(vec![]); } let node_indices: Vec = path .steps .iter() .map(|step| { - self.lookup_node(&step.name, &step.variant) - .unwrap_or_else(|| panic!("Node not found: {} {:?}", step.name, step.variant)) + self.lookup_node(&step.name, &step.variant).ok_or_else(|| { + PathSizeError::UnknownNode { + problem: step.name.clone(), + variant: step.variant.clone(), + } + }) }) - .collect(); + .collect::>()?; node_indices .windows(2) - .map(|pair| { - let edge_idx = self.graph.find_edge(pair[0], pair[1]).unwrap_or_else(|| { - let src = &self.nodes[self.graph[pair[0]]]; - let dst = &self.nodes[self.graph[pair[1]]]; - panic!( - "No edge from {} {:?} to {} {:?}", - src.name, src.variant, dst.name, dst.variant - ) - }); - self.graph[edge_idx].overhead.clone() + .enumerate() + .map(|(index, pair)| { + let edge_idx = self.graph.find_edge(pair[0], pair[1]).ok_or_else(|| { + PathSizeError::MissingEdge { + source_problem: path.steps[index].name.clone(), + target_problem: path.steps[index + 1].name.clone(), + } + })?; + if self.graph[edge_idx].turing { + return Err(PathSizeError::TuringEdge { + step: index + 1, + source_problem: path.steps[index].name.clone(), + target_problem: path.steps[index + 1].name.clone(), + }); + } + let contract = self.graph[edge_idx] + .size_contract + .as_ref() + .map_err(|error| PathSizeError::InvalidContract { + step: index + 1, + source_problem: path.steps[index].name.clone(), + target_problem: path.steps[index + 1].name.clone(), + error: Box::new(error.clone()), + })?; + contract + .transform() + .cloned() + .ok_or_else(|| PathSizeError::Unavailable { + step: index + 1, + source_problem: path.steps[index].name.clone(), + target_problem: path.steps[index + 1].name.clone(), + }) }) .collect() } - /// Compose overheads along a path symbolically. - /// - /// Returns a single `ReductionOverhead` whose expressions map from the - /// source problem's size variables directly to the final target's size variables. - pub fn compose_path_overhead(&self, path: &ReductionPath) -> ReductionOverhead { - self.path_overheads(path) - .into_iter() - .reduce(|acc, oh| acc.compose(&oh)) - .unwrap_or_default() + /// Compose symbolic size transforms along a path. + pub fn compose_path_size_transform( + &self, + path: &ReductionPath, + ) -> Result, PathSizeError> { + if path.steps.is_empty() { + return Err(PathSizeError::EmptyPath); + } + if path.steps.len() == 1 { + return Ok(None); + } + + let mut transforms = self.path_size_transforms(path)?.into_iter(); + let Some(mut composed) = transforms.next() else { + return Ok(None); + }; + for (offset, transform) in transforms.enumerate() { + let edge_index = offset + 1; + composed = composed + .compose( + &transform, + format!( + "{} -> {}", + path.steps[0].name, + path.steps[edge_index + 1].name + ), + ) + .map_err(|error| PathSizeError::Step { + step: edge_index + 1, + source_problem: path.steps[edge_index].name.clone(), + target_problem: path.steps[edge_index + 1].name.clone(), + error: Box::new(error), + })?; + } + Ok(Some(composed)) } /// Get all variant maps registered for a problem name. @@ -901,8 +945,40 @@ impl ReductionGraph { source_variant: src.variant.clone(), target_name: dst.name, target_variant: dst.variant.clone(), - overhead: self.graph[e.id()].overhead.clone(), - capabilities: self.graph[e.id()].capabilities, + size_contract: self.graph[e.id()].size_contract.clone(), + capabilities: self.graph[e.id()].capabilities(), + } + }) + .collect() + } + + /// Get executable outgoing reductions from one exact problem variant. + /// + /// # Panics + /// + /// Panics if `name` and `variant` do not identify an exactly registered problem variant. + pub fn outgoing_reductions_from( + &self, + name: &str, + variant: &BTreeMap, + mode: ReductionMode, + ) -> Vec { + let source = self + .lookup_node(name, variant) + .unwrap_or_else(|| panic!("registered problem variant not found: {name} {variant:?}")); + + self.ordered_outgoing_edges(source, mode) + .into_iter() + .map(|(target, edge)| { + let src = &self.nodes[self.graph[source]]; + let dst = &self.nodes[self.graph[target]]; + ReductionEdgeInfo { + source_name: src.name, + source_variant: src.variant.clone(), + target_name: dst.name, + target_variant: dst.variant.clone(), + size_contract: self.graph[edge].size_contract.clone(), + capabilities: self.graph[edge].capabilities(), } }) .collect() @@ -910,72 +986,104 @@ impl ReductionGraph { /// Get the problem size field names for a problem type. /// - /// Derives size fields from the overhead expressions of reduction entries + /// Derives size fields from the explicit size contracts of reduction entries /// where this problem appears as source or target. When the problem is a - /// source, its size fields are the input variables referenced in the overhead + /// source, its size fields are the input variables referenced in size /// expressions. When it's a target, its size fields are the output field names. - pub fn size_field_names(&self, name: &str) -> Vec<&'static str> { - let mut fields: std::collections::HashSet<&'static str> = + pub fn size_field_names(&self, name: &str) -> Vec { + let mut fields: std::collections::HashSet = crate::registry::declared_size_fields(name) .into_iter() + .map(str::to_string) .collect(); for entry in inventory::iter:: { + let declarations = (entry.size_declarations_fn)(); if entry.source_name == name { - // Source's size fields are the input variables of the overhead. - fields.extend(entry.overhead().input_variable_names()); + fields.extend( + declarations + .fields + .iter() + .flat_map(|(_, expression)| expression.variables()) + .map(str::to_string), + ); } if entry.target_name == name { - // Target's size fields are the output field names. - let overhead = entry.overhead(); - fields.extend(overhead.output_size.iter().map(|(name, _)| *name)); + fields.extend( + declarations + .fields + .iter() + .map(|(field, _)| (*field).to_string()), + ); + fields.extend( + declarations + .unavailable + .iter() + .map(|field| field.field.to_string()), + ); } } - let mut result: Vec<&'static str> = fields.into_iter().collect(); + let mut result: Vec = fields.into_iter().collect(); result.sort_unstable(); result } - /// Evaluate the cumulative output size along a reduction path. - /// - /// Walks the path from start to end, applying each edge's overhead - /// expressions to transform the problem size at each step. - /// Returns `None` if any edge in the path cannot be found. - pub fn evaluate_path_overhead( + /// Evaluate every symbolic size transform along a reduction path. + pub fn evaluate_path_size( &self, path: &ReductionPath, input_size: &ProblemSize, - ) -> Option { - let mut current_size = input_size.clone(); - for pair in path.steps.windows(2) { - let src = self.lookup_node(&pair[0].name, &pair[0].variant)?; - let dst = self.lookup_node(&pair[1].name, &pair[1].variant)?; - let edge_idx = self.graph.find_edge(src, dst)?; - let edge = &self.graph[edge_idx]; - current_size = edge.overhead.evaluate_output_size(¤t_size); + ) -> Result { + let mut current = crate::size::EvaluatedSize::from_problem_size(input_size); + for (index, transform) in self.path_size_transforms(path)?.iter().enumerate() { + current = transform + .evaluate(¤t) + .map_err(|error| PathSizeError::Step { + step: index + 1, + source_problem: path.steps[index].name.clone(), + target_problem: path.steps[index + 1].name.clone(), + error: Box::new(error), + })?; } - Some(current_size) + Ok(current) } - /// Compute the source problem's size from a type-erased instance. + /// Measure every size field used by a rule at this exact problem variant. /// - /// Iterates over all registered reduction entries with a matching source name - /// and merges their `source_size_fn` results to capture all size fields. - /// Different entries may reference different getter methods (e.g., one uses - /// `num_vertices` while another also uses `num_edges`). - pub fn compute_source_size(name: &str, instance: &dyn Any) -> ProblemSize { + /// Both sides of each rule contribute getters. In particular, a sink variant is + /// measured from incoming rules instead of incorrectly producing an empty size. + pub fn compute_problem_size( + name: &str, + variant: &BTreeMap, + instance: &dyn Any, + ) -> ProblemSize { let mut merged: Vec<(String, usize)> = Vec::new(); let mut seen: HashSet = HashSet::new(); + let variant_matches = |entry_variant: Vec<(&str, &str)>| { + let variant_matches = entry_variant.len() == variant.len() + && entry_variant.iter().all(|(key, value)| { + let value = if *key == "graph" && value.is_empty() { + "SimpleGraph" + } else { + value + }; + variant.get(*key).is_some_and(|expected| expected == value) + }); + variant_matches + }; + for entry in inventory::iter:: { - if entry.source_name == name { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - (entry.source_size_fn)(instance) - })); - if let Ok(size) = result { - for (k, v) in size.components { - if seen.insert(k.clone()) { - merged.push((k, v)); - } + let measured = if entry.source_name == name && variant_matches(entry.source_variant()) { + Some((entry.source_size_measure_fn)(instance)) + } else if entry.target_name == name && variant_matches(entry.target_variant()) { + Some((entry.target_size_measure_fn)(instance)) + } else { + None + }; + if let Some(measured) = measured { + for (k, v) in measured.components { + if seen.insert(k.clone()) { + merged.push((k, v)); } } } @@ -1000,8 +1108,8 @@ impl ReductionGraph { source_variant: src.variant.clone(), target_name: dst.name, target_variant: dst.variant.clone(), - overhead: self.graph[e.id()].overhead.clone(), - capabilities: self.graph[e.id()].capabilities, + size_contract: self.graph[e.id()].size_contract.clone(), + capabilities: self.graph[e.id()].capabilities(), } }) .collect() @@ -1165,11 +1273,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 @@ -1177,21 +1286,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(), }, ) @@ -1212,17 +1320,35 @@ impl ReductionGraph { for edge_ref in self.graph.edge_references() { let src_node_id = self.graph[edge_ref.source()]; let dst_node_id = self.graph[edge_ref.target()]; - let overhead = &edge_ref.weight().overhead; - let capabilities = edge_ref.weight().capabilities; - - let overhead_fields = overhead - .output_size - .iter() - .map(|(field, poly)| OverheadFieldJson { - field: field.to_string(), - formula: poly.to_string(), - }) - .collect(); + let contract = &edge_ref.weight().size_contract; + let capabilities = edge_ref.weight().capabilities(); + + let mut size_fields = Vec::new(); + if let Ok(contract) = contract { + if let Some(transform) = contract.transform() { + let relation = match transform.relation() { + crate::size::SizeRelation::Exact => "exact", + crate::size::SizeRelation::UpperBound => "upper_bound", + }; + size_fields.extend(transform.expressions().map(|(field, expression)| { + SizeFieldJson { + field: field.to_string(), + contract: relation, + formula: Some(expression.to_string()), + reason: None, + } + })); + } + size_fields.extend(contract.unavailable().iter().map(|unavailable| { + SizeFieldJson { + field: unavailable.field.to_string(), + contract: "unavailable", + formula: None, + reason: Some(unavailable.reason.to_string()), + } + })); + } + let size_contract_error = contract.as_ref().err().map(ToString::to_string); // Find the doc_path from the matching ReductionEntry let src_name = self.nodes[src_node_id].name; @@ -1235,7 +1361,8 @@ impl ReductionGraph { edges.push(EdgeJson { source: old_to_new[&src_node_id], target: old_to_new[&dst_node_id], - overhead: overhead_fields, + size_fields, + size_contract_error, doc_path, witness: capabilities.witness, aggregate: capabilities.aggregate, @@ -1306,13 +1433,6 @@ impl ReductionGraph { format!("{}/index.html", stripped.replace("::", "/")) } - /// Extract the category from a module path. - /// - /// E.g., `"problemreductions::models::graph::maximum_independent_set"` -> `"graph"`. - fn category_from_module_path(module_path: &str) -> String { - classify_problem_category(module_path).to_string() - } - /// Build the rustdoc path from a module path and problem name. /// /// E.g., `"problemreductions::models::graph::maximum_independent_set"`, `"MaximumIndependentSet"` @@ -1334,7 +1454,7 @@ impl ReductionGraph { /// Returns `Some(MatchedEntry)` only when both the source and target variants /// match exactly. No fallback is attempted — callers that need fuzzy matching /// should resolve variants before calling this method. - pub fn find_best_entry( + pub fn find_entry( &self, source_name: &str, source_variant: &BTreeMap, @@ -1354,7 +1474,7 @@ impl ReductionGraph { return Some(MatchedEntry { source_variant: entry_source, target_variant: entry_target, - overhead: entry.overhead(), + size_contract: entry.size_contract(), }); } } @@ -1363,14 +1483,14 @@ impl ReductionGraph { } } -/// A matched reduction entry returned by [`ReductionGraph::find_best_entry`]. +/// A matched reduction entry returned by [`ReductionGraph::find_entry`]. pub struct MatchedEntry { /// The entry's source variant. pub source_variant: BTreeMap, /// The entry's target variant. pub target_variant: BTreeMap, - /// The overhead of the reduction. - pub overhead: ReductionOverhead, + /// The reduction's explicit size contract. + pub size_contract: Result, } /// A composed reduction chain produced by [`ReductionGraph::reduce_along_path`]. @@ -1401,13 +1521,15 @@ impl ReductionChain { } /// Extract a solution from target space back to source space. - pub fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.steps - .iter() - .rev() - .fold(target_solution.to_vec(), |sol, step| { - step.extract_solution_dyn(&sol) - }) + pub fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + let mut solution = target_solution.to_vec(); + for step in self.steps.iter().rev() { + solution = step.extract_solution_dyn(&solution)?; + } + Ok(solution) } } @@ -1444,20 +1566,6 @@ impl AggregateReductionChain { } } -struct WitnessBackedIdentityAggregateStep { - inner: Box, -} - -impl DynAggregateReductionResult for WitnessBackedIdentityAggregateStep { - fn target_problem_any(&self) -> &dyn Any { - self.inner.target_problem_any() - } - - fn extract_value_dyn(&self, target_value: serde_json::Value) -> serde_json::Value { - target_value - } -} - impl ReductionGraph { fn execute_aggregate_edge( &self, @@ -1469,18 +1577,7 @@ impl ReductionGraph { return None; } - if let Some(edge_fn) = edge.reduce_aggregate_fn { - return Some(edge_fn(input)); - } - - if edge.capabilities.witness && edge.capabilities.aggregate { - let edge_fn = edge.reduce_fn?; - return Some(Box::new(WitnessBackedIdentityAggregateStep { - inner: edge_fn(input), - })); - } - - None + Some(edge.reduce_aggregate_fn?(input)) } /// Execute a reduction path on a source problem instance. @@ -1561,6 +1658,191 @@ impl ReductionGraph { } } +/// A concrete reduction path whose reductions have already been executed. +/// +/// The constructed chain is retained so callers can inspect target sizes and +/// extract solutions without re-executing any reduction. +pub struct ExecutedPath { + /// The variant-level path. + pub path: ReductionPath, + /// The executed reduction steps (one per hop), shared via `Rc`. + steps: Vec>, +} + +impl ExecutedPath { + /// Get the final target problem as a type-erased reference. + pub fn target_problem_any(&self) -> &dyn Any { + self.steps + .last() + .expect("ExecutedPath has no steps") + .target_problem_any() + } + + /// Return the size of every concrete intermediate target in this path. + pub fn target_sizes(&self) -> Vec { + self.steps + .iter() + .zip(self.path.steps.iter().skip(1)) + .map(|(result, target)| { + ReductionGraph::compute_problem_size( + &target.name, + &target.variant, + result.target_problem_any(), + ) + }) + .collect() + } + + /// Extract a solution from target space back to source space. + pub fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + let mut solution = target_solution.to_vec(); + for step in self.steps.iter().rev() { + solution = step.extract_solution_dyn(&solution)?; + } + Ok(solution) + } +} + +impl ReductionGraph { + /// Execute a selected batch of witness paths while sharing every common prefix. + pub fn execute_paths( + &self, + paths: &[ReductionPath], + source_instance: &dyn Any, + ) -> Result, ExecutePathsError> { + let mut prefixes: HashMap, Vec>> = + HashMap::new(); + let mut executed = Vec::with_capacity(paths.len()); + let mut batch_source: Option<&ReductionStep> = None; + for (path_index, path) in paths.iter().enumerate() { + let source = path + .steps + .first() + .ok_or(ExecutePathsError::EmptyPath { path_index })?; + if path.steps.len() < 2 { + return Err(ExecutePathsError::NoEdges { path_index }); + } + if let Some(expected) = batch_source { + if source != expected { + return Err(ExecutePathsError::DifferentSource { path_index }); + } + } else { + batch_source = Some(source); + } + let source_prefix = vec![source.clone()]; + let mut chain = prefixes.get(&source_prefix).cloned().unwrap_or_default(); + prefixes.entry(source_prefix.clone()).or_default(); + let mut prefix = source_prefix; + for pair in path.steps.windows(2) { + prefix.push(pair[1].clone()); + if let Some(cached) = prefixes.get(&prefix) { + chain = cached.clone(); + continue; + } + let source_node = self + .lookup_node(&pair[0].name, &pair[0].variant) + .ok_or_else(|| ExecutePathsError::UnknownNode { + path_index, + problem: pair[0].name.clone(), + variant: pair[0].variant.clone(), + })?; + let target_node_index = self + .lookup_node(&pair[1].name, &pair[1].variant) + .ok_or_else(|| ExecutePathsError::UnknownNode { + path_index, + problem: pair[1].name.clone(), + variant: pair[1].variant.clone(), + })?; + let edge_index = self + .graph + .find_edge(source_node, target_node_index) + .ok_or_else(|| ExecutePathsError::MissingEdge { + path_index, + source_problem: pair[0].name.clone(), + target_problem: pair[1].name.clone(), + })?; + let edge_data = &self.graph[edge_index]; + let Some(reduce_fn) = edge_data.reduce_fn else { + return Err(ExecutePathsError::NotWitnessExecutable { + path_index, + source_problem: pair[0].name.clone(), + target_problem: pair[1].name.clone(), + }); + }; + let current = chain + .last() + .map(|step| step.target_problem_any()) + .unwrap_or(source_instance); + chain.push(Rc::from(reduce_fn(current))); + prefixes.insert(prefix.clone(), chain.clone()); + } + executed.push(ExecutedPath { + path: path.clone(), + steps: chain, + }); + } + Ok(executed) + } +} + +#[cfg(test)] +impl ReductionGraph { + /// Build a bare reduction graph from an explicit node/edge list (test-only). + /// + /// Nodes carry the empty variant and empty complexity; each edge carries a + /// [`ReductionEdgeData`] without depending on registered inventory. + pub(crate) fn from_test_edges( + node_names: &[&'static str], + edges: &[(&'static str, &'static str, ReductionEdgeData)], + ) -> Self { + Self::from_test_variant_edges( + &node_names + .iter() + .map(|&name| (name, BTreeMap::new())) + .collect::>(), + edges, + ) + } + + pub(crate) fn from_test_variant_edges( + test_nodes: &[(&'static str, BTreeMap)], + edges: &[(&'static str, &'static str, ReductionEdgeData)], + ) -> Self { + let mut graph: DiGraph = DiGraph::new(); + let mut nodes: Vec = Vec::new(); + let mut name_to_nodes: HashMap<&'static str, Vec> = HashMap::new(); + let mut index_of: HashMap<&'static str, NodeIndex> = HashMap::new(); + + for (name, variant) in test_nodes { + let node_id = nodes.len(); + nodes.push(VariantNode { + name, + variant: variant.clone(), + complexity: "", + }); + let idx = graph.add_node(node_id); + index_of.insert(name, idx); + name_to_nodes.entry(name).or_default().push(idx); + } + + for (src, dst, data) in edges { + let s = index_of[src]; + let d = index_of[dst]; + graph.add_edge(s, d, data.clone()); + } + + Self { + graph, + nodes, + name_to_nodes, + default_variants: HashMap::new(), + } + } +} + #[cfg(test)] #[path = "../unit_tests/rules/graph.rs"] mod tests; @@ -1569,11 +1851,11 @@ mod tests; #[path = "../unit_tests/rules/reduction_path_parity.rs"] mod reduction_path_parity_tests; -#[cfg(all(test, feature = "ilp-solver"))] +#[cfg(test)] #[path = "../unit_tests/rules/maximumindependentset_ilp.rs"] mod maximumindependentset_ilp_path_tests; -#[cfg(all(test, feature = "ilp-solver"))] +#[cfg(test)] #[path = "../unit_tests/rules/minimumvertexcover_ilp.rs"] mod minimumvertexcover_ilp_path_tests; diff --git a/src/rules/graph_helpers.rs b/src/rules/graph_helpers.rs index bdc02ae88..cf3594ab2 100644 --- a/src/rules/graph_helpers.rs +++ b/src/rules/graph_helpers.rs @@ -6,21 +6,33 @@ use crate::topology::{Graph, SimpleGraph}; /// /// Given a graph and a binary `target_solution` over its edges (1 = selected), /// walks the selected edges to produce a vertex permutation representing the cycle. -/// Returns `vec![0; n]` if the selection does not form a valid Hamiltonian cycle. -pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize]) -> Vec { +/// Returns an error if the selection does not form a valid Hamiltonian cycle. +pub(crate) fn edges_to_cycle_order( + graph: &G, + target_solution: &[usize], +) -> crate::rules::ExtractionResult> { let n = graph.num_vertices(); if n == 0 { - return vec![]; + return Ok(vec![]); } let edges = graph.edges(); if target_solution.len() != edges.len() { - return vec![0; n]; + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} edge-selection values, got {}", + edges.len(), + target_solution.len() + ))); } let mut adjacency = vec![Vec::new(); n]; let mut selected_count = 0usize; for (idx, &selected) in target_solution.iter().enumerate() { + if selected > 1 { + return Err(crate::rules::ExtractionError::invalid( + "edge-selection values must be binary", + )); + } if selected != 1 { continue; } @@ -31,14 +43,23 @@ pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize } if selected_count != n || adjacency.iter().any(|neighbors| neighbors.len() != 2) { - return vec![0; n]; + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form a Hamiltonian cycle", + )); } let mut order = Vec::with_capacity(n); + let mut visited = vec![false; n]; let mut prev = None; let mut current = 0usize; for _ in 0..n { + if visited[current] { + return Err(crate::rules::ExtractionError::invalid( + "selected edges contain multiple disjoint cycles", + )); + } + visited[current] = true; order.push(current); let neighbors = &adjacency[current]; let next = match prev { @@ -55,7 +76,13 @@ pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize current = next; } - order + if current != 0 || visited.iter().any(|seen| !seen) { + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form one Hamiltonian cycle", + )); + } + + Ok(order) } /// Build the complement graph edges: edges between all non-adjacent vertex pairs. @@ -71,3 +98,15 @@ pub(crate) fn complement_edges(graph: &SimpleGraph) -> Vec<(usize, usize)> { } edges } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_disjoint_selected_cycles() { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]); + + assert!(edges_to_cycle_order(&graph, &[1; 6]).is_err()); + } +} diff --git a/src/rules/graphpartitioning_ilp.rs b/src/rules/graphpartitioning_ilp.rs index 94b280286..00de83f40 100644 --- a/src/rules/graphpartitioning_ilp.rs +++ b/src/rules/graphpartitioning_ilp.rs @@ -30,16 +30,21 @@ impl ReductionResult for ReductionGraphPartitioningToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_edges", num_constraints = "2 * num_edges + 1", - } + }, )] impl ReduceTo> for GraphPartitioning { type Result = ReductionGraphPartitioningToILP; diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 0bf31b369..fd2e5a2d0 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -22,8 +22,13 @@ impl ReductionResult for ReductionGPToMaxCut { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -69,7 +74,7 @@ fn penalty_weight(num_edges: usize) -> i32 { } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2", } diff --git a/src/rules/graphpartitioning_qubo.rs b/src/rules/graphpartitioning_qubo.rs index 8e32846c9..f871163aa 100644 --- a/src/rules/graphpartitioning_qubo.rs +++ b/src/rules/graphpartitioning_qubo.rs @@ -24,12 +24,19 @@ impl ReductionResult for ReductionGraphPartitioningToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { num_vars = "num_vertices" })] +#[reduction(size = exact { + num_vars = "num_vertices", +})] impl ReduceTo> for GraphPartitioning { type Result = ReductionGraphPartitioningToQUBO; diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index cc3e75aae..60df75f57 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -44,57 +44,72 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - if n < 3 { - return vec![0; n]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_vertices; + if n < 3 { + return Err(crate::rules::ExtractionError::invalid( + "a Hamiltonian circuit requires at least three vertices", + )); + } - // Collect selected edges (those with config value 1) - let mut adj: Vec> = vec![vec![]; n]; - for (i, &(u, v)) in self.potential_edges.iter().enumerate() { - if i < target_solution.len() && target_solution[i] == 1 { - adj[u].push(v); - adj[v].push(u); + // Collect selected edges (those with config value 1) + let mut adj: Vec> = vec![vec![]; n]; + for (i, &(u, v)) in self.potential_edges.iter().enumerate() { + if target_solution[i] == 1 { + adj[u].push(v); + adj[v].push(u); + } } - } - // Check that every vertex has exactly degree 2 (Hamiltonian cycle) - if adj.iter().any(|neighbors| neighbors.len() != 2) { - return vec![0; n]; - } + // Check that every vertex has exactly degree 2 (Hamiltonian cycle) + if adj.iter().any(|neighbors| neighbors.len() != 2) { + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not give every source vertex degree two", + )); + } - // Walk the cycle starting from vertex 0 - let mut circuit = Vec::with_capacity(n); - circuit.push(0); - let mut prev = 0; - let mut current = adj[0][0]; - while current != 0 { - circuit.push(current); - let next = if adj[current][0] == prev { - adj[current][1] - } else { - adj[current][0] - }; - prev = current; - current = next; - - // Safety: if we've visited more than n vertices, something is wrong - if circuit.len() > n { - return vec![0; n]; + // Walk the cycle starting from vertex 0 + let mut circuit = Vec::with_capacity(n); + circuit.push(0); + let mut prev = 0; + let mut current = adj[0][0]; + while current != 0 { + circuit.push(current); + let next = if adj[current][0] == prev { + adj[current][1] + } else { + adj[current][0] + }; + prev = current; + current = next; + + // Safety: if we've visited more than n vertices, something is wrong + if circuit.len() > n { + return Err(crate::rules::ExtractionError::invalid( + "selected edges revisit a source vertex", + )); + } } - } - if circuit.len() == n { - circuit - } else { - vec![0; n] - } + if circuit.len() == n { + circuit + } else { + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form a spanning circuit", + )); + } + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "0", num_potential_edges = "num_vertices * (num_vertices - 1) / 2", @@ -122,7 +137,8 @@ impl ReduceTo> for HamiltonianCircu } // Budget = n (exactly enough for n weight-1 edges) - let budget = n as i32; + let budget = i64::try_from(n) + .expect("HamiltonianCircuit -> BiconnectivityAugmentation budget must fit i64"); let target = BiconnectivityAugmentation::new(initial_graph, potential_weights, budget); diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index 061ac0e81..d2afabd4e 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -23,13 +23,18 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2", } diff --git a/src/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/rules/hamiltoniancircuit_hamiltonianpath.rs index a3b20d080..eab3074fc 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -36,41 +36,50 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_original_vertices; - if n == 0 { - return vec![]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_original_vertices; + if n == 0 { + return Ok(vec![]); + } - if target_solution.len() != n + 3 { - return vec![0; n]; - } + let v_prime = n; // index of duplicated vertex v' + let s = n + 1; // pendant attached to v=0 + let t = n + 2; // pendant attached to v' + + // The two pendants force any valid witness to have endpoints s and t. + let reversed; + let oriented = match (target_solution.first(), target_solution.last()) { + (Some(&start), Some(&end)) if start == s && end == t => target_solution, + (Some(&start), Some(&end)) if start == t && end == s => { + reversed = target_solution.iter().copied().rev().collect::>(); + reversed.as_slice() + } + _ => { + return Err(crate::rules::ExtractionError::invalid( + "target path does not have the required pendant endpoints", + )) + } + }; - let v_prime = n; // index of duplicated vertex v' - let s = n + 1; // pendant attached to v=0 - let t = n + 2; // pendant attached to v' - - // The two pendants force any valid witness to have endpoints s and t. - let reversed; - let oriented = match (target_solution.first(), target_solution.last()) { - (Some(&start), Some(&end)) if start == s && end == t => target_solution, - (Some(&start), Some(&end)) if start == t && end == s => { - reversed = target_solution.iter().copied().rev().collect::>(); - reversed.as_slice() + if oriented.get(1) != Some(&0) || oriented.get(n + 1) != Some(&v_prime) { + return Err(crate::rules::ExtractionError::invalid( + "target path does not traverse the duplicated source vertex correctly", + )); } - _ => return vec![0; n], - }; - - if oriented.get(1) != Some(&0) || oriented.get(n + 1) != Some(&v_prime) { - return vec![0; n]; - } - oriented[1..=n].to_vec() + oriented[1..=n].to_vec() + }) } } #[reduction( - overhead = { + size = upper_bound { num_vertices = "num_vertices + 3", num_edges = "num_edges + num_vertices + 1", } diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index c6d9a0bba..7bec8d818 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -23,13 +23,18 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index f366b4770..30ffcb178 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -26,15 +26,22 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // QAP config is a permutation γ mapping positions to vertices, - // which is directly the Hamiltonian circuit visit order. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // QAP config is a permutation γ mapping positions to vertices, + // which is directly the Hamiltonian circuit visit order. + target_solution.to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_facilities = "num_vertices", num_locations = "num_vertices", } diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index 277b1aa18..7c25bfa16 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -46,57 +46,65 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // The target solution is edge multiplicities. - // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). - // Connectivity edges start at index n. - // For each source edge (v_i, v_j) at source index k: - // target edge n + 2*k is {v_i^b, v_j^a} - // target edge n + 2*k + 1 is {v_j^b, v_i^a} - // - // A connectivity edge {v_i^b, v_j^a} used with multiplicity 1 means - // the tour goes from vertex i to vertex j (j follows i in the HC). - - let n = self.n; - - // Build successor map from connectivity edges used exactly once - let mut successor = vec![usize::MAX; n]; - for (k, &(vi, vj)) in self.source_edges.iter().enumerate() { - let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} - let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} - - let fwd_mult = target_solution.get(fwd_idx).copied().unwrap_or(0); - let bwd_mult = target_solution.get(bwd_idx).copied().unwrap_or(0); - - // In an optimal HC solution, each connectivity edge is used 0 or 1 times. - // Each vertex should have exactly one outgoing connectivity edge. - if fwd_mult > 0 && successor[vi] == usize::MAX { - successor[vi] = vj; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // The target solution is edge multiplicities. + // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). + // Connectivity edges start at index n. + // For each source edge (v_i, v_j) at source index k: + // target edge n + 2*k is {v_i^b, v_j^a} + // target edge n + 2*k + 1 is {v_j^b, v_i^a} + // + // A connectivity edge {v_i^b, v_j^a} used with multiplicity 1 means + // the tour goes from vertex i to vertex j (j follows i in the HC). + + let n = self.n; + + // Build successor map from connectivity edges used exactly once + let mut successor = vec![usize::MAX; n]; + for (k, &(vi, vj)) in self.source_edges.iter().enumerate() { + let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} + let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} + + let fwd_mult = target_solution[fwd_idx]; + let bwd_mult = target_solution[bwd_idx]; + + // In an optimal HC solution, each connectivity edge is used 0 or 1 times. + // Each vertex should have exactly one outgoing connectivity edge. + if fwd_mult > 0 && successor[vi] == usize::MAX { + successor[vi] = vj; + } + if bwd_mult > 0 && successor[vj] == usize::MAX { + successor[vj] = vi; + } } - if bwd_mult > 0 && successor[vj] == usize::MAX { - successor[vj] = vi; - } - } - // Walk the successor chain starting from vertex 0 - let mut cycle = Vec::with_capacity(n); - let mut current = 0; - for _ in 0..n { - cycle.push(current); - let next = successor[current]; - if next == usize::MAX { - // No valid successor found; return fallback - return vec![0; n]; + // Walk the successor chain starting from vertex 0 + let mut cycle = Vec::with_capacity(n); + let mut current = 0; + for _ in 0..n { + cycle.push(current); + let next = successor[current]; + if next == usize::MAX { + return Err(crate::rules::ExtractionError::invalid( + "target tour does not provide one successor for every source vertex", + )); + } + current = next; } - current = next; - } - cycle + cycle + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vertices", num_edges = "num_vertices + 2 * num_edges", num_required_edges = "num_vertices", diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index 7b8d05345..ff37e08b4 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -32,16 +32,23 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // The target config is a permutation of arc indices. - // Arc i corresponds to original vertex i (arc from 2i to 2i+1). - // The permutation order directly gives the Hamiltonian circuit vertex order. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // The target config is a permutation of arc indices. + // Arc i corresponds to original vertex i (arc from 2i to 2i+1). + // The permutation order directly gives the Hamiltonian circuit vertex order. + target_solution.to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vertices", num_arcs = "num_vertices", num_edges = "2 * num_edges", diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index e90842ca6..a327e2ee4 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -27,45 +27,55 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - if n == 0 { - return vec![]; - } - - // Build directed adjacency from selected arcs. - let candidate_arcs = self.target.candidate_arcs(); - let mut successors = vec![Vec::new(); n]; - for (idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let (u, v, _) = candidate_arcs[idx]; - successors[u].push(v); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.n; + if n == 0 { + return Ok(vec![]); } - } - // Walk the directed cycle starting from vertex 0. - let mut order = Vec::with_capacity(n); - let mut current = 0; - let mut visited = vec![false; n]; - for _ in 0..n { - if visited[current] { - // Not a valid Hamiltonian cycle; return fallback. - return vec![0; n]; + // Build directed adjacency from selected arcs. + let candidate_arcs = self.target.candidate_arcs(); + let mut successors = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v, _) = candidate_arcs[idx]; + successors[u].push(v); + } } - visited[current] = true; - order.push(current); - if successors[current].len() != 1 { - return vec![0; n]; + + // Walk the directed cycle starting from vertex 0. + let mut order = Vec::with_capacity(n); + let mut current = 0; + let mut visited = vec![false; n]; + for _ in 0..n { + if visited[current] { + return Err(crate::rules::ExtractionError::invalid( + "selected arcs revisit a source vertex", + )); + } + visited[current] = true; + order.push(current); + if successors[current].len() != 1 { + return Err(crate::rules::ExtractionError::invalid( + "selected arcs do not provide one successor for every source vertex", + )); + } + current = successors[current][0]; } - current = successors[current][0]; - } - order + order + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_arcs = "0", num_potential_arcs = "num_vertices * (num_vertices - 1)", @@ -89,7 +99,8 @@ impl ReduceTo> for HamiltonianCircuit StrongConnectivityAugmentation bound must fit i64"); let target = StrongConnectivityAugmentation::new(graph, candidate_arcs, bound); ReductionHamiltonianCircuitToStrongConnectivityAugmentation { target, n } diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index eebeb26af..5d001a374 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -23,13 +23,18 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2", } diff --git a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index e6fc6c548..d6d95643f 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -21,13 +21,18 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + extract_hamiltonian_order(self.target.graph(), target_solution) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } @@ -44,20 +49,16 @@ impl ReduceTo> for HamiltonianPath Vec { +fn extract_hamiltonian_order( + graph: &SimpleGraph, + target_solution: &[usize], +) -> crate::rules::ExtractionResult> { let num_vertices = graph.num_vertices(); - if num_vertices == 0 { - return vec![]; - } - if num_vertices == 1 { - return vec![0]; + if num_vertices < 2 { + return Ok((0..num_vertices).collect()); } let edges = graph.edges(); - if target_solution.len() != edges.len() { - return vec![]; - } - let mut adjacency = vec![Vec::new(); num_vertices]; for ((u, v), &selected) in edges.iter().copied().zip(target_solution.iter()) { if selected != 1 { @@ -74,7 +75,9 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> .collect(); endpoints.sort_unstable(); if endpoints.len() != 2 { - return vec![]; + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form a Hamiltonian path", + )); } let mut order = Vec::with_capacity(num_vertices); @@ -84,7 +87,9 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> loop { if visited[current] { - return vec![]; + return Err(crate::rules::ExtractionError::invalid( + "selected edges contain a cycle", + )); } visited[current] = true; order.push(current); @@ -103,9 +108,11 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> } if order.len() == num_vertices { - order + Ok(order) } else { - vec![] + Err(crate::rules::ExtractionError::invalid( + "selected edges do not span every source vertex", + )) } } diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index d60f1291a..d883f8860 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -35,15 +35,20 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + one_hot_decode(target_solution, self.num_vertices, self.num_vertices, 0) } } #[reduction( - overhead = { - num_vars = "num_vertices^2 + 2 * num_edges * num_vertices", - num_constraints = "2 * num_vertices + 6 * num_edges * num_vertices + num_vertices", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for HamiltonianPath { diff --git a/src/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/rules/hamiltonianpath_isomorphicspanningtree.rs index a5a96e829..cd871a046 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -28,16 +28,20 @@ impl ReductionResult for ReductionHPToIST { /// The IST config maps tree vertex i to graph vertex config[i]. Since the /// tree is P_n (path 0-1-2-...-n-1), this mapping directly gives the /// vertex ordering of the Hamiltonian path. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", - num_graph_edges = "num_edges", - num_tree_edges = "num_vertices - 1", + num_edges = "num_edges", } )] impl ReduceTo> for HamiltonianPath { diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index bc177227e..fdd256e15 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -33,48 +33,56 @@ impl ReductionResult for ReductionHPBTVToLP { /// /// The target solution is a binary vector over edges. We walk the selected /// edges from the source vertex to reconstruct the vertex ordering. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - - // Build adjacency from selected edges - let mut adj: Vec> = vec![Vec::new(); n]; - for (idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let (u, v) = self.edges[idx]; - adj[u].push(v); - adj[v].push(u); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_vertices; + + // Build adjacency from selected edges + let mut adj: Vec> = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v) = self.edges[idx]; + adj[u].push(v); + adj[v].push(u); + } } - } - // Walk the path from source - let mut path = Vec::with_capacity(n); - let mut current = self.source_vertex; - let mut prev = usize::MAX; // sentinel for "no previous" - path.push(current); - - while path.len() < n { - let next = adj[current] - .iter() - .find(|&&neighbor| neighbor != prev) - .copied(); - match next { - Some(next_vertex) => { - prev = current; - current = next_vertex; - path.push(current); + // Walk the path from source + let mut path = Vec::with_capacity(n); + let mut current = self.source_vertex; + let mut prev = usize::MAX; // sentinel for "no previous" + path.push(current); + + while path.len() < n { + let next = adj[current] + .iter() + .find(|&&neighbor| neighbor != prev) + .copied(); + match next { + Some(next_vertex) => { + prev = current; + current = next_vertex; + path.push(current); + } + None => break, } - None => break, } - } - path + path + }) } } -#[reduction(overhead = { - num_vertices = "num_vertices", - num_edges = "num_edges", -})] +#[reduction( + size = exact { + num_vertices = "num_vertices", + num_edges = "num_edges", + })] impl ReduceTo> for HamiltonianPathBetweenTwoVertices { type Result = ReductionHPBTVToLP; diff --git a/src/rules/highlyconnecteddeletion_ilp.rs b/src/rules/highlyconnecteddeletion_ilp.rs index 74493280f..b1d6887af 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -60,36 +60,41 @@ impl ReductionResult for ReductionHighlyConnectedDeletionToILP { /// For every source edge `(u, v)`, the edge is *kept* iff some chosen /// cluster `S` (i.e. with `x_S = 1`) contains both `u` and `v`; otherwise /// it is deleted (`config[e] = 1`). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Map every vertex to the (unique, for a feasible ILP solution) chosen - // cluster id. For partial/infeasible target assignments we fall back to - // `None`, which forces the corresponding source edges to be marked - // deleted -- preserving feasibility of `is_valid_solution` is the - // caller's responsibility, not ours. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let mut cluster_of: Vec> = vec![None; vertex_count(&self.clusters)]; for (c, cluster) in self.clusters.iter().enumerate() { - if target_solution.get(c).copied().unwrap_or(0) == 1 { + if target_solution[c] == 1 { for &v in cluster { + if cluster_of[v].is_some() { + return Err(crate::rules::ExtractionError::invalid(format!( + "vertex {v} belongs to multiple selected clusters" + ))); + } cluster_of[v] = Some(c); } + } else if target_solution[c] != 0 { + return Err(crate::rules::ExtractionError::invalid(format!( + "cluster selection {c} is not binary" + ))); } } - self.edges + if let Some(vertex) = cluster_of.iter().position(Option::is_none) { + return Err(crate::rules::ExtractionError::invalid(format!( + "vertex {vertex} has no selected cluster" + ))); + } + + Ok(self + .edges .iter() - .map(|&(u, v)| { - debug_assert!( - cluster_of[u].is_some() && cluster_of[v].is_some(), - "extract_solution invariant violated: edge ({}, {}) has endpoint(s) with no cluster assignment; a well-formed ILP witness assigns every vertex to exactly one selected cluster", - u, - v - ); - match (cluster_of[u], cluster_of[v]) { - (Some(cu), Some(cv)) if cu == cv => 0, - _ => 1, - } - }) - .collect() + .map(|&(u, v)| usize::from(cluster_of[u] != cluster_of[v])) + .collect()) } } @@ -146,9 +151,11 @@ fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { } #[reduction( - overhead = { - num_vars = "2^num_vertices", + size = exact { num_constraints = "num_vertices", + }, + unavailable = { + num_vars = "the exact count is the number of feasible highly connected vertex subsets, a hard structural parameter absent from the source size vector", } )] impl ReduceTo> for HighlyConnectedDeletion { diff --git a/src/rules/ilp_bool_ilp_i32.rs b/src/rules/ilp_bool_ilp_i32.rs index 5e36032a8..9ec53b3dd 100644 --- a/src/rules/ilp_bool_ilp_i32.rs +++ b/src/rules/ilp_bool_ilp_i32.rs @@ -24,15 +24,21 @@ impl ReductionResult for ReductionBinaryILPToIntILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vars = "num_vars", - num_constraints = "num_constraints + num_vars", -})] +#[reduction( + size = exact { + num_vars = "num_vars", + num_constraints = "num_constraints + num_vars", + },)] impl ReduceTo> for ILP { type Result = ReductionBinaryILPToIntILP; diff --git a/src/rules/ilp_helpers.rs b/src/rules/ilp_helpers.rs index db5294571..93fe410ac 100644 --- a/src/rules/ilp_helpers.rs +++ b/src/rules/ilp_helpers.rs @@ -140,12 +140,56 @@ pub fn one_hot_decode( num_items: usize, num_slots: usize, var_offset: usize, -) -> Vec { - (0..num_slots) +) -> crate::rules::ExtractionResult> { + let assignment: Vec = (0..num_slots) .map(|p| { - (0..num_items) - .find(|&v| solution[var_offset + v * num_slots + p] == 1) - .unwrap_or(0) + let mut selected = + (0..num_items).filter(|&v| solution[var_offset + v * num_slots + p] == 1); + let item = selected.next().ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "assignment slot {p} has no selected item" + )) + })?; + if selected.next().is_some() { + return Err(crate::rules::ExtractionError::invalid(format!( + "assignment slot {p} has multiple selected items" + ))); + } + Ok(item) + }) + .collect::>()?; + + let mut assigned = vec![false; num_items]; + for &item in &assignment { + if std::mem::replace(&mut assigned[item], true) { + return Err(crate::rules::ExtractionError::invalid(format!( + "item {item} is selected for multiple assignment slots" + ))); + } + } + Ok(assignment) +} + +/// Decode one selected column from each row of a row-major binary matrix. +pub fn one_hot_decode_rows( + solution: &[usize], + num_rows: usize, + num_columns: usize, + var_offset: usize, +) -> crate::rules::ExtractionResult> { + (0..num_rows) + .map(|row| { + let mut selected = (0..num_columns) + .filter(|&column| solution[var_offset + row * num_columns + column] == 1); + match (selected.next(), selected.next()) { + (Some(column), None) => Ok(column), + (None, _) => Err(crate::rules::ExtractionError::invalid(format!( + "assignment row {row} has no selected column" + ))), + (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( + "assignment row {row} has multiple selected columns" + ))), + } }) .collect() } diff --git a/src/rules/ilp_i32_ilp_bool.rs b/src/rules/ilp_i32_ilp_bool.rs index 6577cb5e5..f2fb3176d 100644 --- a/src/rules/ilp_i32_ilp_bool.rs +++ b/src/rules/ilp_i32_ilp_bool.rs @@ -247,26 +247,34 @@ impl ReductionResult for ReductionIntILPToBinaryILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.encodings - .iter() - .map(|enc| { - let val: i64 = enc - .weights - .iter() - .enumerate() - .map(|(j, &w)| w * target_solution[enc.start + j] as i64) - .sum(); - val as usize - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.encodings + .iter() + .map(|enc| { + let val: i64 = enc + .weights + .iter() + .enumerate() + .map(|(j, &w)| w * target_solution[enc.start + j] as i64) + .sum(); + val as usize + }) + .collect() + }) } } -#[reduction(overhead = { - num_vars = "31 * num_variables", - num_constraints = "num_constraints", -})] +#[reduction( + size = exact { + num_vars = "31 * num_vars", + num_constraints = "num_constraints", + },)] impl ReduceTo> for ILP { type Result = ReductionIntILPToBinaryILP; diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index 829bab31c..756ef3378 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -29,13 +29,20 @@ impl ReductionResult for ReductionILPToQUBO { } /// Extract only the original variables (discard slack). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_original_vars].to_vec()) } } #[reduction( - overhead = { num_vars = "num_vars + num_constraints * num_vars" } + size = unavailable { + num_vars = "the exact count depends on source incidence structure or construction branches not represented by registered source fields", + } )] impl ReduceTo> for ILP { type Result = ReductionILPToQUBO; diff --git a/src/rules/integerknapsack_ilp.rs b/src/rules/integerknapsack_ilp.rs index d5e7ef33d..99321be7e 100644 --- a/src/rules/integerknapsack_ilp.rs +++ b/src/rules/integerknapsack_ilp.rs @@ -22,16 +22,21 @@ impl ReductionResult for ReductionIntegerKnapsackToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_items", num_constraints = "num_items + 1", - } + }, )] impl ReduceTo> for IntegerKnapsack { type Result = ReductionIntegerKnapsackToILP; diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index ad423ec32..1999ffbeb 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -23,16 +23,21 @@ impl ReductionResult for ReductionIFBToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_arcs", num_constraints = "num_bundles + num_vertices - 1", - } + }, )] impl ReduceTo> for IntegralFlowBundles { type Result = ReductionIFBToILP; diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 05c6cfc1e..207c14920 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -22,15 +22,23 @@ impl ReductionResult for ReductionIFHAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_arcs", - num_constraints = "num_arcs + num_vertices - 2 + 1", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for IntegralFlowHomologousArcs { diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index c53b35bc4..f82c851b0 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -22,16 +22,21 @@ impl ReductionResult for ReductionIFWMToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_arcs", num_constraints = "num_arcs + num_vertices - 1", - } + }, )] impl ReduceTo> for IntegralFlowWithMultipliers { type Result = ReductionIFWMToILP; diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index dad7a8126..646f40d4a 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -24,22 +24,23 @@ impl ReductionResult for ReductionISTToILP { } /// For each tree vertex u, output the unique graph vertex v with x_{u,v} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|u| { - (0..n) - .find(|&v| target_solution[u * n + v] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices * num_vertices", - num_constraints = "2 * num_vertices + 2 * (num_vertices - 1) * num_vertices * num_vertices", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for IsomorphicSpanningTree { diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs index 0c84772a3..a5040f3fa 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -34,15 +34,22 @@ impl ReductionResult for ReductionKCliqueToBCBS { /// The k-clique is S = {v in V : v not in A'}, i.e., the original vertices /// NOT selected on the left side. For each original vertex v (0..n-1): /// source_config[v] = 1 - target_config[v]. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_original_vertices) - .map(|v| 1 - target_solution[v]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_original_vertices) + .map(|v| 1 - target_solution[v]) + .collect() + }) } } #[reduction( - overhead = { + size = exact { left_size = "num_vertices + k * (k - 1) / 2", right_size = "num_edges + num_vertices - k", k = "num_vertices + k * (k - 1) / 2 - k", diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index 0dcd3c4ca..b61bcb7e9 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -34,13 +34,21 @@ impl ReductionResult for ReductionKCliqueToCBQ { /// CBQ config: vec of length k, each value is a domain element (vertex index). /// KClique config: binary vec of length n; set config[v]=1 for each v in /// the CBQ assignment. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - KClique::::config_from_vertices(self.num_vertices, target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(KClique::::config_from_vertices( + self.num_vertices, + target_solution, + )) } } #[reduction( - overhead = { + size = exact { domain_size = "num_vertices", num_relations = "1", num_variables = "k", diff --git a/src/rules/kclique_ilp.rs b/src/rules/kclique_ilp.rs index 96db11c91..eb11a6709 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -39,15 +39,23 @@ impl ReductionResult for ReductionKCliqueToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", - num_constraints = "num_vertices^2", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for KClique { diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index e351a2cfa..c74589faf 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -34,13 +34,20 @@ impl ReductionResult for ReductionKCliqueToSubIso { /// The SubgraphIsomorphism config maps each pattern vertex (0..k-1) to a /// host vertex. We create a binary vector of length n and set positions /// f(0), f(1), ..., f(k-1) to 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - KClique::::config_from_vertices(self.num_source_vertices, target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + KClique::::config_from_vertices(self.num_source_vertices, target_solution) + }) } } #[reduction( - overhead = { + size = exact { num_host_vertices = "num_vertices", num_host_edges = "num_edges", num_pattern_vertices = "k", diff --git a/src/rules/kcoloring_bicliquecover.rs b/src/rules/kcoloring_bicliquecover.rs index 2c28ced38..ed39583c0 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -68,57 +68,57 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { /// cover yields at most `q` such distinct bicliques, so the result is a /// proper `q`-coloring of the source. /// - /// If the witness is invalid (e.g. some diagonal edge is uncovered), - /// the extracted entry for `v` falls back to color `0`. Validation - /// downstream is the responsibility of `source.is_valid_solution`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - let k = self.target.k(); - let left_size = 2 * n; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // For each source vertex v, find the first biclique r that contains - // both a_v (unified index v) and b_v (unified index left_size + v). - let mut diagonal_biclique = vec![None; n]; - for (v, slot) in diagonal_biclique.iter_mut().enumerate() { - let a_v = v; - let b_v = left_size + v; - for r in 0..k { - let a_idx = a_v * k + r; - let b_idx = b_v * k + r; - if target_solution.get(a_idx).copied().unwrap_or(0) == 1 - && target_solution.get(b_idx).copied().unwrap_or(0) == 1 - { - *slot = Some(r); - break; - } + Ok({ + let n = self.num_vertices; + let k = self.target.k(); + let left_size = 2 * n; + + // For each source vertex v, find the first biclique r that contains + // both a_v (unified index v) and b_v (unified index left_size + v). + let mut diagonal_biclique = Vec::with_capacity(n); + for v in 0..n { + let a_v = v; + let b_v = left_size + v; + let biclique = (0..k) + .find(|&r| { + target_solution[a_v * k + r] == 1 && target_solution[b_v * k + r] == 1 + }) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target cover leaves diagonal gadget edge {v} uncovered" + )) + })?; + diagonal_biclique.push(biclique); } - } - // Compact distinct biclique indices into colors 0..q-1 in first-seen order. - let mut color_of_biclique: std::collections::HashMap = - std::collections::HashMap::new(); - let mut coloring = vec![0usize; n]; - for (v, slot) in diagonal_biclique.iter().enumerate() { - if let Some(r) = *slot { + // Compact distinct biclique indices into colors 0..q-1 in first-seen order. + let mut color_of_biclique: std::collections::HashMap = + std::collections::HashMap::new(); + let mut coloring = Vec::with_capacity(n); + for biclique in diagonal_biclique { let next_color = color_of_biclique.len(); - let color = *color_of_biclique.entry(r).or_insert(next_color); - // Clamp into [0, q-1]: if the witness exceeds q distinct - // diagonal bicliques (which a valid cover never does) keep - // the entry in range so the downstream validator can - // simply reject it as an improper coloring. - coloring[v] = if self.num_colors == 0 { - 0 - } else { - color.min(self.num_colors - 1) - }; + let color = *color_of_biclique.entry(biclique).or_insert(next_color); + if color >= self.num_colors { + return Err(crate::rules::ExtractionError::invalid(format!( + "target cover uses more than {} diagonal bicliques", + self.num_colors + ))); + } + coloring.push(color); } - } - coloring + coloring + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "4 * num_vertices", num_edges = "2 * num_vertices * (num_vertices - 1) - 4 * num_edges + 3 * num_vertices", rank = "num_vertices + num_colors", diff --git a/src/rules/kcoloring_casts.rs b/src/rules/kcoloring_casts.rs index 800584dcf..a3e1f6789 100644 --- a/src/rules/kcoloring_casts.rs +++ b/src/rules/kcoloring_casts.rs @@ -9,5 +9,6 @@ impl_variant_reduction!( KColoring, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| KColoring::with_k(src.graph().clone(), src.num_colors()) ); diff --git a/src/rules/kcoloring_clustering.rs b/src/rules/kcoloring_clustering.rs index 3ce0ef69e..0f77a50d0 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -28,8 +28,13 @@ impl ReductionResult for ReductionKColoringToClustering { /// Cluster labels are color labels. The empty-graph corner case uses one /// dummy target element because Clustering forbids empty instances. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vertices.min(target_solution.len())].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vertices].to_vec()) } } @@ -47,9 +52,10 @@ fn build_distances(graph: &SimpleGraph) -> Vec> { distances } -#[reduction(overhead = { - num_elements = "num_vertices", -})] +#[reduction( + size = exact { + num_elements = "num_vertices", + })] impl ReduceTo for KColoring { type Result = ReductionKColoringToClustering; diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index 081a0d82c..216d2647f 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -25,13 +25,18 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { } /// Solution extraction is the identity: color classes become clique classes. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", } diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index 18fd9575e..357f20bbc 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -39,32 +39,39 @@ impl ReductionResult for ReductionKColoringToTDCS { /// The first `num_vertices` symbols correspond to graph vertices, /// so their group assignments directly give a valid 3-coloring /// (after remapping to colors 0, 1, 2). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // The target solution is config[symbol] = group_index. - // Vertex symbols are indices 0..num_vertices. - // We need to remap the group indices to colors 0, 1, 2. - // The target may use any labels, so we compress the distinct - // group indices used by vertex symbols to 0..2. - - let vertex_groups = &target_solution[..self.num_vertices]; - - // Collect distinct group indices used by vertices and map to 0..k-1 - let mut used: Vec = vertex_groups.to_vec(); - used.sort(); - used.dedup(); - - let group_to_color: std::collections::HashMap = used - .into_iter() - .enumerate() - .map(|(color, group)| (group, color % 3)) - .collect(); - - vertex_groups.iter().map(|&g| group_to_color[&g]).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // The target solution is config[symbol] = group_index. + // Vertex symbols are indices 0..num_vertices. + // We need to remap the group indices to colors 0, 1, 2. + // The target may use any labels, so we compress the distinct + // group indices used by vertex symbols to 0..2. + + let vertex_groups = &target_solution[..self.num_vertices]; + + // Collect distinct group indices used by vertices and map to 0..k-1 + let mut used: Vec = vertex_groups.to_vec(); + used.sort(); + used.dedup(); + + let group_to_color: std::collections::HashMap = used + .into_iter() + .enumerate() + .map(|(color, group)| (group, color % 3)) + .collect(); + + vertex_groups.iter().map(|&g| group_to_color[&g]).collect() + }) } } #[reduction( - overhead = { + size = exact { alphabet_size = "num_vertices + num_edges", num_subsets = "num_edges", } diff --git a/src/rules/knapsack_ilp.rs b/src/rules/knapsack_ilp.rs index 6732870ac..1c3dff9b8 100644 --- a/src/rules/knapsack_ilp.rs +++ b/src/rules/knapsack_ilp.rs @@ -24,16 +24,21 @@ impl ReductionResult for ReductionKnapsackToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_items", num_constraints = "1", - } + }, )] impl ReduceTo> for Knapsack { type Result = ReductionKnapsackToILP; diff --git a/src/rules/knapsack_qubo.rs b/src/rules/knapsack_qubo.rs index fd8898ea2..c9944fa9f 100644 --- a/src/rules/knapsack_qubo.rs +++ b/src/rules/knapsack_qubo.rs @@ -30,12 +30,19 @@ impl ReductionResult for ReductionKnapsackToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_items].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_items].to_vec()) } } -#[reduction(overhead = { num_vars = "num_items + num_slack_bits" })] +#[reduction(size = exact { + num_vars = "num_items + num_slack_bits", +})] impl ReduceTo> for Knapsack { type Result = ReductionKnapsackToQUBO; diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index 6e747aa60..a87963adc 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -72,14 +72,10 @@ impl ReductionPartitionToAcyclicPartition { DirectedGraph::new(num_elements + 2, arcs), vertex_weights, arc_costs, - u64_to_i32( - weight_bound, - "Partition -> AcyclicPartition requires weight bound to fit in i32", - ), - usize_to_i32( - num_elements, - "Partition -> AcyclicPartition requires num_elements to fit in i32", - ), + i64::try_from(weight_bound) + .expect("Partition -> AcyclicPartition weight bound must fit in i64"), + i64::try_from(num_elements) + .expect("Partition -> AcyclicPartition cost bound must fit in i64"), ); Self { @@ -99,21 +95,25 @@ impl ReductionResult for ReductionPartitionToAcyclicPartition { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() != self.source_num_elements + 2 { - return vec![0; self.source_num_elements]; - } - - let source_label = target_solution[self.source_vertex]; - let sink_label = target_solution[self.sink_vertex]; - debug_assert_ne!( - source_label, sink_label, - "valid target witnesses must place source and sink in different blocks" - ); - - (0..self.source_num_elements) - .map(|item| usize::from(target_solution[item] == sink_label)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let source_label = target_solution[self.source_vertex]; + let sink_label = target_solution[self.sink_vertex]; + if source_label == sink_label { + return Err(crate::rules::ExtractionError::invalid( + "target partition places the source and sink in the same block", + )); + } + + (0..self.source_num_elements) + .map(|item| usize::from(target_solution[item] == sink_label)) + .collect() + }) } } @@ -133,12 +133,21 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { self.partition_to_acyclic.target_problem() } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let partition_solution = self.partition_to_acyclic.extract_solution(target_solution); - let subset_solution = self - .subset_to_partition - .extract_solution(&partition_solution); - self.sat_to_subset.extract_solution(&subset_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let partition_solution = self + .partition_to_acyclic + .extract_solution(target_solution)?; + let subset_solution = self + .subset_to_partition + .extract_solution(&partition_solution)?; + self.sat_to_subset.extract_solution(&subset_solution)? + }) } } @@ -146,12 +155,8 @@ fn u64_to_i32(value: u64, context: &str) -> i32 { i32::try_from(value).expect(context) } -fn usize_to_i32(value: usize, context: &str) -> i32 { - i32::try_from(value).expect(context) -} - #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars + 2 * num_clauses + 3", num_arcs = "4 * num_vars + 4 * num_clauses + 2", } diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 9e01fc831..05ca52fbf 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -98,13 +98,15 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { /// 4. Map normalized variables back to source variables by reading /// each original `t_i`. /// - /// If no qualifying `B_1` is found (e.g. the witness is invalid), - /// the extracted assignment defaults to all-false. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n = self.normalized_n; let left_size = self.target.left_size(); let k = self.target.k(); - // Unified-vertex helpers for the named gadget anchors. let s11_u = self.s1_left_offset; // s_{1,1}^u let s11_v = left_size + self.s1_right_offset; // s_{1,1}^v @@ -115,11 +117,9 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { // Find a biclique containing both s_11^u and s_11^v, but no // Y-matching vertex. By Lemma 17, free-edge bicliques touch the // Y matching; the important-edge biclique B_1 does not. - let mut b1_index: Option = None; + let mut b1_index = None; for r in 0..k { - let in_b1 = |vertex: usize| -> bool { - target_solution.get(vertex * k + r).copied().unwrap_or(0) == 1 - }; + let in_b1 = |vertex: usize| target_solution[vertex * k + r] == 1; if !in_b1(s11_u) || !in_b1(s11_v) { continue; } @@ -133,11 +133,14 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { } // Read off normalized assignment: t_i = (h_i^u in B_1) for i in 0..n. + let b1_index = b1_index.ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration has no important-edge biclique B_1", + ) + })?; let mut normalized_assignment = vec![false; n]; - if let Some(r) = b1_index { - for (i, slot) in normalized_assignment.iter_mut().enumerate() { - *slot = target_solution.get(h_left(i) * k + r).copied().unwrap_or(0) == 1; - } + for (i, slot) in normalized_assignment.iter_mut().enumerate() { + *slot = target_solution[h_left(i) * k + b1_index] == 1; } // Map normalized t_i back to the source: source x_s = t_s @@ -146,13 +149,9 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { let mut source_assignment = vec![0usize; self.source_num_vars]; for (s, slot) in source_assignment.iter_mut().enumerate() { let t_idx = 2 * s; - *slot = if normalized_assignment.get(t_idx).copied().unwrap_or(false) { - 1 - } else { - 0 - }; + *slot = if normalized_assignment[t_idx] { 1 } else { 0 }; } - source_assignment + Ok(source_assignment) } } @@ -237,7 +236,7 @@ fn free_edge_budget(ell: usize, m: usize) -> usize { 4 * ell + 2 * ceil_log2(m) + 6 } -// Overhead expressions are upper bounds in terms of source counts. +// Size expressions are upper bounds in terms of source counts. // After normalization, `n ≤ 4·num_vars` (next power of two of `2·num_vars`) // and `m ≤ num_clauses + n ≤ num_clauses + 4·num_vars`. With // `ell = log2 n ≤ 2 + log2(num_vars)` we use the coarser bound @@ -245,7 +244,7 @@ fn free_edge_budget(ell: usize, m: usize) -> usize { // giving the polynomial bounds below. Edges are bounded by // `partition_size^2` which is `O((num_vars + num_clauses)^2)`. #[reduction( - overhead = { + size = exact { num_vertices = "32 * num_vars + 24 * num_clauses + 100", num_edges = "(32 * num_vars + 24 * num_clauses + 100) * (32 * num_vars + 24 * num_clauses + 100)", rank = "10 * num_vars + 4 * num_clauses + 20", diff --git a/src/rules/ksatisfiability_casts.rs b/src/rules/ksatisfiability_casts.rs index e98a02a1f..fbfac77cd 100644 --- a/src/rules/ksatisfiability_casts.rs +++ b/src/rules/ksatisfiability_casts.rs @@ -7,13 +7,15 @@ use crate::variant::{K2, K3, KN}; impl_variant_reduction!( KSatisfiability, => , - fields: [num_vars, num_clauses], + fields: [num_vars, num_clauses, num_literals], + aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); impl_variant_reduction!( KSatisfiability, => , - fields: [num_vars, num_clauses], + fields: [num_vars, num_clauses, num_literals], + aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); diff --git a/src/rules/ksatisfiability_cyclicordering.rs b/src/rules/ksatisfiability_cyclicordering.rs index d56c3b49d..077033889 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -30,17 +30,24 @@ impl ReductionResult for Reduction3SATToCyclicOrdering { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|var_idx| { - let (alpha, beta, gamma) = variable_triple(var_idx); - usize::from(!is_cyclic_order( - target_solution[alpha], - target_solution[beta], - target_solution[gamma], - )) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.source_num_vars) + .map(|var_idx| { + let (alpha, beta, gamma) = variable_triple(var_idx); + usize::from(!is_cyclic_order( + target_solution[alpha], + target_solution[beta], + target_solution[gamma], + )) + }) + .collect() + }) } } @@ -64,7 +71,7 @@ fn is_cyclic_order(a: usize, b: usize, c: usize) -> bool { } #[reduction( - overhead = { + size = exact { num_elements = "3 * num_vars + 5 * num_clauses", num_triples = "10 * num_clauses", } diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 37d22483f..84f6b031a 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -28,13 +28,16 @@ impl ReductionResult for Reduction3SATToDecisionMVC { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { self.base_reduction.extract_solution(target_solution) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars + 3 * num_clauses", num_edges = "num_vars + 6 * num_clauses", k = "num_vars + 2 * num_clauses", @@ -47,8 +50,12 @@ impl ReduceTo>> for KSatisfiabilit let base_reduction = as ReduceTo< MinimumVertexCover, >>::reduce_to(self); - let bound = i32::try_from(self.num_vars() + 2 * self.num_clauses()) - .expect("decision minimum vertex cover bound must fit in i32"); + let bound = self + .num_clauses() + .checked_mul(2) + .and_then(|value| value.checked_add(self.num_vars())) + .and_then(|value| i64::try_from(value).ok()) + .expect("decision minimum vertex cover bound must fit in i64"); let target = Decision::new(base_reduction.target_problem().clone(), bound); Reduction3SATToDecisionMVC { diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index d0cde28e5..72544a5c3 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -171,26 +171,26 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.variable_paths - .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.lower_entry_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.variable_paths + .iter() + .map(|paths| usize::from(target_solution[paths.lower_entry_arc] > 0)) + .collect() + }) } } -#[reduction(overhead = { - num_vertices = "6 * num_vars + 2 * num_literals + num_clauses + 4", - num_arcs = "7 * num_vars + 4 * num_literals + num_clauses + 1", -})] +#[reduction( + size = exact { + num_vertices = "6 * num_vars + 2 * num_literals + num_clauses + 4", + num_arcs = "7 * num_vars + 4 * num_literals + num_clauses + 1", + })] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToDirectedTwoCommodityIntegralFlow; diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 80f6d0ade..7fe4f7f71 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -69,23 +69,31 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_vars) - .map(|var| { - usize::from( - target_solution[s_pos_idx(var)] - < target_solution[s_neg_idx(self.num_vars, var)], - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_vars) + .map(|var| { + usize::from( + target_solution[s_pos_idx(var)] + < target_solution[s_neg_idx(self.num_vars, var)], + ) + }) + .collect() + }) } } -#[reduction(overhead = { - num_vertices = "2 * num_vars + 12 * num_clauses", - num_arcs = "15 * num_clauses", - num_registers = "num_vars + 9 * num_clauses", -})] +#[reduction( + size = exact { + num_vertices = "2 * num_vars + 12 * num_clauses", + num_arcs = "15 * num_clauses", + num_registers = "num_vars + 9 * num_clauses", + })] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToFeasibleRegisterAssignment; @@ -180,8 +188,8 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let n = self.source_num_vars; - // Start with all variables unset (false = 0). - let mut assignment = vec![0usize; n]; - // Track which variables have been explicitly set by a clique vertex. - let mut set = vec![false; n]; - - for (v, &val) in target_solution.iter().enumerate() { - if val != 1 { - continue; - } - // Vertex v corresponds to clause j, position p. - let j = v / 3; - let p = v % 3; - let lit = self.source_clauses[j][p]; - let var_idx = (lit.unsigned_abs() as usize) - 1; // 0-indexed - if !set[var_idx] { - assignment[var_idx] = if lit > 0 { 1 } else { 0 }; - set[var_idx] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.source_num_vars; + // Start with all variables unset (false = 0). + let mut assignment = vec![0usize; n]; + // Track which variables have been explicitly set by a clique vertex. + let mut set = vec![false; n]; + + for (v, &val) in target_solution.iter().enumerate() { + if val != 1 { + continue; + } + // Vertex v corresponds to clause j, position p. + let j = v / 3; + let p = v % 3; + let lit = self.source_clauses[j][p]; + let var_idx = (lit.unsigned_abs() as usize) - 1; // 0-indexed + if !set[var_idx] { + assignment[var_idx] = if lit > 0 { 1 } else { 0 }; + set[var_idx] = true; + } } - } - assignment + assignment + }) } } @@ -67,10 +74,10 @@ fn literals_contradict(lit1: i32, lit2: i32) -> bool { } #[reduction( - overhead = { + size = upper_bound { num_vertices = "3 * num_clauses", - num_edges = "9 * num_clauses * (num_clauses - 1) / 2", k = "num_clauses", + num_edges = "9 * num_clauses^2", } )] impl ReduceTo> for KSatisfiability { diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index c09f9aca9..d0829d8d8 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -25,10 +25,17 @@ impl ReductionResult for Reduction3SatToKernel { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| usize::from(target_solution.get(2 * i).copied().unwrap_or(0) == 1)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.source_num_vars) + .map(|i| usize::from(target_solution[2 * i] == 1)) + .collect() + }) } } @@ -42,7 +49,7 @@ fn literal_vertex(literal: i32) -> usize { } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars + 3 * num_clauses", num_arcs = "2 * num_vars + 6 * num_clauses", } diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs index 9e881dd4e..50d52895b 100644 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/rules/ksatisfiability_minimumvertexcover.rs @@ -40,22 +40,29 @@ impl ReductionResult for Reduction3SATToMVC { /// is not-u_i. Each truth-setting edge forces exactly one of these two /// into any minimum vertex cover. If u_i is in the cover, set x_i = 1; /// if not-u_i is in the cover, set x_i = 0. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| { - // u_i is at index 2*i, not-u_i is at index 2*i+1 - if target_solution[2 * i] == 1 { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.source_num_vars) + .map(|i| { + // u_i is at index 2*i, not-u_i is at index 2*i+1 + if target_solution[2 * i] == 1 { + 1 + } else { + 0 + } + }) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars + 3 * num_clauses", num_edges = "num_vars + 6 * num_clauses", } diff --git a/src/rules/ksatisfiability_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index d2a49e311..04a14bc47 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -47,32 +47,34 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let direct: Vec = self .negation_edge_indices .iter() - .map( - |&edge_idx| match target_solution.get(edge_idx).copied().unwrap_or(1) { - 0 => 1, - _ => 0, - }, - ) + .map(|&edge_idx| usize::from(target_solution[edge_idx] == 0)) .collect(); if self.source.evaluate(&direct).0 { - return direct; + return Ok(direct); } let complement: Vec = direct.iter().map(|&value| 1 - value).collect(); if self.source.evaluate(&complement).0 { - return complement; + return Ok(complement); } - direct + Err(crate::rules::ExtractionError::invalid( + "target coloring does not map to a satisfying source assignment", + )) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars + 3 * num_clauses", num_edges = "num_vars + 9 * num_clauses", } @@ -154,7 +156,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vars].to_vec()) } } -#[reduction(overhead = { - num_vars = "num_vars + 2 + 6 * num_clauses", - num_clauses = "1 + 5 * num_clauses", -})] +#[reduction( + size = exact { + num_vars = "num_vars + 2 + 6 * num_clauses", + num_clauses = "1 + 5 * num_clauses", + })] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToOneInThreeSAT; fn reduce_to(&self) -> Self::Result { let source_num_vars = self.num_vars(); - let z_false = source_num_vars as i32 + 1; - let z_true = source_num_vars as i32 + 2; - let mut next_var = source_num_vars as i32 + 3; + let mut variables = SatVariableAllocator::new( + "KSatisfiability -> OneInThreeSatisfiability", + source_num_vars, + ) + .unwrap_or_else(|message| panic!("{message}")); + let sentinels = variables + .allocate_many(2) + .unwrap_or_else(|message| panic!("{message}")); + let z_false = sentinels[0]; + let z_true = sentinels[1]; - let mut clauses = Vec::with_capacity(1 + 5 * self.num_clauses()); + let capacity = self + .num_clauses() + .checked_mul(5) + .and_then(|count| count.checked_add(1)) + .expect("KSatisfiability -> OneInThreeSatisfiability clause count overflow"); + let mut clauses = Vec::with_capacity(capacity); clauses.push(CNFClause::new(vec![z_false, z_false, z_true])); for clause in self.clauses() { let [l1, l2, l3] = clause.literals.as_slice() else { unreachable!("K3 clauses must have exactly three literals"); }; - let a = next_var; - let b = next_var + 1; - let c = next_var + 2; - let d = next_var + 3; - let e = next_var + 4; - let f = next_var + 5; - next_var += 6; - - clauses.push(CNFClause::new(vec![*l1, a, d])); - clauses.push(CNFClause::new(vec![*l2, b, d])); - clauses.push(CNFClause::new(vec![a, b, e])); - clauses.push(CNFClause::new(vec![c, d, f])); - clauses.push(CNFClause::new(vec![*l3, c, z_false])); + let allocated = variables + .allocate_many(6) + .unwrap_or_else(|message| panic!("{message}")); + let [a, b, c, d, e, f] = allocated.as_slice() else { + unreachable!("six variables were allocated") + }; + + clauses.push(CNFClause::new(vec![*l1, *a, *d])); + clauses.push(CNFClause::new(vec![*l2, *b, *d])); + clauses.push(CNFClause::new(vec![*a, *b, *e])); + clauses.push(CNFClause::new(vec![*c, *d, *f])); + clauses.push(CNFClause::new(vec![*l3, *c, z_false])); } - let target = OneInThreeSatisfiability::new((next_var - 1) as usize, clauses); + let target = OneInThreeSatisfiability::new(variables.num_vars(), clauses); Reduction3SATToOneInThreeSAT { source_num_vars, diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index ec7ec9bc3..9333d2386 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -335,20 +335,27 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d_max = self.target.d_max(); - self.positive_start_jobs - .iter() - .map(|&job| usize::from(task_slot(target_solution, job, d_max) == Some(0))) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let d_max = self.target.d_max(); + self.positive_start_jobs + .iter() + .map(|&job| usize::from(task_slot(target_solution, job, d_max) == Some(0))) + .collect() + }) } } #[reduction( - overhead = { - num_tasks = "(((2 * num_vars + 2) + 6 * num_clauses + sqrt(((2 * num_vars + 2) - 6 * num_clauses)^2)) / 2) * (num_vars + 3)", - num_processors = "((2 * num_vars + 2) + 6 * num_clauses + sqrt(((2 * num_vars + 2) - 6 * num_clauses)^2)) / 2", - d_max = "(((2 * num_vars + 2) + 6 * num_clauses + sqrt(((2 * num_vars + 2) - 6 * num_clauses)^2)) / 2) * (num_vars + 3)", + size = unavailable { + num_tasks = "the exact count uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", + num_processors = "the exact count uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", + d_max = "the exact deadline uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", } )] impl ReduceTo for KSatisfiability { diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index e24189349..9589ca16f 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -31,37 +31,50 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut source_assignment = vec![0; self.source_num_vars]; - let Some(x) = self.target.decode_witness(target_solution) else { - return source_assignment; - }; - if x > self.h { - return source_assignment; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut source_assignment = vec![0; self.source_num_vars]; + let Some(x) = self.target.decode_witness(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a quadratic-congruence witness", + )); + }; + if x > self.h { + return Err(crate::rules::ExtractionError::invalid( + "decoded quadratic-congruence witness exceeds the construction bound", + )); + } - let h_minus_x = &self.h - &x; - let h_plus_x = &self.h + &x; - let mut alpha = vec![0i8; self.prime_powers.len()]; + let h_minus_x = &self.h - &x; + let h_plus_x = &self.h + &x; + let mut alpha = vec![0i8; self.prime_powers.len()]; - for (j, prime_power) in self.prime_powers.iter().enumerate() { - if (&h_minus_x % prime_power).is_zero() { - alpha[j] = 1; - } else if (&h_plus_x % prime_power).is_zero() { - alpha[j] = -1; + for (j, prime_power) in self.prime_powers.iter().enumerate() { + if (&h_minus_x % prime_power).is_zero() { + alpha[j] = 1; + } else if (&h_plus_x % prime_power).is_zero() { + alpha[j] = -1; + } } - } - for (active_index, &source_index) in self.active_to_source.iter().enumerate() { - let alpha_index = 2 * self.standard_clause_count + active_index + 1; - source_assignment[source_index] = if alpha.get(alpha_index) == Some(&-1) { - 1 - } else { - 0 - }; - } + for (active_index, &source_index) in self.active_to_source.iter().enumerate() { + let alpha_index = 2 * self.standard_clause_count + active_index + 1; + source_assignment[source_index] = match alpha[alpha_index] { + 1 => 0, + -1 => 1, + sign => return Err(crate::rules::ExtractionError::invalid(format!( + "target witness encodes invalid sign {sign} for source variable {source_index}" + ))), + }; + } - source_assignment + source_assignment + }) } } @@ -501,11 +514,13 @@ fn exhaustive_alpha_solution(source: &KSatisfiability) -> Option> { None } -#[reduction(overhead = { - bit_length_a = "(num_vars + num_clauses)^2 * log(num_vars + num_clauses + 1)", - bit_length_b = "(num_vars + num_clauses)^2 * log(num_vars + num_clauses + 1)", - bit_length_c = "(num_vars + num_clauses)^2 * log(num_vars + num_clauses + 1)", -})] +#[reduction( + size = unavailable { + bit_length_a = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", + bit_length_b = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", + bit_length_c = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", + } +)] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToQuadraticCongruences; diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index dc82fed95..1b1a933f2 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -28,21 +28,32 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let Some(x) = self.target.decode_witness(target_solution) else { - return self.congruence_reduction.extract_solution(&[]); - }; - - let Some(congruence_config) = self - .congruence_reduction - .target_problem() - .encode_witness(&x) - else { - return self.congruence_reduction.extract_solution(&[]); - }; - - self.congruence_reduction - .extract_solution(&congruence_config) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let Some(x) = self.target.decode_witness(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a Diophantine witness", + )); + }; + + let Some(congruence_config) = self + .congruence_reduction + .target_problem() + .encode_witness(&x) + else { + return Err(crate::rules::ExtractionError::invalid( + "decoded Diophantine witness cannot be encoded for the source congruence", + )); + }; + + self.congruence_reduction + .extract_solution(&congruence_config)? + }) } } @@ -67,11 +78,15 @@ fn translate_congruence(source: &QuadraticCongruences) -> QuadraticDiophantineEq QuadraticDiophantineEquations::new(BigUint::one(), source.b().clone(), c) } -#[reduction(overhead = { - bit_length_a = "1", - bit_length_b = "(num_vars + num_clauses)^2 * log(num_vars + num_clauses + 1)", - bit_length_c = "(num_vars + num_clauses)^2 * log(num_vars + num_clauses + 1)", -})] +#[reduction( + size = exact { + bit_length_a = "1", + }, + unavailable = { + bit_length_b = "the exact coefficient bit length depends on constructed prime products and is not determined by clause and variable counts", + bit_length_c = "the exact coefficient bit length depends on constructed prime products and padding and is not determined by clause and variable counts", + } +)] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToQuadraticDiophantineEquations; diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index a39f404c7..90e661ab6 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -32,8 +32,13 @@ impl ReductionResult for ReductionKSatToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -52,8 +57,13 @@ impl ReductionResult for Reduction3SATToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -291,7 +301,9 @@ fn build_qubo_matrix( } #[reduction( - overhead = { num_vars = "num_vars" } + size = exact { + num_vars = "num_vars", + } )] impl ReduceTo> for KSatisfiability { type Result = ReductionKSatToQUBO; @@ -308,7 +320,9 @@ impl ReduceTo> for KSatisfiability { } #[reduction( - overhead = { num_vars = "num_vars + num_clauses" } + size = exact { + num_vars = "num_vars + num_clauses", + } )] impl ReduceTo> for KSatisfiability { type Result = Reduction3SATToQUBO; diff --git a/src/rules/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index 30caf6c25..5a72c6dd0 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -199,31 +199,41 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.layout.num_vars == 0 { - return Vec::new(); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + if self.layout.num_vars == 0 { + return Ok(Vec::new()); + } - let cutoff = target_solution[self.layout.w(self.layout.num_vars - 1)]; - (0..self.layout.num_vars) - .map(|var| { - let x_pos_before = target_solution[self.layout.x_pos(var)] < cutoff; - let x_neg_before = target_solution[self.layout.x_neg(var)] < cutoff; - debug_assert!( - !(x_pos_before && x_neg_before), - "Sethi extraction expects at most one of x_pos/x_neg before w[n]", - ); - usize::from(x_pos_before) - }) - .collect() + let cutoff = target_solution[self.layout.w(self.layout.num_vars - 1)]; + (0..self.layout.num_vars) + .map(|var| { + let x_pos_before = target_solution[self.layout.x_pos(var)] < cutoff; + let x_neg_before = target_solution[self.layout.x_neg(var)] < cutoff; + if x_pos_before && x_neg_before { + Err(crate::rules::ExtractionError::invalid(format!( + "both literals of variable {var} precede the extraction cutoff" + ))) + } else { + Ok(usize::from(x_pos_before)) + } + }) + .collect::>>()? + }) } } -#[reduction(overhead = { - num_vertices = "3 * num_vars^2 + 9 * num_vars + 4 * num_clauses + register_sufficiency_padding + 4", - num_arcs = "6 * num_vars^2 + 19 * num_vars + 16 * num_clauses + 2 * register_sufficiency_padding + 1", - bound = "3 * num_clauses + 4 * num_vars + 1 + register_sufficiency_padding", -})] +#[reduction( + size = exact { + num_vertices = "3 * num_vars^2 + 9 * num_vars + 4 * num_clauses + register_sufficiency_padding + 4", + num_arcs = "6 * num_vars^2 + 19 * num_vars + 16 * num_clauses + 2 * register_sufficiency_padding + 1", + bound = "3 * num_clauses + 4 * num_vars + 1 + register_sufficiency_padding", + })] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToRegisterSufficiency; @@ -377,7 +387,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let x = target_solution.first().copied().unwrap_or(0) as u64; - self.variable_primes - .iter() - .map(|&prime| if x % prime == 1 { 1 } else { 0 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let x = target_solution[0] as u64; + self.variable_primes + .iter() + .map(|&prime| if x % prime == 1 { 1 } else { 0 }) + .collect() + }) } } @@ -147,9 +154,10 @@ fn ensure_prime_product_within_lcm_cap(variable_primes: &[u64]) { } } -#[reduction(overhead = { - num_pairs = "simultaneous_incongruences_num_incongruences", -})] +#[reduction( + size = exact { + num_pairs = "simultaneous_incongruences_num_incongruences", + })] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToSimultaneousIncongruences; diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 1f4efc32b..09927fc5a 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -35,20 +35,27 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Variable integers are the first 2n elements in 0-based indexing: - // for variable i (0 <= i < n), y_i is stored at index 2*i and z_i at index 2*i + 1. - // If y_i is selected (target_solution[2*i] == 1), set x_i = 1; otherwise x_i = 0. - (0..self.source_num_vars) - .map(|i| { - let y_selected = target_solution[2 * i] == 1; - if y_selected { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Variable integers are the first 2n elements in 0-based indexing: + // for variable i (0 <= i < n), y_i is stored at index 2*i and z_i at index 2*i + 1. + // If y_i is selected (target_solution[2*i] == 1), set x_i = 1; otherwise x_i = 0. + (0..self.source_num_vars) + .map(|i| { + let y_selected = target_solution[2 * i] == 1; + if y_selected { + 1 + } else { + 0 + } + }) + .collect() + }) } } @@ -65,7 +72,9 @@ fn digits_to_integer(digits: &[u8]) -> BigUint { } #[reduction( - overhead = { num_elements = "2 * num_vars + 2 * num_clauses" } + size = unavailable { + num_elements = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", + } )] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToSubsetSum; diff --git a/src/rules/ksatisfiability_timetabledesign.rs b/src/rules/ksatisfiability_timetabledesign.rs index 08f9e4d0a..8d11a599c 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -23,6 +23,7 @@ use crate::models::formula::{CNFClause, KSatisfiability}; use crate::models::misc::TimetableDesign; use crate::reduction; +use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; #[cfg(any(test, feature = "example-db"))] use crate::traits::Problem; @@ -128,7 +129,7 @@ pub struct Reduction3SATToTimetableDesign { } fn literal_var_index(literal: i32) -> usize { - literal.unsigned_abs() as usize - 1 + usize::try_from(literal.unsigned_abs()).expect("SAT literal magnitude must fit usize") - 1 } #[cfg(any(test, feature = "example-db"))] @@ -203,7 +204,11 @@ fn normalize_formula(source: &KSatisfiability) -> NormalizedFormula { let (mut clauses, pure_assignments) = eliminate_pure_literals(source); let source_num_vars = source.num_vars(); let mut transformed_to_original = Vec::new(); - let mut next_var = source_num_vars + 1; + let mut variables = SatVariableAllocator::new( + "KSatisfiability -> TimetableDesign normalization", + source_num_vars, + ) + .unwrap_or_else(|message| panic!("{message}")); for original_var in 1..=source_num_vars { let mut occurrences = Vec::new(); @@ -220,41 +225,38 @@ fn normalize_formula(source: &KSatisfiability) -> NormalizedFormula { } if occurrences.len() <= 3 { - let replacement = next_var; - next_var += 1; + let replacement = variables + .allocate() + .unwrap_or_else(|message| panic!("{message}")); transformed_to_original.push(original_var - 1); for (clause_idx, lit_idx, is_positive) in occurrences { clauses[clause_idx].literals[lit_idx] = if is_positive { - replacement as i32 + replacement } else { - -(replacement as i32) + -replacement }; } continue; } - let replacements: Vec = (0..occurrences.len()) - .map(|_| { - let id = next_var; - next_var += 1; - transformed_to_original.push(original_var - 1); - id - }) - .collect(); + let replacements = variables + .allocate_many(occurrences.len()) + .unwrap_or_else(|message| panic!("{message}")); + transformed_to_original.extend(std::iter::repeat_n(original_var - 1, replacements.len())); for ((clause_idx, lit_idx, is_positive), replacement) in occurrences.into_iter().zip(replacements.iter().copied()) { clauses[clause_idx].literals[lit_idx] = if is_positive { - replacement as i32 + replacement } else { - -(replacement as i32) + -replacement }; } for idx in 0..replacements.len() { - let current = replacements[idx] as i32; - let next = replacements[(idx + 1) % replacements.len()] as i32; + let current = replacements[idx]; + let next = replacements[(idx + 1) % replacements.len()]; clauses.push(CNFClause::new(vec![current, -next])); } } @@ -262,13 +264,16 @@ fn normalize_formula(source: &KSatisfiability) -> NormalizedFormula { for clause in &mut clauses { for literal in &mut clause.literals { let sign = if *literal < 0 { -1 } else { 1 }; - let temp_var = literal.unsigned_abs() as usize; + let temp_var = usize::try_from(literal.unsigned_abs()) + .expect("SAT literal magnitude must fit usize"); debug_assert!( temp_var > source_num_vars, "all residual literals should have been replaced by transformed variables" ); let compact_var = temp_var - source_num_vars; - *literal = sign * compact_var as i32; + *literal = sign + * i32::try_from(compact_var) + .expect("checked normalized SAT variable count fits i32"); } } @@ -745,47 +750,57 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_tasks = self.target.num_tasks(); - let num_periods = self.target.num_periods(); - - let mut transformed_assignment = vec![0usize; self.layout.transformed_to_original.len()]; - for (index, encoding) in self.layout.variable_encodings.iter().enumerate() { - let vb_pair = match &encoding.vb { - EdgeEncoding::Direct { edge, .. } => self.layout.edge_pairs[*edge], - EdgeEncoding::TwoList { left_outer, .. } => self.layout.edge_pairs[*left_outer], - }; - let vb_color = core_edge_color(target_solution, vb_pair, num_tasks, num_periods); - transformed_assignment[index] = usize::from(vb_color == encoding.neg2); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let num_tasks = self.target.num_tasks(); + let num_periods = self.target.num_periods(); + + let mut transformed_assignment = + vec![0usize; self.layout.transformed_to_original.len()]; + for (index, encoding) in self.layout.variable_encodings.iter().enumerate() { + let vb_pair = match &encoding.vb { + EdgeEncoding::Direct { edge, .. } => self.layout.edge_pairs[*edge], + EdgeEncoding::TwoList { left_outer, .. } => self.layout.edge_pairs[*left_outer], + }; + let vb_color = core_edge_color(target_solution, vb_pair, num_tasks, num_periods); + transformed_assignment[index] = usize::from(vb_color == encoding.neg2); + } - let mut source_assignment = vec![0usize; self.layout.source_num_vars]; - for (var, fixed) in self.layout.pure_assignments.iter().copied().enumerate() { - if let Some(value) = fixed { - source_assignment[var] = value; + let mut source_assignment = vec![0usize; self.layout.source_num_vars]; + for (var, fixed) in self.layout.pure_assignments.iter().copied().enumerate() { + if let Some(value) = fixed { + source_assignment[var] = value; + } } - } - let mut seen_transformed = vec![false; self.layout.source_num_vars]; - for (value, &original_var) in transformed_assignment - .iter() - .zip(self.layout.transformed_to_original.iter()) - { - if !seen_transformed[original_var] { - source_assignment[original_var] = *value; - seen_transformed[original_var] = true; + let mut seen_transformed = vec![false; self.layout.source_num_vars]; + for (value, &original_var) in transformed_assignment + .iter() + .zip(self.layout.transformed_to_original.iter()) + { + if !seen_transformed[original_var] { + source_assignment[original_var] = *value; + seen_transformed[original_var] = true; + } } - } - source_assignment + source_assignment + }) } } -#[reduction(overhead = { - num_periods = "4 * num_literals", - num_craftsmen = "24 * num_literals + 1", - num_tasks = "24 * num_literals + 1", -})] +#[reduction( + size = upper_bound { + num_periods = "4 * num_literals", + num_craftsmen = "24 * num_literals + 1", + num_tasks = "24 * num_literals + 1", + } +)] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToTimetableDesign; diff --git a/src/rules/lengthboundeddisjointpaths_ilp.rs b/src/rules/lengthboundeddisjointpaths_ilp.rs index 08eaadc37..df501d32f 100644 --- a/src/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/rules/lengthboundeddisjointpaths_ilp.rs @@ -32,45 +32,55 @@ impl ReductionResult for ReductionLBDPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // For each path slot k, set the source vertex-indicator block to 1 - // exactly on the vertices incident to the commodity-k path, including s and t. - let m = self.edges.len(); - let n = self.num_vertices; - let j = self.num_paths; - let flow_vars_per_k = 2 * m; - - let mut result = vec![0usize; j * n]; - for k in 0..j { - // Find which vertices are on the path for commodity k - let mut on_path = vec![false; n]; - for e in 0..m { - let (u, v) = self.edges[e]; - let fwd = target_solution[k * flow_vars_per_k + 2 * e]; - let rev = target_solution[k * flow_vars_per_k + 2 * e + 1]; - if fwd == 1 { - on_path[u] = true; - on_path[v] = true; - } - if rev == 1 { - on_path[u] = true; - on_path[v] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // For each path slot k, set the source vertex-indicator block to 1 + // exactly on the vertices incident to the commodity-k path, including s and t. + let m = self.edges.len(); + let n = self.num_vertices; + let j = self.num_paths; + let flow_vars_per_k = 2 * m; + + let mut result = vec![0usize; j * n]; + for k in 0..j { + // Find which vertices are on the path for commodity k + let mut on_path = vec![false; n]; + for e in 0..m { + let (u, v) = self.edges[e]; + let fwd = target_solution[k * flow_vars_per_k + 2 * e]; + let rev = target_solution[k * flow_vars_per_k + 2 * e + 1]; + if fwd == 1 { + on_path[u] = true; + on_path[v] = true; + } + if rev == 1 { + on_path[u] = true; + on_path[v] = true; + } } - } - for v in 0..n { - if on_path[v] { - result[k * n + v] = 1; + for v in 0..n { + if on_path[v] { + result[k * n + v] = 1; + } } } - } - result + result + }) } } #[reduction( - overhead = { + size = exact { num_vars = "max_paths * 2 * num_edges + max_paths", - num_constraints = "max_paths * num_vertices + max_paths * num_edges + max_paths + num_edges + num_vertices + max_paths", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for LengthBoundedDisjointPaths { diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index e52911a30..287f8e724 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -35,16 +35,21 @@ impl ReductionResult for ReductionLongestCircuitToILP { } /// Extract: output the binary edge-selection vector (y_e). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + num_vertices + 2 * num_edges * (num_vertices - 1)", num_constraints = "1 + num_vertices^2 + 2 * num_edges * (num_vertices - 1)", - } + }, )] impl ReduceTo> for LongestCircuit { type Result = ReductionLongestCircuitToILP; diff --git a/src/rules/longestcommonsubsequence_ilp.rs b/src/rules/longestcommonsubsequence_ilp.rs index 565305fc2..3eac921ab 100644 --- a/src/rules/longestcommonsubsequence_ilp.rs +++ b/src/rules/longestcommonsubsequence_ilp.rs @@ -31,24 +31,26 @@ impl ReductionResult for ReductionLCSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_symbols = self.alphabet_size + 1; - let mut witness = Vec::with_capacity(self.max_length); - for position in 0..self.max_length { - let selected = (0..num_symbols) - .find(|&symbol| target_solution.get(position * num_symbols + symbol) == Some(&1)) - .unwrap_or(self.alphabet_size); - witness.push(selected); - } - witness + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.max_length, + self.alphabet_size + 1, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "max_length * (alphabet_size + 1) + max_length * total_length", num_constraints = "max_length + num_transitions + max_length * num_strings + max_length * total_length + num_transitions * sum_triangular_lengths", - } + }, )] impl ReduceTo> for LongestCommonSubsequence { type Result = ReductionLCSToILP; diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index 9cecb576a..2e2279cd5 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -48,32 +48,39 @@ impl ReductionResult for ReductionLCSToIS { /// /// Selected vertices correspond to match nodes. Sort by position in /// the first string to get the subsequence order, then pad to `max_length`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Collect selected match nodes with their characters - let mut selected: Vec<(usize, usize)> = target_solution - .iter() - .enumerate() - .filter(|(_, &v)| v == 1) - .map(|(i, _)| (self.match_nodes[i][0], self.match_chars[i])) - .collect(); - // Sort by position in the first string - selected.sort_by_key(|&(pos, _)| pos); - - // Build config: characters followed by padding - let mut config = Vec::with_capacity(self.max_length); - for &(_, ch) in &selected { - config.push(ch); - } - // Pad with alphabet_size (the padding symbol) - while config.len() < self.max_length { - config.push(self.alphabet_size); - } - config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Collect selected match nodes with their characters + let mut selected: Vec<(usize, usize)> = target_solution + .iter() + .enumerate() + .filter(|(_, &v)| v == 1) + .map(|(i, _)| (self.match_nodes[i][0], self.match_chars[i])) + .collect(); + // Sort by position in the first string + selected.sort_by_key(|&(pos, _)| pos); + + // Build config: characters followed by padding + let mut config = Vec::with_capacity(self.max_length); + for &(_, ch) in &selected { + config.push(ch); + } + // Pad with alphabet_size (the padding symbol) + while config.len() < self.max_length { + config.push(self.alphabet_size); + } + config + }) } } #[reduction( - overhead = { + size = upper_bound { num_vertices = "cross_frequency_product", num_edges = "cross_frequency_product^2", } diff --git a/src/rules/longestpath_ilp.rs b/src/rules/longestpath_ilp.rs index 7c43a1a74..11d047404 100644 --- a/src/rules/longestpath_ilp.rs +++ b/src/rules/longestpath_ilp.rs @@ -31,30 +31,30 @@ impl ReductionResult for ReductionLongestPathToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_edges) - .map(|edge_idx| { - usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution[Self::arc_var(edge_idx, 0)] > 0 + || target_solution[Self::arc_var(edge_idx, 1)] > 0, + ) + }) + .collect() + }) } } -#[reduction(overhead = { - num_vars = "2 * num_edges + num_vertices", - num_constraints = "5 * num_edges + 4 * num_vertices + 1", -})] +#[reduction( + size = exact { + num_vars = "2 * num_edges + num_vertices", + num_constraints = "5 * num_edges + 4 * num_vertices + 1", + },)] impl ReduceTo> for LongestPath { type Result = ReductionLongestPathToILP; diff --git a/src/rules/maxcut_minimumcutintoboundedsets.rs b/src/rules/maxcut_minimumcutintoboundedsets.rs index e3289b666..7bf6beccf 100644 --- a/src/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/rules/maxcut_minimumcutintoboundedsets.rs @@ -30,13 +30,18 @@ impl ReductionResult for ReductionMaxCutToMinCutBounded { /// Extract the source solution from the target balanced bisection. /// Take only the first `original_n` vertex assignments. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.original_n].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.original_n].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vertices + 2", num_edges = "(num_vertices + 1) * (2 * num_vertices + 1)", } diff --git a/src/rules/maxcut_minimummatrixcover.rs b/src/rules/maxcut_minimummatrixcover.rs index 3cc465185..53ac2e48b 100644 --- a/src/rules/maxcut_minimummatrixcover.rs +++ b/src/rules/maxcut_minimummatrixcover.rs @@ -48,13 +48,18 @@ impl ReductionResult for ReductionMaxCutToMMC { /// vertex `i` in `S`. The complementary assignment is equally optimal /// because the quadratic form (and the cut) is invariant under /// `f -> -f`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_rows = "num_vertices", } )] diff --git a/src/rules/maximalis_ilp.rs b/src/rules/maximalis_ilp.rs index 8e0f45a00..25f4372b1 100644 --- a/src/rules/maximalis_ilp.rs +++ b/src/rules/maximalis_ilp.rs @@ -22,16 +22,21 @@ impl ReductionResult for ReductionMxISToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", num_constraints = "num_edges + num_vertices", - } + }, )] impl ReduceTo> for MaximalIS { type Result = ReductionMxISToILP; diff --git a/src/rules/maximum2satisfiability_ilp.rs b/src/rules/maximum2satisfiability_ilp.rs index 1631aff91..2ba4527a7 100644 --- a/src/rules/maximum2satisfiability_ilp.rs +++ b/src/rules/maximum2satisfiability_ilp.rs @@ -27,16 +27,21 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vars].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vars + num_clauses", num_constraints = "num_clauses", - } + }, )] impl ReduceTo> for Maximum2Satisfiability { type Result = ReductionMaximum2SatisfiabilityToILP; diff --git a/src/rules/maximum2satisfiability_maxcut.rs b/src/rules/maximum2satisfiability_maxcut.rs index 8b0e8d6cd..cef060b6d 100644 --- a/src/rules/maximum2satisfiability_maxcut.rs +++ b/src/rules/maximum2satisfiability_maxcut.rs @@ -33,11 +33,18 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToMaxCut { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let reference_side = target_solution[0]; - (0..self.source_num_vars) - .map(|i| usize::from(target_solution[i + 1] == reference_side)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let reference_side = target_solution[0]; + (0..self.source_num_vars) + .map(|i| usize::from(target_solution[i + 1] == reference_side)) + .collect() + }) } } @@ -55,9 +62,9 @@ fn literal_polarity(lit: i32) -> i32 { } #[reduction( - overhead = { + size = upper_bound { num_vertices = "num_vars + 1", - num_edges = "num_vars + num_clauses", + num_edges = "(num_vars + 1)^2", } )] impl ReduceTo> for Maximum2Satisfiability { diff --git a/src/rules/maximumclique_ilp.rs b/src/rules/maximumclique_ilp.rs index c0ac43130..ea0cd0b6b 100644 --- a/src/rules/maximumclique_ilp.rs +++ b/src/rules/maximumclique_ilp.rs @@ -35,15 +35,23 @@ impl ReductionResult for ReductionCliqueToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", - num_constraints = "num_vertices^2", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MaximumClique { diff --git a/src/rules/maximumclique_maximumindependentset.rs b/src/rules/maximumclique_maximumindependentset.rs index 91bd6ebbf..aac322a85 100644 --- a/src/rules/maximumclique_maximumindependentset.rs +++ b/src/rules/maximumclique_maximumindependentset.rs @@ -28,8 +28,13 @@ where /// Solution extraction: identity mapping. /// A clique in G is an independent set in the complement, so the configuration is the same. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -45,7 +50,7 @@ fn reduce_clique_to_is( } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", } @@ -59,7 +64,7 @@ impl ReduceTo> for MaximumClique Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -70,10 +75,10 @@ where } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", num_constraints = "num_vertices", - } + }, )] impl ReduceTo> for MaximumCoKPlex { type Result = ReductionCoKPlexToILP; @@ -90,10 +95,10 @@ impl ReduceTo> for MaximumCoKPlex { } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", num_constraints = "num_vertices", - } + }, )] impl ReduceTo> for MaximumCoKPlex { type Result = ReductionCoKPlexToILP; diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index a36090ec4..e2f830900 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -43,23 +43,33 @@ impl ReductionResult for ReductionMCESToILP { /// Extract: for each source vertex `u`, output the unique target vertex /// `p` with `x_(u,p) = 1`, or the sentinel `n2` ("bottom") when no /// mapping variable is selected. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n1 = self.num_vertices_1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n2 = self.num_vertices_2; - (0..n1) - .map(|u| { - (0..n2) - .find(|&p| target_solution[u * n2 + p] == 1) - .unwrap_or(n2) + (0..self.num_vertices_1) + .map(|vertex| { + let mut selected = + (0..n2).filter(|&mapped| target_solution[vertex * n2 + mapped] == 1); + match (selected.next(), selected.next()) { + (Some(mapped), None) => Ok(mapped), + (None, _) => Ok(n2), + (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( + "source vertex {vertex} maps to multiple target vertices" + ))), + } }) .collect() } } #[reduction( - overhead = { - num_vars = "num_vertices_1 * num_vertices_2 + num_arcs_1 * num_arcs_2", - num_constraints = "num_vertices_1 + num_vertices_2 + 3 * num_arcs_1 * num_arcs_2", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MaximumCommonEdgeSubgraph { diff --git a/src/rules/maximumcontactmapoverlap_ilp.rs b/src/rules/maximumcontactmapoverlap_ilp.rs index 6607997da..739802b6b 100644 --- a/src/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/rules/maximumcontactmapoverlap_ilp.rs @@ -46,25 +46,34 @@ impl ReductionResult for ReductionCMOToILP { /// For each source residue `i in V_1`, find the unique `j` with /// `x_(i,j) = 1` and encode it as `j + 1` (CMO's `bot` is `0`); if no /// `x_(i,*)` is selected, the residue is left unmatched (`0`). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n1 = self.num_vertices_1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n2 = self.num_vertices_2; - (0..n1) - .map(|i| { - (0..n2) - .find(|&j| target_solution[i * n2 + j] == 1) - .map(|j| j + 1) - .unwrap_or(0) + (0..self.num_vertices_1) + .map(|residue| { + let mut selected = + (0..n2).filter(|&mapped| target_solution[residue * n2 + mapped] == 1); + match (selected.next(), selected.next()) { + (Some(mapped), None) => Ok(mapped + 1), + (None, _) => Ok(0), + (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( + "source residue {residue} maps to multiple target residues" + ))), + } }) .collect() } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices_1 * num_vertices_2 + num_contacts_1 * num_contacts_2", num_constraints = "num_vertices_1 + num_vertices_2 + num_vertices_1 * (num_vertices_1 - 1) / 2 * num_vertices_2 * (num_vertices_2 + 1) / 2 + 2 * num_contacts_1 * num_contacts_2", - } + }, )] impl ReduceTo> for MaximumContactMapOverlap { type Result = ReductionCMOToILP; diff --git a/src/rules/maximumdomaticnumber_ilp.rs b/src/rules/maximumdomaticnumber_ilp.rs index ebf772153..fb81abdbd 100644 --- a/src/rules/maximumdomaticnumber_ilp.rs +++ b/src/rules/maximumdomaticnumber_ilp.rs @@ -36,26 +36,33 @@ impl ReductionResult for ReductionDomaticNumberToILP { /// Extract solution from ILP back to MaximumDomaticNumber. /// /// For each vertex v, find the set index i where x_{v,i} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let mut config = vec![0; n]; - for v in 0..n { - for i in 0..n { - if target_solution[v * n + i] == 1 { - config[v] = i; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.n; + let mut config = vec![0; n]; + for v in 0..n { + for i in 0..n { + if target_solution[v * n + i] == 1 { + config[v] = i; + break; + } } } - } - config + config + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices * num_vertices + num_vertices", num_constraints = "num_vertices + num_vertices * num_vertices + num_vertices * num_vertices", - } + }, )] impl ReduceTo> for MaximumDomaticNumber { type Result = ReductionDomaticNumberToILP; diff --git a/src/rules/maximumedgeweightedkclique_ilp.rs b/src/rules/maximumedgeweightedkclique_ilp.rs index 5db911e78..4d19cce70 100644 --- a/src/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/rules/maximumedgeweightedkclique_ilp.rs @@ -58,8 +58,13 @@ where /// Extract: take the first `num_vertices` entries of the ILP solution. /// They are exactly the binary `x_v` selection variables. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } @@ -119,10 +124,10 @@ where } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_edges", num_constraints = "1 + num_vertices * (num_vertices - 1) / 2 + 2 * num_edges", - } + }, )] impl ReduceTo> for MaximumEdgeWeightedKClique { type Result = ReductionMaximumEdgeWeightedKCliqueToILP; @@ -134,10 +139,10 @@ impl ReduceTo> for MaximumEdgeWeightedKClique { } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_edges", num_constraints = "1 + num_vertices * (num_vertices - 1) / 2 + 2 * num_edges", - } + }, )] impl ReduceTo> for MaximumEdgeWeightedKClique { type Result = ReductionMaximumEdgeWeightedKCliqueToILP; diff --git a/src/rules/maximumindependentset_casts.rs b/src/rules/maximumindependentset_casts.rs index c293f0019..fe4b527bd 100644 --- a/src/rules/maximumindependentset_casts.rs +++ b/src/rules/maximumindependentset_casts.rs @@ -13,6 +13,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -21,6 +22,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -29,6 +31,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -38,6 +41,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -46,6 +50,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -55,6 +60,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); @@ -63,6 +69,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); @@ -71,6 +78,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); diff --git a/src/rules/maximumindependentset_gridgraph.rs b/src/rules/maximumindependentset_gridgraph.rs index 2515371b8..b0c41540b 100644 --- a/src/rules/maximumindependentset_gridgraph.rs +++ b/src/rules/maximumindependentset_gridgraph.rs @@ -25,13 +25,18 @@ impl ReductionResult for ReductionISSimpleOneToGridOne { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.mapping_result.map_config_back(target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(self.mapping_result.map_config_back(target_solution)) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices * num_vertices", num_edges = "num_vertices * num_vertices", } diff --git a/src/rules/maximumindependentset_integralflowbundles.rs b/src/rules/maximumindependentset_integralflowbundles.rs index 6928d7336..8be73ad30 100644 --- a/src/rules/maximumindependentset_integralflowbundles.rs +++ b/src/rules/maximumindependentset_integralflowbundles.rs @@ -43,21 +43,22 @@ impl ReductionResult for ReductionMISToIFB { /// Extract solution: vertex i is selected iff arc_out_i (index 2i + 1) /// has nonzero flow. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_source_vertices) - .map(|i| { - if target_solution.get(2 * i + 1).copied().unwrap_or(0) > 0 { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_source_vertices) + .map(|i| if target_solution[2 * i + 1] > 0 { 1 } else { 0 }) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices + 2", num_arcs = "2 * num_vertices", num_bundles = "num_edges + num_vertices", @@ -141,7 +142,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( source, diff --git a/src/rules/maximumindependentset_maximumclique.rs b/src/rules/maximumindependentset_maximumclique.rs index f65042b51..85327d435 100644 --- a/src/rules/maximumindependentset_maximumclique.rs +++ b/src/rules/maximumindependentset_maximumclique.rs @@ -28,8 +28,13 @@ where /// Solution extraction: identity mapping. /// A vertex selected in the clique (target) is also selected in the independent set (source). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -45,7 +50,7 @@ fn reduce_is_to_clique( } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", } @@ -59,7 +64,7 @@ impl ReduceTo> for MaximumIndependentSet Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } macro_rules! impl_is_to_sp { ($W:ty) => { - #[reduction(overhead = { num_sets = "num_vertices", universe_size = "num_edges" })] + #[reduction(size = unavailable { + num_sets = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", + universe_size = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", + })] impl ReduceTo> for MaximumIndependentSet { type Result = ReductionISToSP<$W>; @@ -80,14 +88,22 @@ where } /// Solutions map directly. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } macro_rules! impl_sp_to_is { ($W:ty) => { - #[reduction(overhead = { num_vertices = "num_sets", num_edges = "num_sets^2" })] + #[reduction(size = unavailable { + num_vertices = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", + num_edges = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", + })] impl ReduceTo> for MaximumSetPacking<$W> { type Result = ReductionSPToIS<$W>; diff --git a/src/rules/maximumindependentset_triangular.rs b/src/rules/maximumindependentset_triangular.rs index 6d9bd44c5..7f1081207 100644 --- a/src/rules/maximumindependentset_triangular.rs +++ b/src/rules/maximumindependentset_triangular.rs @@ -27,14 +27,21 @@ impl ReductionResult for ReductionISSimpleToTriangular { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.mapping_result - .map_config_back_via_centers(target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.mapping_result + .map_config_back_via_centers(target_solution) + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices * num_vertices", num_edges = "num_vertices * num_vertices", } diff --git a/src/rules/maximumleafspanningtree_ilp.rs b/src/rules/maximumleafspanningtree_ilp.rs index c6bdcb78d..d4d713d07 100644 --- a/src/rules/maximumleafspanningtree_ilp.rs +++ b/src/rules/maximumleafspanningtree_ilp.rs @@ -39,17 +39,24 @@ impl ReductionResult for ReductionMaximumLeafSpanningTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First m variables are edge selectors - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // First m variables are edge selectors + target_solution[..self.num_edges].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "3 * num_edges + num_vertices", num_constraints = "3 * num_vertices + 2 * num_edges + 1", - } + }, )] impl ReduceTo> for MaximumLeafSpanningTree { type Result = ReductionMaximumLeafSpanningTreeToILP; diff --git a/src/rules/maximumlikelihoodranking_ilp.rs b/src/rules/maximumlikelihoodranking_ilp.rs index 52abbac60..1959a7f6d 100644 --- a/src/rules/maximumlikelihoodranking_ilp.rs +++ b/src/rules/maximumlikelihoodranking_ilp.rs @@ -39,37 +39,44 @@ impl ReductionResult for ReductionMaximumLikelihoodRankingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - if n == 0 { - return vec![]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.n; + if n == 0 { + return Ok(vec![]); + } - // Count how many items are ranked before each item i. - // config[i] = number of items ranked before i = rank of item i. - let mut config = vec![0usize; n]; - for i in 0..n { - for j in (i + 1)..n { - let idx = pair_index(i, j, n); - if target_solution[idx] == 1 { - // i is before j -> contributes 1 to config[j] - config[j] += 1; - } else { - // j is before i -> contributes 1 to config[i] - config[i] += 1; + // Count how many items are ranked before each item i. + // config[i] = number of items ranked before i = rank of item i. + let mut config = vec![0usize; n]; + for i in 0..n { + for j in (i + 1)..n { + let idx = pair_index(i, j, n); + if target_solution[idx] == 1 { + // i is before j -> contributes 1 to config[j] + config[j] += 1; + } else { + // j is before i -> contributes 1 to config[i] + config[i] += 1; + } } } - } - config + config + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_items * (num_items - 1) / 2", num_constraints = "num_items * (num_items - 1) * (num_items - 2) / 3", - } + }, )] impl ReduceTo> for MaximumLikelihoodRanking { type Result = ReductionMaximumLikelihoodRankingToILP; diff --git a/src/rules/maximummatching_ilp.rs b/src/rules/maximummatching_ilp.rs index 329a104d5..1c28381fa 100644 --- a/src/rules/maximummatching_ilp.rs +++ b/src/rules/maximummatching_ilp.rs @@ -35,16 +35,21 @@ impl ReductionResult for ReductionMatchingToILP { /// /// Since the mapping is 1:1 (each edge maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges", num_constraints = "num_vertices", - } + }, )] impl ReduceTo> for MaximumMatching { type Result = ReductionMatchingToILP; diff --git a/src/rules/maximummatching_maximumsetpacking.rs b/src/rules/maximummatching_maximumsetpacking.rs index 9c74bf411..30f69f2d9 100644 --- a/src/rules/maximummatching_maximumsetpacking.rs +++ b/src/rules/maximummatching_maximumsetpacking.rs @@ -30,13 +30,18 @@ where } /// Solutions map directly: edge i in MaximumMatching = set i in MaximumSetPacking. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_sets = "num_edges", universe_size = "num_vertices", } diff --git a/src/rules/maximumsetpacking_casts.rs b/src/rules/maximumsetpacking_casts.rs index e9afd996f..23ff12005 100644 --- a/src/rules/maximumsetpacking_casts.rs +++ b/src/rules/maximumsetpacking_casts.rs @@ -9,6 +9,7 @@ impl_variant_reduction!( MaximumSetPacking, => , fields: [num_sets, universe_size], + aggregate: identity, |src| MaximumSetPacking::with_weights( src.sets().to_vec(), src.weights_ref().iter().map(|w| w.cast_to_parent()).collect()) diff --git a/src/rules/maximumsetpacking_ilp.rs b/src/rules/maximumsetpacking_ilp.rs index 7ccd7de47..177245ef2 100644 --- a/src/rules/maximumsetpacking_ilp.rs +++ b/src/rules/maximumsetpacking_ilp.rs @@ -29,16 +29,21 @@ impl ReductionResult for ReductionSPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_sets", num_constraints = "universe_size", - } + }, )] impl ReduceTo> for MaximumSetPacking { type Result = ReductionSPToILP; diff --git a/src/rules/maximumsetpacking_qubo.rs b/src/rules/maximumsetpacking_qubo.rs index a3b13949c..0e6d73314 100644 --- a/src/rules/maximumsetpacking_qubo.rs +++ b/src/rules/maximumsetpacking_qubo.rs @@ -25,13 +25,20 @@ impl ReductionResult for ReductionSPToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { num_vars = "num_sets" } + size = exact { + num_vars = "num_sets", + } )] impl ReduceTo> for MaximumSetPacking { type Result = ReductionSPToQUBO; diff --git a/src/rules/minimumcapacitatedspanningtree_ilp.rs b/src/rules/minimumcapacitatedspanningtree_ilp.rs index 7208dc432..6a07c4a96 100644 --- a/src/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/rules/minimumcapacitatedspanningtree_ilp.rs @@ -42,16 +42,26 @@ impl ReductionResult for ReductionMinimumCapacitatedSpanningTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First m variables are edge selectors - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // First m variables are edge selectors + target_solution[..self.num_edges].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "3 * num_edges", - num_constraints = "5 * num_edges + num_vertices + 1", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MinimumCapacitatedSpanningTree { diff --git a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 36c941c36..2f502e452 100644 --- a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -43,13 +43,18 @@ impl ReductionResult for ReductionMCMFToMCC { /// Extract the source flow by discarding the return arc: the first /// `num_original_arcs` entries of the circulation are exactly the /// flow values on the original arcs. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_original_arcs].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_arcs = "num_arcs + 1", } diff --git a/src/rules/minimumcoveringbycliques_ilp.rs b/src/rules/minimumcoveringbycliques_ilp.rs index 52c643111..36af91087 100644 --- a/src/rules/minimumcoveringbycliques_ilp.rs +++ b/src/rules/minimumcoveringbycliques_ilp.rs @@ -39,28 +39,33 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.num_edges == 0 { - return vec![]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; (0..self.num_edges) - .map(|edge_idx| { + .map(|edge| { (0..self.num_edges) - .find(|&slot| { - target_solution[self.y_offset + edge_idx * self.num_edges + slot] == 1 + .find(|&clique| { + target_solution[self.y_offset + edge * self.num_edges + clique] == 1 + }) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "edge {edge} is not covered by any clique" + )) }) - .unwrap_or(0) }) .collect() } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices * num_edges + num_edges + num_edges * num_edges", num_constraints = "num_vertices * num_edges + (num_vertices * (num_vertices - 1) / 2 - num_edges) * num_edges + 3 * num_edges * num_edges + num_edges", - } + }, )] impl ReduceTo> for MinimumCoveringByCliques { type Result = ReductionMinimumCoveringByCliquesToILP; diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index a700acdfe..4ac1a9c58 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -16,15 +16,6 @@ pub struct ReductionMinimumCoveringByCliquesToMinimumIntersectionGraphBasis { target: MinimumIntersectionGraphBasis, } -fn invalid_source_solution(num_edges: usize) -> Vec { - if num_edges == 0 { - // Deliberately wrong length so source `evaluate` returns `Min(None)`. - vec![0] - } else { - vec![0; num_edges - 1] - } -} - fn extract_edge_clique_cover(graph: &SimpleGraph, target_solution: &[usize]) -> Option> { let n = graph.num_vertices(); let m = graph.num_edges(); @@ -89,18 +80,30 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if !self.target.evaluate(target_solution).is_valid() { - return invalid_source_solution(self.target.num_edges()); - } - - extract_edge_clique_cover(self.target.graph(), target_solution) - .unwrap_or_else(|| invalid_source_solution(self.target.num_edges())) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + if !self.target.evaluate(target_solution).is_valid() { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not a valid intersection graph basis", + )); + } + + extract_edge_clique_cover(self.target.graph(), target_solution).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target basis does not assign a shared label to every source edge", + ) + })? + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } diff --git a/src/rules/minimumcutintoboundedsets_ilp.rs b/src/rules/minimumcutintoboundedsets_ilp.rs index 8edb3a65d..609eb84ac 100644 --- a/src/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/rules/minimumcutintoboundedsets_ilp.rs @@ -26,16 +26,21 @@ impl ReductionResult for ReductionMinCutBSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_edges", num_constraints = "2 + 2 + 2 * num_edges", - } + }, )] impl ReduceTo> for MinimumCutIntoBoundedSets { type Result = ReductionMinCutBSToILP; diff --git a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs index 317daa77c..8d5b46619 100644 --- a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -39,21 +39,38 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + self.block_offsets .iter() .zip(&self.block_sizes) - .map(|(&start, &size)| { - target_solution[start..start + size] + .enumerate() + .map(|(link, (&start, &size))| { + let mut selected = target_solution[start..start + size] .iter() - .position(|&bit| bit == 1) - .unwrap_or(0) + .enumerate() + .filter_map(|(orientation, &bit)| (bit == 1).then_some(orientation)); + match (selected.next(), selected.next()) { + (Some(orientation), None) => Ok(orientation), + (None, _) => Err(crate::rules::ExtractionError::invalid(format!( + "link {link} has no selected orientation" + ))), + (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( + "link {link} has multiple selected orientations" + ))), + } }) .collect() } } -#[reduction(overhead = { num_vars = "num_orientation_samples" })] +#[reduction(size = exact { + num_vars = "num_orientation_samples", +})] impl ReduceTo> for MinimumDiscretePlanarInverseKinematics { type Result = ReductionMinimumDiscretePlanarInverseKinematicsToQUBO; diff --git a/src/rules/minimumdominatingset_ilp.rs b/src/rules/minimumdominatingset_ilp.rs index 7aa9933c0..24fa558e2 100644 --- a/src/rules/minimumdominatingset_ilp.rs +++ b/src/rules/minimumdominatingset_ilp.rs @@ -36,16 +36,21 @@ impl ReductionResult for ReductionDSToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", num_constraints = "num_vertices", - } + }, )] impl ReduceTo> for MinimumDominatingSet { type Result = ReductionDSToILP; diff --git a/src/rules/minimumedgecostflow_ilp.rs b/src/rules/minimumedgecostflow_ilp.rs index fda1c6908..9bf4782fa 100644 --- a/src/rules/minimumedgecostflow_ilp.rs +++ b/src/rules/minimumedgecostflow_ilp.rs @@ -43,16 +43,21 @@ impl ReductionResult for ReductionMECFToILP { } /// Extract flow solution: first m variables are the flow values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "2 * num_edges", num_constraints = "2 * num_edges + num_vertices - 1", - } + }, )] impl ReduceTo> for MinimumEdgeCostFlow { type Result = ReductionMECFToILP; diff --git a/src/rules/minimumexternalmacrodatacompression_ilp.rs b/src/rules/minimumexternalmacrodatacompression_ilp.rs index 9e50b9f1a..5e72621b4 100644 --- a/src/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/rules/minimumexternalmacrodatacompression_ilp.rs @@ -121,66 +121,85 @@ impl ReductionResult for ReductionEMDCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.layout.n; - let k = self.alphabet_size; - let empty = k; // empty marker - - // Build D-slots - let mut d_slots = vec![empty; n]; - for j in 0..n { - if target_solution[self.layout.d_used_var(j)] == 1 { - for c in 0..k { - if target_solution[self.layout.d_var(j, c)] == 1 { - d_slots[j] = c; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.layout.n; + let k = self.alphabet_size; + let empty = k; // empty marker + + // Build D-slots + let mut d_slots = vec![empty; n]; + for j in 0..n { + let symbols: Vec<_> = (0..k) + .filter(|&c| target_solution[self.layout.d_var(j, c)] == 1) + .collect(); + if target_solution[self.layout.d_used_var(j)] == 1 { + match symbols.as_slice() { + [symbol] => d_slots[j] = *symbol, + [] => { + return Err(crate::rules::ExtractionError::invalid(format!( + "dictionary slot {j} is active without a symbol" + ))) + } + _ => { + return Err(crate::rules::ExtractionError::invalid(format!( + "dictionary slot {j} selects multiple symbols" + ))) + } } + } else if !symbols.is_empty() { + return Err(crate::rules::ExtractionError::invalid(format!( + "inactive dictionary slot {j} selects a symbol" + ))); } } - } - // Walk through active segments to build C-slots - let mut c_slots = vec![empty; n]; - let mut c_pos = 0; - let mut pos = 0; - while pos < n { - // Check if lit[pos] = 1 - if target_solution[self.layout.lit_var(pos)] == 1 { - // Literal at position pos - c_slots[c_pos] = self.source_string[pos]; - c_pos += 1; - pos += 1; - continue; - } - // Check for an active pointer starting at pos - let mut found = false; - for l in 1..=(n - pos) { - for d_start in 0..=(n - l) { - let var_idx = self.layout.ptr_var(pos, l, d_start); - if target_solution[var_idx] == 1 { - // Encode pointer (d_start, l) as EMDC pointer index - let ptr_idx = encode_pointer(n, d_start, l); - c_slots[c_pos] = k + 1 + ptr_idx; - c_pos += 1; - pos += l; - found = true; - break; + // Walk through active segments to build C-slots + let mut c_slots = vec![empty; n]; + let mut c_pos = 0; + let mut pos = 0; + while pos < n { + let pointers: Vec<_> = (1..=(n - pos)) + .flat_map(|length| { + (0..=(n - length)).filter_map(move |start| { + (target_solution[self.layout.ptr_var(pos, length, start)] == 1) + .then_some((start, length)) + }) + }) + .collect(); + if target_solution[self.layout.lit_var(pos)] == 1 { + if !pointers.is_empty() { + return Err(crate::rules::ExtractionError::invalid(format!( + "position {pos} selects both a literal and a pointer" + ))); } + // Literal at position pos + c_slots[c_pos] = self.source_string[pos]; + c_pos += 1; + pos += 1; + continue; } - if found { - break; - } - } - if !found { - // Should not happen with a valid ILP solution - pos += 1; + let [(d_start, length)] = pointers.as_slice() else { + return Err(crate::rules::ExtractionError::invalid(format!( + "position {pos} must select exactly one pointer" + ))); + }; + let ptr_idx = encode_pointer(n, *d_start, *length); + c_slots[c_pos] = k + 1 + ptr_idx; + c_pos += 1; + pos += length; } - } - // Combine D-slots and C-slots - let mut config = d_slots; - config.extend(c_slots); - config + // Combine D-slots and C-slots + let mut config = d_slots; + config.extend(c_slots); + config + }) } } @@ -195,9 +214,9 @@ fn encode_pointer(n: usize, start: usize, len: usize) -> usize { } #[reduction( - overhead = { - num_vars = "string_length * alphabet_size + 2 * string_length + string_length ^ 3", - num_constraints = "string_length + string_length * alphabet_size + string_length + string_length + 1 + string_length ^ 3 * string_length", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MinimumExternalMacroDataCompression { @@ -387,7 +406,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vars = "num_inputs * num_outputs", - num_constraints = "num_vertices - num_inputs - num_outputs", -})] +#[reduction( + size = exact { + num_vars = "num_inputs * num_outputs", + num_constraints = "num_vertices - num_inputs - num_outputs", + },)] impl ReduceTo> for MinimumFaultDetectionTestSet { type Result = ReductionMFDTSToILP; diff --git a/src/rules/minimumfeedbackarcset_ilp.rs b/src/rules/minimumfeedbackarcset_ilp.rs index fcce6d4ec..171dc44ba 100644 --- a/src/rules/minimumfeedbackarcset_ilp.rs +++ b/src/rules/minimumfeedbackarcset_ilp.rs @@ -41,16 +41,21 @@ impl ReductionResult for ReductionFASToILP { /// /// The first m variables of the ILP solution are the binary y_a values, /// which directly correspond to the FAS configuration (1 = removed). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_arcs].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_arcs + num_vertices", num_constraints = "num_arcs + num_arcs + num_vertices", - } + }, )] impl ReduceTo> for MinimumFeedbackArcSet { type Result = ReductionFASToILP; diff --git a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index 3b07146a5..9deae2c85 100644 --- a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -48,16 +48,23 @@ impl ReductionResult for ReductionFASToMLR { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_arcs - .iter() - .map(|&(u, v)| usize::from(target_solution[u] > target_solution[v])) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.source_arcs + .iter() + .map(|&(u, v)| usize::from(target_solution[u] > target_solution[v])) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_items = "num_vertices", } )] @@ -96,7 +103,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( source, diff --git a/src/rules/minimumfeedbackvertexset_ilp.rs b/src/rules/minimumfeedbackvertexset_ilp.rs index 1f3e45032..ee7f2f5a8 100644 --- a/src/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/rules/minimumfeedbackvertexset_ilp.rs @@ -38,16 +38,21 @@ impl ReductionResult for ReductionMFVSToILP { /// /// The first n variables of the ILP solution are the binary x_i values, /// which directly correspond to the FVS configuration (1 = removed). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "2 * num_vertices", num_constraints = "num_arcs + 2 * num_vertices", - } + }, )] impl ReduceTo> for MinimumFeedbackVertexSet { type Result = ReductionMFVSToILP; diff --git a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 82f5db3d7..f4d38ea51 100644 --- a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -37,38 +37,45 @@ impl ReductionResult for ReductionFVSToCodeGen { /// A leaf register R_x is destroyed when x¹ executes (left operand). /// If any right-child user of x⁰ is evaluated after x¹, a LOAD was needed, /// meaning x is in the feedback vertex set. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_source_vertices; - let mut source_config = vec![0usize; n]; - - // target_solution[i] = evaluation position for the i-th internal node - // Internal nodes are indices n, n+1, ..., n+m-1 (sorted), so - // target_solution[j] = position for internal node (n + j). - - // eval_pos[j] = evaluation position for internal node (n + j) - let eval_pos = target_solution; - - for (x, cfg) in source_config.iter_mut().enumerate() { - if let Some(chain_start_idx) = self.chain_start[x] { - let start_j = chain_start_idx - n; - let start_pos = eval_pos[start_j]; - - for &user_idx in &self.right_child_users[x] { - let user_j = user_idx - n; - if eval_pos[user_j] > start_pos { - *cfg = 1; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_source_vertices; + let mut source_config = vec![0usize; n]; + + // target_solution[i] = evaluation position for the i-th internal node + // Internal nodes are indices n, n+1, ..., n+m-1 (sorted), so + // target_solution[j] = position for internal node (n + j). + + // eval_pos[j] = evaluation position for internal node (n + j) + let eval_pos = target_solution; + + for (x, cfg) in source_config.iter_mut().enumerate() { + if let Some(chain_start_idx) = self.chain_start[x] { + let start_j = chain_start_idx - n; + let start_pos = eval_pos[start_j]; + + for &user_idx in &self.right_child_users[x] { + let user_j = user_idx - n; + if eval_pos[user_j] > start_pos { + *cfg = 1; + break; + } } } } - } - source_config + source_config + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices + num_arcs", } )] @@ -162,7 +169,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_vertices, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2 + num_vertices + 1", num_constraints = "2 * num_vertices + num_vertices^2 + num_vertices + num_vertices + 1 + 2 * num_edges", - } + }, )] impl ReduceTo> for MinimumGraphBandwidth { type Result = ReductionMGBToILP; diff --git a/src/rules/minimumhittingset_ilp.rs b/src/rules/minimumhittingset_ilp.rs index 14018ffaf..03d63ff63 100644 --- a/src/rules/minimumhittingset_ilp.rs +++ b/src/rules/minimumhittingset_ilp.rs @@ -21,16 +21,21 @@ impl ReductionResult for ReductionHSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "universe_size", num_constraints = "num_sets", - } + }, )] impl ReduceTo> for MinimumHittingSet { type Result = ReductionHSToILP; diff --git a/src/rules/minimuminternalmacrodatacompression_ilp.rs b/src/rules/minimuminternalmacrodatacompression_ilp.rs index fe442a3e2..c1bac1f0f 100644 --- a/src/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/rules/minimuminternalmacrodatacompression_ilp.rs @@ -95,67 +95,77 @@ impl ReductionResult for ReductionIMDCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.layout.n; - let k = self.alphabet_size; - let eos = k; // end-of-string marker - - // First pass: collect segments and build source-to-compressed-position map. - // source_to_c_pos[i] = compressed position that covers source position i. - let mut source_to_c_pos = vec![0usize; n]; - let mut segments: Vec<(usize, usize, Option)> = Vec::new(); // (source_start, len, ref_source_pos) - let mut c_pos = 0; - let mut pos = 0; - - while pos < n { - if target_solution[self.layout.lit_var(pos)] == 1 { - source_to_c_pos[pos] = c_pos; - segments.push((pos, 1, None)); - c_pos += 1; - pos += 1; - continue; - } - let mut found = false; - for (idx, &(i, l, r)) in self.layout.ptr_triples.iter().enumerate() { - if i == pos && target_solution[self.layout.ptr_offset + idx] == 1 { - for offset in 0..l { - source_to_c_pos[pos + offset] = c_pos; - } - segments.push((pos, l, Some(r))); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.layout.n; + let k = self.alphabet_size; + let eos = k; // end-of-string marker + + // First pass: collect segments and build source-to-compressed-position map. + // source_to_c_pos[i] = compressed position that covers source position i. + let mut source_to_c_pos = vec![0usize; n]; + let mut segments: Vec<(usize, usize, Option)> = Vec::new(); // (source_start, len, ref_source_pos) + let mut c_pos = 0; + let mut pos = 0; + + while pos < n { + if target_solution[self.layout.lit_var(pos)] == 1 { + source_to_c_pos[pos] = c_pos; + segments.push((pos, 1, None)); c_pos += 1; - pos += l; - found = true; - break; + pos += 1; + continue; + } + let mut found = false; + for (idx, &(i, l, r)) in self.layout.ptr_triples.iter().enumerate() { + if i == pos && target_solution[self.layout.ptr_offset + idx] == 1 { + for offset in 0..l { + source_to_c_pos[pos + offset] = c_pos; + } + segments.push((pos, l, Some(r))); + c_pos += 1; + pos += l; + found = true; + break; + } + } + if !found { + pos += 1; } } - if !found { - pos += 1; - } - } - // Second pass: build config using source_to_c_pos for pointer references - let mut config = vec![eos; n]; - for (idx, &(src_start, _len, ref_pos)) in segments.iter().enumerate() { - match ref_pos { - None => { - config[idx] = self.source_string[src_start]; - } - Some(r) => { - // Pointer references source position r, which is at - // compressed position source_to_c_pos[r] - config[idx] = k + 1 + source_to_c_pos[r]; + // Second pass: build config using source_to_c_pos for pointer references + let mut config = vec![eos; n]; + for (idx, &(src_start, _len, ref_pos)) in segments.iter().enumerate() { + match ref_pos { + None => { + config[idx] = self.source_string[src_start]; + } + Some(r) => { + // Pointer references source position r, which is at + // compressed position source_to_c_pos[r] + config[idx] = k + 1 + source_to_c_pos[r]; + } } } - } - config + config + }) } } #[reduction( - overhead = { - num_vars = "string_len + string_len ^ 3", + size = exact { + num_constraints = "string_len + 1", + }, + unavailable = { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for MinimumInternalMacroDataCompression { @@ -287,7 +297,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimummatrixcover_ilp.rs b/src/rules/minimummatrixcover_ilp.rs index ecbfe96e5..fe90c4050 100644 --- a/src/rules/minimummatrixcover_ilp.rs +++ b/src/rules/minimummatrixcover_ilp.rs @@ -27,9 +27,16 @@ impl ReductionResult for ReductionMinimumMatrixCoverToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First n variables are the sign variables x_0,...,x_{n-1} - target_solution[..self.n].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // First n variables are the sign variables x_0,...,x_{n-1} + target_solution[..self.n].to_vec() + }) } } @@ -42,10 +49,10 @@ fn y_index(n: usize, i: usize, j: usize) -> usize { } #[reduction( - overhead = { + size = exact { num_vars = "num_rows + num_rows * (num_rows - 1) / 2", num_constraints = "3 * num_rows * (num_rows - 1) / 2", - } + }, )] impl ReduceTo> for MinimumMatrixCover { type Result = ReductionMinimumMatrixCoverToILP; diff --git a/src/rules/minimummaximalmatching_ilp.rs b/src/rules/minimummaximalmatching_ilp.rs index bbb39f80c..d7846536d 100644 --- a/src/rules/minimummaximalmatching_ilp.rs +++ b/src/rules/minimummaximalmatching_ilp.rs @@ -38,16 +38,21 @@ impl ReductionResult for ReductionMMMToILP { /// /// Since the mapping is 1:1 (each edge maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges", num_constraints = "num_vertices + num_edges", - } + }, )] impl ReduceTo> for MinimumMaximalMatching { type Result = ReductionMMMToILP; diff --git a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs index 81bbbb893..ad9189a07 100644 --- a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -42,16 +42,23 @@ impl ReductionResult for ReductionMMMToAchromatic { /// size 2, i.e., a source edge. A source edge `(u, v)` belongs to the /// extracted matching iff `u` and `v` share a color, which we detect in a /// single pass over `source_edges`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_edges - .iter() - .map(|&(u, v)| usize::from(target_solution[u] == target_solution[v])) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.source_edges + .iter() + .map(|&(u, v)| usize::from(target_solution[u] == target_solution[v])) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", } diff --git a/src/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/rules/minimummaximalmatching_minimummatrixdomination.rs index 97c5d4d75..8dc8536cc 100644 --- a/src/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -93,107 +93,127 @@ impl ReductionResult for ReductionMMMToMatrixDomination { /// and a swap candidate, for a total of `O(|F|^3)` time. The result is a /// matching that is an EDS, i.e. an independent EDS, which is precisely a /// maximal matching. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let graph = self.source.graph(); - let edges = graph.edges(); - let num_source_edges = edges.len(); - let m = graph.left_size(); - let target_ones = self.target.ones(); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // Step 1: map selected target 1-entries back to source edge indices. - // The reduction places source edge `(l_i, r_j)` (in bipartite-local - // form) at matrix cell `(i, m + j)`, which equals the global edge - // `(i, m + j)` returned by `Graph::edges()`. Build the lookup from - // matrix cell -> source edge index so we are robust to any ordering - // discrepancy between `Graph::edges()` and row-major 1-entries. - let cell_to_source_edge: std::collections::HashMap<(usize, usize), usize> = edges - .iter() - .enumerate() - .map(|(idx, &(u, v))| { - // Source edge endpoints in bipartite global coords are - // (left_idx, m + right_idx); matrix cell is (row=left, col=m+right). - let (row, col) = if u < m { (u, v) } else { (v, u) }; - ((row, col), idx) - }) - .collect(); - let mut d: Vec = target_solution - .iter() - .zip(target_ones.iter()) - .filter_map(|(&sel, &cell)| { - if sel == 1 { - cell_to_source_edge.get(&cell).copied() - } else { - None - } - }) - .collect(); + Ok({ + let graph = self.source.graph(); + let edges = graph.edges(); + let num_source_edges = edges.len(); + let m = graph.left_size(); + let target_ones = self.target.ones(); - // Step 2: Yannakakis-Gavril EDS -> independent EDS (maximal matching). - // Loop invariants: `d` is an EDS of the source graph; each iteration - // strictly decreases either |d| or the number of (unordered) pairs of - // adjacent edges inside `d`. - loop { - // Find an adjacent pair (e1_idx, e2_idx) inside `d`, sharing vertex v. - let pair = find_adjacent_pair(&d, &edges); - let Some((e1_idx, e2_idx, _shared)) = pair else { - break; // `d` is a matching; we are done. - }; + // Step 1: map selected target 1-entries back to source edge indices. + // The reduction places source edge `(l_i, r_j)` (in bipartite-local + // form) at matrix cell `(i, m + j)`, which equals the global edge + // `(i, m + j)` returned by `Graph::edges()`. Build the lookup from + // matrix cell -> source edge index so we are robust to any ordering + // discrepancy between `Graph::edges()` and row-major 1-entries. + let cell_to_source_edge: std::collections::HashMap<(usize, usize), usize> = edges + .iter() + .enumerate() + .map(|(idx, &(u, v))| { + // Source edge endpoints in bipartite global coords are + // (left_idx, m + right_idx); matrix cell is (row=left, col=m+right). + let (row, col) = if u < m { (u, v) } else { (v, u) }; + ((row, col), idx) + }) + .collect(); + let mut d: Vec = target_solution + .iter() + .zip(target_ones.iter()) + .filter_map(|(&sel, &cell)| { + if sel == 1 { + Some(cell_to_source_edge.get(&cell).copied().ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "selected matrix cell {cell:?} has no source edge" + )) + })) + } else { + None + } + }) + .collect::>()?; - // Try dropping e1_idx or e2_idx if the remainder is still an EDS. - let mut without_e1 = d.clone(); - without_e1.swap_remove(d.iter().position(|&x| x == e1_idx).unwrap()); - if is_edge_dominating_set(&without_e1, &edges) { - d = without_e1; - continue; - } - let mut without_e2 = d.clone(); - without_e2.swap_remove(d.iter().position(|&x| x == e2_idx).unwrap()); - if is_edge_dominating_set(&without_e2, &edges) { - d = without_e2; - continue; - } + // Step 2: Yannakakis-Gavril EDS -> independent EDS (maximal matching). + // Loop invariants: `d` is an EDS of the source graph; each iteration + // strictly decreases either |d| or the number of (unordered) pairs of + // adjacent edges inside `d`. + loop { + // Find an adjacent pair (e1_idx, e2_idx) inside `d`, sharing vertex v. + let pair = find_adjacent_pair(&d, &edges); + let Some((e1_idx, e2_idx, _shared)) = pair else { + break; // `d` is a matching; we are done. + }; - // Neither drop works -> perform a swap on one of e1 or e2. - // Choose endpoint not shared with the other edge: for e1=(u, v), - // e2=(v, w), the "non-shared" endpoint of e1 is u. - let (e1_a, e1_b) = edges[e1_idx]; - let (e2_a, e2_b) = edges[e2_idx]; - let shared = if e1_a == e2_a || e1_a == e2_b { - e1_a - } else { - e1_b - }; - let u = if e1_a == shared { e1_b } else { e1_a }; - let w = if e2_a == shared { e2_b } else { e2_a }; + // Try dropping e1_idx or e2_idx if the remainder is still an EDS. + let mut without_e1 = d.clone(); + let e1_position = d.iter().position(|&x| x == e1_idx).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "edge-domination transformation lost its selected edge", + ) + })?; + without_e1.swap_remove(e1_position); + if is_edge_dominating_set(&without_e1, &edges) { + d = without_e1; + continue; + } + let mut without_e2 = d.clone(); + let e2_position = d.iter().position(|&x| x == e2_idx).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "edge-domination transformation lost its selected edge", + ) + })?; + without_e2.swap_remove(e2_position); + if is_edge_dominating_set(&without_e2, &edges) { + d = without_e2; + continue; + } - // Try to swap e1 := (u, x) where x ∉ V(d \ {e1}). The YG proof - // guarantees such x exists when neither drop succeeded. - if let Some(new_idx) = find_swap_edge(u, e1_idx, &d, &edges) { - replace_in(&mut d, e1_idx, new_idx); - continue; - } - // Symmetric swap on e2. - if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { - replace_in(&mut d, e2_idx, new_idx); - continue; - } + // Neither drop works -> perform a swap on one of e1 or e2. + // Choose endpoint not shared with the other edge: for e1=(u, v), + // e2=(v, w), the "non-shared" endpoint of e1 is u. + let (e1_a, e1_b) = edges[e1_idx]; + let (e2_a, e2_b) = edges[e2_idx]; + let shared = if e1_a == e2_a || e1_a == e2_b { + e1_a + } else { + e1_b + }; + let u = if e1_a == shared { e1_b } else { e1_a }; + let w = if e2_a == shared { e2_b } else { e2_a }; - // YG guarantees that for an EDS at least one of the four moves - // above succeeds. Reaching this point implies the input was not - // a valid EDS (i.e., not a feasible MMD witness on the constructed - // instance), which violates the reduction's precondition. - unreachable!( - "Yannakakis-Gavril EDS->IEDS transformation could not progress; \ - target witness must be a feasible (dominating) MMD configuration" - ); - } + // Try to swap e1 := (u, x) where x ∉ V(d \ {e1}). The YG proof + // guarantees such x exists when neither drop succeeded. + if let Some(new_idx) = find_swap_edge(u, e1_idx, &d, &edges) { + d[e1_position] = new_idx; + continue; + } + // Symmetric swap on e2. + if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { + d[e2_position] = new_idx; + continue; + } - // Step 3: encode the matching as a binary configuration over source edges. - let mut config = vec![0usize; num_source_edges]; - for &idx in &d { - config[idx] = 1; - } - config + // YG guarantees that for an EDS at least one of the four moves + // above succeeds. Reaching this point implies the input was not + // a valid EDS (i.e., not a feasible MMD witness on the constructed + // instance), which violates the reduction's precondition. + return Err(crate::rules::ExtractionError::invalid( + "target matrix entries do not encode an edge-dominating set", + )); + } + + // Step 3: encode the matching as a binary configuration over source edges. + let mut config = vec![0usize; num_source_edges]; + for &idx in &d { + config[idx] = 1; + } + config + }) } } @@ -272,18 +292,8 @@ fn find_swap_edge( None } -/// Replace `old_idx` with `new_idx` inside `d` in-place. Panics if `old_idx` -/// is not present. -fn replace_in(d: &mut [usize], old_idx: usize, new_idx: usize) { - let pos = d - .iter() - .position(|&x| x == old_idx) - .expect("old_idx must be present in d"); - d[pos] = new_idx; -} - #[reduction( - overhead = { + size = exact { num_rows = "num_vertices", num_cols = "num_vertices", num_ones = "num_edges", diff --git a/src/rules/minimummetricdimension_ilp.rs b/src/rules/minimummetricdimension_ilp.rs index 16516c72a..ec31c37d3 100644 --- a/src/rules/minimummetricdimension_ilp.rs +++ b/src/rules/minimummetricdimension_ilp.rs @@ -38,16 +38,21 @@ impl ReductionResult for ReductionMDToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices", num_constraints = "num_vertices * (num_vertices - 1) / 2", - } + }, )] impl ReduceTo> for MinimumMetricDimension { type Result = ReductionMDToILP; diff --git a/src/rules/minimummultiwaycut_ilp.rs b/src/rules/minimummultiwaycut_ilp.rs index 62f442eaf..572ec7b58 100644 --- a/src/rules/minimummultiwaycut_ilp.rs +++ b/src/rules/minimummultiwaycut_ilp.rs @@ -42,17 +42,24 @@ impl ReductionResult for ReductionMMCToILP { /// Extract solution from ILP back to MinimumMultiwayCut. /// /// For each edge e, source config[e] = target_solution[k*n + e] (the x_e variable). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let offset = self.k * self.n; - (0..self.m).map(|e| target_solution[offset + e]).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let offset = self.k * self.n; + (0..self.m).map(|e| target_solution[offset + e]).collect() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_terminals * num_vertices + num_edges", num_constraints = "num_vertices + 2 * num_terminals * num_edges + num_terminals * num_terminals", - } + }, )] impl ReduceTo> for MinimumMultiwayCut { type Result = ReductionMMCToILP; diff --git a/src/rules/minimummultiwaycut_qubo.rs b/src/rules/minimummultiwaycut_qubo.rs index 610ec7397..a078f1fa8 100644 --- a/src/rules/minimummultiwaycut_qubo.rs +++ b/src/rules/minimummultiwaycut_qubo.rs @@ -36,34 +36,38 @@ impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { /// Decode one-hot assignment: for each vertex find its terminal, then /// for each edge check if endpoints are in different terminals. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let k = self.num_terminals; - let n = self.num_vertices; - - // For each vertex, find which terminal position it is assigned to - let assignments: Vec = (0..n) - .map(|u| { - (0..k) - .find(|&t| target_solution[u * k + t] == 1) - .unwrap_or(0) - }) - .collect(); - - // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise - self.edges - .iter() - .map(|&(u, v)| { - if assignments[u] != assignments[v] { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let k = self.num_terminals; + let n = self.num_vertices; + + // For each vertex, find which terminal position it is assigned to + let assignments = + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, n, k, 0)?; + + // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise + self.edges + .iter() + .map(|&(u, v)| { + if assignments[u] != assignments[v] { + 1 + } else { + 0 + } + }) + .collect() + }) } } -#[reduction(overhead = { num_vars = "num_terminals * num_vertices" })] +#[reduction(size = exact { + num_vars = "num_terminals * num_vertices", +})] impl ReduceTo> for MinimumMultiwayCut { type Result = ReductionMinimumMultiwayCutToQUBO; diff --git a/src/rules/minimumsetcovering_ilp.rs b/src/rules/minimumsetcovering_ilp.rs index 7befcbaca..eaacf6311 100644 --- a/src/rules/minimumsetcovering_ilp.rs +++ b/src/rules/minimumsetcovering_ilp.rs @@ -33,16 +33,21 @@ impl ReductionResult for ReductionSCToILP { /// /// Since the mapping is 1:1 (each set maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_sets", num_constraints = "universe_size", - } + }, )] impl ReduceTo> for MinimumSetCovering { type Result = ReductionSCToILP; diff --git a/src/rules/minimumsummulticenter_ilp.rs b/src/rules/minimumsummulticenter_ilp.rs index 976f1a387..ce826fa91 100644 --- a/src/rules/minimumsummulticenter_ilp.rs +++ b/src/rules/minimumsummulticenter_ilp.rs @@ -41,8 +41,13 @@ impl ReductionResult for ReductionMSMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } @@ -110,9 +115,12 @@ fn weighted_distances_msmc( } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_vertices^2", - num_constraints = "num_vertices^2 + 2 * num_vertices + 1", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MinimumSumMulticenter { diff --git a/src/rules/minimumtardinesssequencing_ilp.rs b/src/rules/minimumtardinesssequencing_ilp.rs index f09bdc7f4..bef3d865f 100644 --- a/src/rules/minimumtardinesssequencing_ilp.rs +++ b/src/rules/minimumtardinesssequencing_ilp.rs @@ -26,10 +26,17 @@ impl ReductionResult for ReductionMTSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0)?; + permutation_to_lehmer(&schedule) + }) } } @@ -48,10 +55,17 @@ impl ReductionResult for ReductionMTSWeightedToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0)?; + permutation_to_lehmer(&schedule) + }) } } @@ -89,10 +103,11 @@ fn build_common_constraints( } // Unit-length variant -#[reduction(overhead = { - num_vars = "num_tasks * num_tasks + num_tasks", - num_constraints = "2 * num_tasks + num_precedences + num_tasks", -})] +#[reduction( + size = exact { + num_vars = "num_tasks * num_tasks + num_tasks", + num_constraints = "2 * num_tasks + num_precedences + num_tasks", + },)] impl ReduceTo> for MinimumTardinessSequencing { type Result = ReductionMTSToILP; @@ -125,10 +140,11 @@ impl ReduceTo> for MinimumTardinessSequencing { } // Arbitrary-length variant -#[reduction(overhead = { - num_vars = "num_tasks * num_tasks + num_tasks", - num_constraints = "2 * num_tasks + num_precedences + num_tasks * num_tasks", -})] +#[reduction( + size = exact { + num_vars = "num_tasks * num_tasks + num_tasks", + num_constraints = "2 * num_tasks + num_precedences + num_tasks * num_tasks", + },)] impl ReduceTo> for MinimumTardinessSequencing { type Result = ReductionMTSWeightedToILP; diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 64344c219..7f5853d5e 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -45,24 +45,30 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if let Some(witness) = &self.trivial_yes { - return witness.clone(); - } - let mut cover = vec![0; self.num_source_vertices]; - for (vertex, &selected) in target_solution - .iter() - .take(self.num_source_vertices) - .enumerate() - { - cover[vertex] = selected; - } - cover + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + if let Some(witness) = &self.trivial_yes { + return Ok(witness.clone()); + } + let mut cover = vec![0; self.num_source_vertices]; + for (vertex, &selected) in target_solution[..self.num_source_vertices] + .iter() + .enumerate() + { + cover[vertex] = selected; + } + cover + }) } } #[reduction( - overhead = { + size = exact { universe_size = "num_vertices", num_r_sets = "num_vertices", num_s_sets = "num_edges + 1", @@ -105,7 +111,8 @@ impl ReduceTo> for Decision= num_vertices as i32 { + if i128::from(raw_bound) >= i128::try_from(num_vertices).expect("usize always fits in i128") + { let target = ComparativeContainment::with_weights( 0, Vec::new(), diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs index 158fc65dc..500239dc9 100644 --- a/src/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -45,34 +45,45 @@ impl ReductionResult for ReductionVCToEC { /// We collect all vertices that appear as singleton operands (index < |V|) /// in the meaningful steps only (before all required subsets are covered). /// Padding steps beyond the coverage point are ignored. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - use crate::traits::Problem; - use crate::types::Min; - - let meaningful_steps = match self.target.evaluate(target_solution) { - Min(Some(n)) => n, - _ => return vec![0; self.num_vertices], - }; - let mut cover = vec![0usize; self.num_vertices]; - - for step in 0..meaningful_steps { - let left = target_solution[2 * step]; - let right = target_solution[2 * step + 1]; - - if left < self.num_vertices { - cover[left] = 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + use crate::traits::Problem; + use crate::types::Min; + + let meaningful_steps = match self.target.evaluate(target_solution) { + Min(Some(n)) => n, + _ => { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a valid ensemble computation", + )) + } + }; + let mut cover = vec![0usize; self.num_vertices]; + + for step in 0..meaningful_steps { + let left = target_solution[2 * step]; + let right = target_solution[2 * step + 1]; + + if left < self.num_vertices { + cover[left] = 1; + } + if right < self.num_vertices { + cover[right] = 1; + } } - if right < self.num_vertices { - cover[right] = 1; - } - } - cover + cover + }) } } #[reduction( - overhead = { + size = exact { universe_size = "num_vertices + 1", num_subsets = "num_edges", } diff --git a/src/rules/minimumvertexcover_longestcommonsubsequence.rs b/src/rules/minimumvertexcover_longestcommonsubsequence.rs index 0c99aa568..34903ca5d 100644 --- a/src/rules/minimumvertexcover_longestcommonsubsequence.rs +++ b/src/rules/minimumvertexcover_longestcommonsubsequence.rs @@ -21,20 +21,27 @@ impl ReductionResult for ReductionVCToLCS { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut cover = vec![1; self.num_vertices]; - for &symbol in target_solution { - if symbol >= self.num_vertices { - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut cover = vec![1; self.num_vertices]; + for &symbol in target_solution { + if symbol >= self.num_vertices { + break; + } + cover[symbol] = 0; } - cover[symbol] = 0; - } - cover + cover + }) } } #[reduction( - overhead = { + size = exact { alphabet_size = "num_vertices", num_strings = "num_edges + 1", max_length = "num_vertices", diff --git a/src/rules/minimumvertexcover_maximumindependentset.rs b/src/rules/minimumvertexcover_maximumindependentset.rs index 85a650286..91c334a35 100644 --- a/src/rules/minimumvertexcover_maximumindependentset.rs +++ b/src/rules/minimumvertexcover_maximumindependentset.rs @@ -27,13 +27,18 @@ where /// Solution extraction: complement the configuration. /// If v is in the independent set (1), it's NOT in the vertex cover (0). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.iter().map(|&x| 1 - x).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } @@ -68,13 +73,18 @@ where } /// Solution extraction: complement the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.iter().map(|&x| 1 - x).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", } diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs index b616f2b77..3d30928cd 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -31,13 +31,18 @@ impl ReductionResult for ReductionVCToFAS { /// Extract solution: internal arcs are at positions 0..n in the FAS config. /// If internal arc i is in the FAS (config[i] = 1), vertex i is in the cover. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_source_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_source_vertices].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vertices", num_arcs = "num_vertices + 2 * num_edges", } @@ -105,7 +110,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs index 7f984aa67..759e5c93c 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -26,13 +26,18 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_arcs = "2 * num_edges", } diff --git a/src/rules/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs index 0b426e715..7db098817 100644 --- a/src/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/rules/minimumvertexcover_minimumhittingset.rs @@ -26,13 +26,18 @@ impl ReductionResult for ReductionVCToHS { /// Solution extraction: variables correspond 1:1. /// Element i in the hitting set corresponds to vertex i in the vertex cover. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { universe_size = "num_vertices", num_sets = "num_edges", } diff --git a/src/rules/minimumvertexcover_minimummaximalmatching.rs b/src/rules/minimumvertexcover_minimummaximalmatching.rs index 3556e510d..16a87bda1 100644 --- a/src/rules/minimumvertexcover_minimummaximalmatching.rs +++ b/src/rules/minimumvertexcover_minimummaximalmatching.rs @@ -8,7 +8,8 @@ //! (for example, on `C5`, `mmm(G) = 2` but `mvc(G) = 3`). use crate::models::graph::{MinimumMaximalMatching, MinimumVertexCover}; -use crate::rules::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +use crate::rules::registry::ReductionSizeDeclarations; +use crate::rules::ReductionEntry; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{One, ProblemSize}; @@ -30,13 +31,28 @@ inventory::submit! { target_name: MinimumMaximalMatching::::NAME, source_variant_fn: as Problem>::variant, target_variant_fn: as Problem>::variant, - overhead_fn: || ReductionOverhead::identity(&["num_vertices", "num_edges"]), + size_declarations_fn: || ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![ + ("num_vertices", crate::expr::Expr::variable("num_vertices")), + ("num_edges", crate::expr::Expr::variable("num_edges")), + ], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::none(), - overhead_eval_fn: source_problem_size, - source_size_fn: source_problem_size, + turing: false, + source_size_measure_fn: source_problem_size, + target_size_measure_fn: |any| { + let target = any + .downcast_ref::>() + .expect("MinimumVertexCover -> MinimumMaximalMatching target type mismatch"); + ProblemSize::new(vec![ + ("num_vertices", target.num_vertices()), + ("num_edges", target.num_edges()), + ]) + }, } } diff --git a/src/rules/minimumvertexcover_minimumsetcovering.rs b/src/rules/minimumvertexcover_minimumsetcovering.rs index c15f5f8c0..58c2c4210 100644 --- a/src/rules/minimumvertexcover_minimumsetcovering.rs +++ b/src/rules/minimumvertexcover_minimumsetcovering.rs @@ -29,13 +29,18 @@ where /// Solution extraction: variables correspond 1:1. /// Vertex i in VC corresponds to set i in SC. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_sets = "num_vertices", universe_size = "num_edges", } diff --git a/src/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/rules/minimumvertexcover_minimumweightandorgraph.rs index feeefdf1a..fc4786eb6 100644 --- a/src/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -23,15 +23,22 @@ impl ReductionResult for ReductionVCToAndOrGraph { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_source_vertices) - .map(|j| usize::from(target_solution.get(self.sink_arc_start + j) == Some(&1))) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_source_vertices) + .map(|j| usize::from(target_solution[self.sink_arc_start + j] == 1)) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "1 + num_edges + 2 * num_vertices", num_arcs = "3 * num_edges + num_vertices", } diff --git a/src/rules/minimumweightdecoding_ilp.rs b/src/rules/minimumweightdecoding_ilp.rs index 2961fac19..76e619ffc 100644 --- a/src/rules/minimumweightdecoding_ilp.rs +++ b/src/rules/minimumweightdecoding_ilp.rs @@ -40,16 +40,21 @@ impl ReductionResult for ReductionMinimumWeightDecodingToILP { } /// Extract the source solution: first m variables are the binary x_j values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_cols].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_cols].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_cols + num_rows", num_constraints = "num_rows + num_cols", - } + }, )] impl ReduceTo> for MinimumWeightDecoding { type Result = ReductionMinimumWeightDecodingToILP; diff --git a/src/rules/minmaxmulticenter_ilp.rs b/src/rules/minmaxmulticenter_ilp.rs index 0e475e6a3..7553b066c 100644 --- a/src/rules/minmaxmulticenter_ilp.rs +++ b/src/rules/minmaxmulticenter_ilp.rs @@ -45,8 +45,13 @@ impl ReductionResult for ReductionMMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } @@ -114,10 +119,10 @@ fn weighted_distances_mmc( } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_vertices^2 + 1", num_constraints = "2 * num_vertices^2 + 3 * num_vertices + 2", - } + }, )] impl ReduceTo> for MinMaxMulticenter { type Result = ReductionMMCToILP; diff --git a/src/rules/mixedchinesepostman_ilp.rs b/src/rules/mixedchinesepostman_ilp.rs index ded42a6fb..ee34886a3 100644 --- a/src/rules/mixedchinesepostman_ilp.rs +++ b/src/rules/mixedchinesepostman_ilp.rs @@ -26,16 +26,26 @@ impl ReductionResult for ReductionMCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Return the orientation bits d_k in source edge order - target_solution[..self.num_undirected_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Return the orientation bits d_k in source edge order + target_solution[..self.num_undirected_edges].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + 4 * (num_arcs + 2 * num_edges) + 3 * num_vertices + 1", - num_constraints = "num_vertices + 2 * (num_arcs + 2 * num_edges) + 2 * (num_arcs + 2 * num_edges) + num_vertices + 1 + num_vertices + 4 * num_vertices + 2 * (num_arcs + 2 * num_edges) + 2 * num_vertices", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for MixedChinesePostman { diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e648997a4..a1620aa2e 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -1,12 +1,11 @@ //! Reduction rules between NP-hard problems. pub mod analysis; -pub mod cost; pub mod registry; -pub use cost::{ - CustomCost, Minimize, MinimizeOutputSize, MinimizeSteps, MinimizeStepsThenOverhead, PathCostFn, +pub use registry::{ + EdgeCapabilities, ReductionEntry, ReductionSizeContract, ReductionSizeDeclarations, + SizeContractError, UnavailableSizeField, }; -pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; pub(crate) mod bicliquecover_bmf; pub(crate) mod bmf_bicliquecover; @@ -42,7 +41,6 @@ pub(crate) mod hamiltonianpath_degreeconstrainedspanningtree; pub(crate) mod hamiltonianpath_isomorphicspanningtree; pub(crate) mod hamiltonianpathbetweentwovertices_longestpath; pub(crate) mod ilp_i32_ilp_bool; -#[cfg(feature = "ilp-solver")] pub(crate) mod integerknapsack_ilp; pub(crate) mod kclique_balancedcompletebipartitesubgraph; pub(crate) mod kclique_conjunctivebooleanquery; @@ -128,6 +126,7 @@ pub(crate) mod prizecollectingsteinerforest_steinertree; pub(crate) mod rootedtreearrangement_rootedtreestorageassignment; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; +pub(crate) mod sat_helpers; pub(crate) mod sat_ksat; pub(crate) mod sat_maximumindependentset; pub(crate) mod sat_minimumdominatingset; @@ -155,259 +154,141 @@ pub(crate) mod travelingsalesman_qubo; pub mod unitdiskmapping; -#[cfg(feature = "ilp-solver")] pub(crate) mod acyclicpartition_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod balancedcompletebipartitesubgraph_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod biconnectivityaugmentation_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod binpacking_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod bmf_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod bottlenecktravelingsalesman_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod boundedcomponentspanningforest_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod capacityassignment_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod circuit_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod closeststring_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod closestsubstring_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod clustering_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod coloring_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod consecutiveblockminimization_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod consecutiveonesmatrixaugmentation_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod consecutiveonessubmatrix_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod consistencyofdatabasefrequencytables_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod directedhamiltonianpath_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod directedtwocommodityintegralflow_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod disjointconnectingpaths_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod eulerianpath_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod exactcoverby3sets_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod expectedretrievalcost_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod factoring_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod feasibleregisterassignment_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod flowshopscheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod graphpartitioning_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod hamiltonianpath_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod highlyconnecteddeletion_ilp; -#[cfg(feature = "ilp-solver")] mod ilp_bool_ilp_i32; -#[cfg(feature = "ilp-solver")] pub(crate) mod ilp_helpers; -#[cfg(feature = "ilp-solver")] pub(crate) mod ilp_qubo; -#[cfg(feature = "ilp-solver")] pub(crate) mod integralflowbundles_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod integralflowhomologousarcs_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod integralflowwithmultipliers_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod isomorphicspanningtree_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod kclique_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod knapsack_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod lengthboundeddisjointpaths_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod longestcircuit_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod longestcommonsubsequence_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod longestpath_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximalis_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximum2satisfiability_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumclique_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumcokplex_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumcommonedgesubgraph_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumcontactmapoverlap_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumdomaticnumber_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumedgeweightedkclique_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumleafspanningtree_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumlikelihoodranking_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximummatching_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod maximumsetpacking_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumcapacitatedspanningtree_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumcoveringbycliques_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumcutintoboundedsets_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumdominatingset_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumedgecostflow_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumexternalmacrodatacompression_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumfaultdetectiontestset_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumfeedbackarcset_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumfeedbackvertexset_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumgraphbandwidth_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumhittingset_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimuminternalmacrodatacompression_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimummatrixcover_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimummaximalmatching_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimummetricdimension_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimummultiwaycut_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumsetcovering_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumsummulticenter_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumtardinesssequencing_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minimumweightdecoding_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod minmaxmulticenter_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod mixedchinesepostman_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod monochromatictriangle_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod multiplecopyfileallocation_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod multiprocessorscheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod naesatisfiability_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod numericalmatchingwithtargetsums_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod openshopscheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod optimallineararrangement_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod optimumcommunicationspanningtree_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod paintshop_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod partiallyorderedknapsack_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod partitionintopathsoflength2_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod partitionintotriangles_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod pathconstrainednetworkflow_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod precedenceconstrainedscheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod preemptivescheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod quadraticassignment_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod qubo_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod rectilinearpicturecompression_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod registersufficiency_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod resourceconstrainedscheduling_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod rootedtreestorageassignment_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod ruralpostman_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod schedulingtominimizeweightedcompletiontime_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod schedulingwithindividualdeadlines_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizemaximumcumulativecost_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizetardytaskweight_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizeweightedcompletiontime_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingtominimizeweightedtardiness_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingwithdeadlinesandsetuptimes_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingwithinintervals_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sequencingwithreleasetimesanddeadlines_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod setsplitting_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod shortestcommonsupersequence_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod shortestweightconstrainedpath_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sparsematrixcompression_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod stackercrane_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod steinertree_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod steinertreeingraphs_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod stringtostringcorrection_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod strongconnectivityaugmentation_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod subgraphisomorphism_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod sumofsquarespartition_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod threedimensionalmatching_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod timetabledesign_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod travelingsalesman_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod undirectedflowlowerbounds_ilp; -#[cfg(feature = "ilp-solver")] pub(crate) mod undirectedtwocommodityintegralflow_ilp; +#[cfg(test)] +pub(crate) use graph::ReductionEdgeData; pub use graph::{ - AggregateReductionChain, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, - ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, + AggregateReductionChain, ExecutePathsError, ExecutedPath, NeighborInfo, NeighborTree, + PathSizeError, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, + ReductionStep, TraversalFlow, }; +pub(crate) use traits::{validate_target_solution, DynReductionResult}; pub use traits::{ - AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, + AggregateReductionResult, ExtractionError, ExtractionResult, ReduceTo, ReduceToAggregate, + ReductionAutoCast, ReductionResult, }; #[cfg(feature = "example-db")] @@ -444,7 +325,6 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec => < $($dst_param:ty),+ >, fields: [$($field:ident),+], + $(aggregate: $aggregate:ident,)? |$src:ident| $body:expr) => { #[$crate::reduction( - overhead = { - $crate::rules::registry::ReductionOverhead::identity( - &[$(stringify!($field)),+] - ) + size = exact { + $($field = $field),+ } + $(, aggregate = $aggregate)? )] impl $crate::rules::ReduceTo<$problem<$($dst_param),+>> for $problem<$($src_param),+> diff --git a/src/rules/monochromatictriangle_ilp.rs b/src/rules/monochromatictriangle_ilp.rs index f9c06851a..7afafce0b 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -24,16 +24,21 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges", num_constraints = "2 * num_triangles", - } + }, )] impl ReduceTo> for MonochromaticTriangle { type Result = ReductionMonochromaticTriangleToILP; diff --git a/src/rules/multiplecopyfileallocation_ilp.rs b/src/rules/multiplecopyfileallocation_ilp.rs index 1852fb2a6..c355e55a5 100644 --- a/src/rules/multiplecopyfileallocation_ilp.rs +++ b/src/rules/multiplecopyfileallocation_ilp.rs @@ -36,8 +36,13 @@ impl ReductionResult for ReductionMCFAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } @@ -61,10 +66,10 @@ fn bfs_distances(graph: &SimpleGraph, source: usize, n: usize) -> Vec { } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices + num_vertices^2", num_constraints = "num_vertices^2 + num_vertices", - } + }, )] impl ReduceTo> for MultipleCopyFileAllocation { type Result = ReductionMCFAToILP; diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index f96d7ff4d..6ee306208 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -33,23 +33,26 @@ impl ReductionResult for ReductionMSToILP { } /// Extract solution: for each task j, find the unique processor p where x_{j,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_processors = self.num_processors; - (0..self.num_tasks) - .map(|j| { - (0..num_processors) - .find(|&p| target_solution[j * num_processors + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_tasks, + self.num_processors, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks * num_processors", num_constraints = "num_tasks + num_processors", - } + }, )] impl ReduceTo> for MultiprocessorScheduling { type Result = ReductionMSToILP; diff --git a/src/rules/naesatisfiability_ilp.rs b/src/rules/naesatisfiability_ilp.rs index 382fa58f5..a30c2999f 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -26,16 +26,21 @@ impl ReductionResult for ReductionNAESATToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vars", num_constraints = "2 * num_clauses", - } + }, )] impl ReduceTo> for NAESatisfiability { type Result = ReductionNAESATToILP; diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 5bdda85d9..8f13b5c37 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -36,10 +36,17 @@ impl ReductionResult for ReductionNAESATToMaxCut { /// Variable x_i is assigned based on vertex 2*i: if it is in set 0 /// (config[2*i] == 0), set x_i = false (config value 0); if in set 1, /// set x_i = true (config value 1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| target_solution[2 * i]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.source_num_vars) + .map(|i| target_solution[2 * i]) + .collect() + }) } } @@ -57,7 +64,7 @@ fn literal_vertex(lit: i32) -> usize { } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vars", num_edges = "num_vars + num_literal_pairs", } diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index 43f363de1..f23449129 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -65,12 +65,19 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.layout - .variables - .iter() - .map(|variable| usize::from(target_solution[variable.t] == 0)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.layout + .variables + .iter() + .map(|variable| usize::from(target_solution[variable.t] == 0)) + .collect() + }) } } @@ -300,7 +307,7 @@ fn build_layout(problem: &NAESatisfiability) -> ReductionLayout { } #[reduction( - overhead = { + size = exact { num_vertices = "4 * num_vars + 16 * num_clauses", num_edges = "3 * num_vars + 21 * num_clauses", num_matchings = "2", diff --git a/src/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index f27732a6a..a9aa47cb4 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -25,14 +25,13 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - assert!( - target_solution.len() >= self.num_source_variables, - "SetSplitting solution has {} variables but source requires {}", - target_solution.len(), - self.num_source_variables, - ); - target_solution[..self.num_source_variables].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_source_variables].to_vec()) } } @@ -46,7 +45,7 @@ fn literal_element_index(lit: i32, num_vars: usize) -> usize { } #[reduction( - overhead = { + size = exact { universe_size = "2 * num_vars", num_subsets = "num_vars + num_clauses", } diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 3177b1f93..2a4cef6cf 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -26,32 +26,47 @@ impl ReductionResult for ReductionN3DMToNMTS { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut x_indices_by_pair_sum: BTreeMap> = BTreeMap::new(); - for (x_index, &y_index) in target_solution.iter().enumerate() { - let pair_sum = self.target.sizes_x()[x_index] - .checked_add(self.target.sizes_y()[y_index]) - .expect("NMTS witness must not overflow i64 pair sums"); - x_indices_by_pair_sum - .entry(pair_sum) - .or_default() - .push(x_index); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let mut x_perm = Vec::with_capacity(self.source_sizes_w.len()); - let mut y_perm = Vec::with_capacity(self.source_sizes_w.len()); - for &w_size in &self.source_sizes_w { - let target_sum = checked_target_sum_to_i64(self.source_bound, w_size); - let x_index = x_indices_by_pair_sum - .get_mut(&target_sum) - .and_then(Vec::pop) - .expect("satisfying NMTS witness must realize every target complement"); - x_perm.push(x_index); - y_perm.push(target_solution[x_index]); - } + Ok({ + let mut x_indices_by_pair_sum: BTreeMap> = BTreeMap::new(); + for (x_index, &y_index) in target_solution.iter().enumerate() { + let pair_sum = self.target.sizes_x()[x_index] + .checked_add(self.target.sizes_y()[y_index]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target pair sum overflows the target numeric domain", + ) + })?; + x_indices_by_pair_sum + .entry(pair_sum) + .or_default() + .push(x_index); + } + + let mut x_perm = Vec::with_capacity(self.source_sizes_w.len()); + let mut y_perm = Vec::with_capacity(self.source_sizes_w.len()); + for &w_size in &self.source_sizes_w { + let target_sum = checked_target_sum_to_i64(self.source_bound, w_size); + let x_index = x_indices_by_pair_sum + .get_mut(&target_sum) + .and_then(Vec::pop) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target matching does not realize required pair sum {target_sum}" + )) + })?; + x_perm.push(x_index); + y_perm.push(target_solution[x_index]); + } - x_perm.extend(y_perm); - x_perm + x_perm.extend(y_perm); + x_perm + }) } } @@ -70,9 +85,10 @@ fn checked_target_sum_to_i64(bound: u64, w_size: u64) -> i64 { ) } -#[reduction(overhead = { - num_pairs = "num_groups", -})] +#[reduction( + size = exact { + num_pairs = "num_groups", + })] impl ReduceTo for Numerical3DimensionalMatching { type Result = ReductionN3DMToNMTS; diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 17b7aeb03..91e58dc77 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -44,21 +44,31 @@ impl ReductionResult for ReductionNMTSToILP { } /// Extract solution: for each x_i find the y_j it is paired with. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut assignment = vec![0usize; self.m]; - for (var_idx, triple) in self.triples.iter().enumerate() { - if target_solution[var_idx] == 1 { - assignment[triple.i] = triple.j; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut assignment = vec![0usize; self.m]; + for (var_idx, triple) in self.triples.iter().enumerate() { + if target_solution[var_idx] == 1 { + assignment[triple.i] = triple.j; + } } - } - assignment + assignment + }) } } #[reduction( - overhead = { - num_vars = "num_pairs * num_pairs * num_pairs", + size = exact { + num_constraints = "3 * num_pairs", + }, + unavailable = { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for NumericalMatchingWithTargetSums { diff --git a/src/rules/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index 7487c1b95..ac64ef621 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -88,31 +88,39 @@ impl ReductionResult for ReductionOSSToILP { /// Extract per-machine job orderings from the ILP start times, then /// convert to the config format (direct permutation indices per machine). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_jobs; - let m = self.num_machines; - - // Read start times s_{j,i} for each (j, i) - let start = |j: usize, i: usize| -> usize { - let idx = self.num_order_vars + j * m + i; - target_solution.get(idx).copied().unwrap_or(0) - }; - - // For each machine, sort jobs by their start time on that machine - let mut config = Vec::with_capacity(n * m); - for i in 0..m { - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| (start(j, i), j)); - config.extend(jobs); - } - config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_jobs; + let m = self.num_machines; + + // Read start times s_{j,i} for each (j, i) + let start = |j: usize, i: usize| -> usize { + let idx = self.num_order_vars + j * m + i; + target_solution[idx] + }; + + // For each machine, sort jobs by their start time on that machine + let mut config = Vec::with_capacity(n * m); + for i in 0..m { + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| (start(j, i), j)); + config.extend(jobs); + } + config + }) } } -#[reduction(overhead = { - num_vars = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 1", - num_constraints = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + 1 + 2 * num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 2 * num_jobs * num_machines * (num_machines - 1) / 2 + num_jobs * num_machines", -})] +#[reduction( + size = exact { + num_vars = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 1", + num_constraints = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + 1 + 2 * num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 2 * num_jobs * num_machines * (num_machines - 1) / 2 + num_jobs * num_machines", + },)] impl ReduceTo> for OpenShopScheduling { type Result = ReductionOSSToILP; diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 62b4ef512..8ced782a6 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -45,36 +45,41 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match &self.construction { - // No edges: any arrangement has total length 0 <= k, so emit the - // identity arrangement f(v) = v over all source vertices. - ConstructionKind::EdgelessYes { num_vertices } => (0..*num_vertices).collect(), - // Genuine NO: there is no valid arrangement; return a sentinel - // (identity) so the source decision evaluates correctly (NO). - ConstructionKind::FixedNo { num_vertices } => (0..*num_vertices).collect(), - ConstructionKind::Incidence { num_vertices } => { - // The C1MA witness is a column permutation: `config[position] = col`. - // Columns correspond to vertices, so this places vertex `col` at - // `position`. The OLA arrangement is `f(vertex) = position`, i.e. - // the inverse permutation. - let n = *num_vertices; - if target_solution.len() != n { - return (0..n).collect(); - } - let mut arrangement = vec![0usize; n]; - let mut seen = vec![false; n]; - for (position, &vertex) in target_solution.iter().enumerate() { - if vertex >= n || seen[vertex] { - // Not a valid permutation; fall back to identity. - return (0..n).collect(); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + match &self.construction { + // No edges: any arrangement has total length 0 <= k, so emit the + // identity arrangement f(v) = v over all source vertices. + ConstructionKind::EdgelessYes { num_vertices } => (0..*num_vertices).collect(), + // Genuine NO: the identity arrangement is the mathematically defined + // source-side representative and evaluates to NO. + ConstructionKind::FixedNo { num_vertices } => (0..*num_vertices).collect(), + ConstructionKind::Incidence { num_vertices } => { + // The C1MA witness is a column permutation: `config[position] = col`. + // Columns correspond to vertices, so this places vertex `col` at + // `position`. The OLA arrangement is `f(vertex) = position`, i.e. + // the inverse permutation. + let n = *num_vertices; + let mut arrangement = vec![0usize; n]; + let mut seen = vec![false; n]; + for (position, &vertex) in target_solution.iter().enumerate() { + if seen[vertex] { + return Err(crate::rules::ExtractionError::invalid( + "target column order is not a permutation", + )); + } + seen[vertex] = true; + arrangement[vertex] = position; } - seen[vertex] = true; - arrangement[vertex] = position; + arrangement } - arrangement } - } + }) } } @@ -93,7 +98,7 @@ fn no_sentinel() -> ConsecutiveOnesMatrixAugmentation { } #[reduction( - overhead = { + size = exact { num_rows = "num_edges", num_cols = "num_vertices", bound = "k - num_edges", diff --git a/src/rules/optimallineararrangement_ilp.rs b/src/rules/optimallineararrangement_ilp.rs index b84b4f7d7..06cef7d1f 100644 --- a/src/rules/optimallineararrangement_ilp.rs +++ b/src/rules/optimallineararrangement_ilp.rs @@ -34,23 +34,26 @@ impl ReductionResult for ReductionOLAToILP { } /// Extract: for each vertex v, output its position p (the unique p with x_{v,p} = 1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_vertices, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2 + num_vertices + num_edges", num_constraints = "2 * num_vertices + num_vertices^2 + num_vertices + num_vertices + 3 * num_edges", - } + }, )] impl ReduceTo> for OptimalLinearArrangement { type Result = ReductionOLAToILP; diff --git a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index 1ff24c75c..46c88150b 100644 --- a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -32,26 +32,39 @@ impl ReductionResult for ReductionOLAToSequencingToMinimizeWeightedCompletionTim &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let schedule = crate::models::misc::decode_lehmer(target_solution, self.target.num_tasks()) - .expect("target solution must be a valid Lehmer code"); - let mut arrangement = vec![0usize; self.num_vertices]; - let mut next_position = 0usize; - - for task in schedule { - if task < self.num_vertices { - arrangement[task] = next_position; - next_position += 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let schedule = + crate::models::misc::decode_lehmer(target_solution, self.target.num_tasks()) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration is not a Lehmer code", + ) + })?; + let mut arrangement = vec![0usize; self.num_vertices]; + let mut next_position = 0usize; + + for task in schedule { + if task < self.num_vertices { + arrangement[task] = next_position; + next_position += 1; + } } - } - arrangement + arrangement + }) } } -#[reduction(overhead = { - num_tasks = "num_vertices + num_edges", -})] +#[reduction( + size = exact { + num_tasks = "num_vertices + num_edges", + })] impl ReduceTo for OptimalLinearArrangement { @@ -106,7 +119,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + 2 * num_edges * num_vertices * (num_vertices - 1) / 2", num_constraints = "1 + num_vertices * num_vertices * (num_vertices - 1) / 2 + 2 * num_edges * num_vertices * (num_vertices - 1) / 2", - } + }, )] impl ReduceTo> for OptimumCommunicationSpanningTree { type Result = ReductionOptimumCommunicationSpanningTreeToILP; diff --git a/src/rules/paintshop_ilp.rs b/src/rules/paintshop_ilp.rs index c43ea8dd3..f94fce6f3 100644 --- a/src/rules/paintshop_ilp.rs +++ b/src/rules/paintshop_ilp.rs @@ -24,15 +24,23 @@ impl ReductionResult for ReductionPaintShopToILP { } /// Extract first-occurrence color bits (x_i) from ILP solution. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_cars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_cars].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_cars + 2 * num_sequence", - num_constraints = "num_sequence + 2 * num_sequence", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for PaintShop { @@ -130,7 +138,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/paintshop_qubo.rs b/src/rules/paintshop_qubo.rs index fcd1dd294..14f636534 100644 --- a/src/rules/paintshop_qubo.rs +++ b/src/rules/paintshop_qubo.rs @@ -28,12 +28,19 @@ impl ReductionResult for ReductionPaintShopToQUBO { /// The QUBO solution maps directly back: car i's first occurrence gets /// color x_i, second gets 1 - x_i. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { num_vars = "num_cars" })] +#[reduction(size = exact { + num_vars = "num_cars", +})] impl ReduceTo> for PaintShop { type Result = ReductionPaintShopToQUBO; diff --git a/src/rules/partiallyorderedknapsack_ilp.rs b/src/rules/partiallyorderedknapsack_ilp.rs index a8a4d4161..d780e6cb9 100644 --- a/src/rules/partiallyorderedknapsack_ilp.rs +++ b/src/rules/partiallyorderedknapsack_ilp.rs @@ -21,16 +21,21 @@ impl ReductionResult for ReductionPOKToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_items", num_constraints = "num_precedences + 1", - } + }, )] impl ReduceTo> for PartiallyOrderedKnapsack { type Result = ReductionPOKToILP; diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 4314c678d..93ffc6ad9 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -30,15 +30,22 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // BinPacking may use any bin indices (0..n-1). Remap the two distinct - // bins used in a 2-bin packing to Partition's {0, 1} assignment. - // The first bin encountered maps to 0, the second to 1. - let first_bin = target_solution[0]; - target_solution - .iter() - .map(|&b| if b == first_bin { 0 } else { 1 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // BinPacking may use any bin indices (0..n-1). Remap the two distinct + // bins used in a 2-bin packing to Partition's {0, 1} assignment. + // The first bin encountered maps to 0, the second to 1. + let first_bin = target_solution[0]; + target_solution + .iter() + .map(|&b| if b == first_bin { 0 } else { 1 }) + .collect() + }) } } @@ -47,9 +54,10 @@ fn partition_size_to_i32(value: u64) -> i32 { .expect("Partition -> BinPacking requires all sizes and total_sum / 2 to fit in i32") } -#[reduction(overhead = { - num_items = "num_elements", -})] +#[reduction( + size = exact { + num_items = "num_elements", + })] impl ReduceTo> for Partition { type Result = ReductionPartitionToBinPacking; diff --git a/src/rules/partition_cosineproductintegration.rs b/src/rules/partition_cosineproductintegration.rs index bad735af9..71b43f347 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -28,14 +28,20 @@ impl ReductionResult for ReductionPartitionToCPI { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_coefficients = "num_elements", -})] +#[reduction( + size = exact { + num_coefficients = "num_elements", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToCPI; diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 74e5be98b..8ccd80958 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -15,8 +15,7 @@ use crate::topology::DirectedGraph; #[derive(Debug, Clone)] pub struct ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers, - source_n: usize, - item_arc_count: usize, + item_arc_count: Option, } impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { @@ -27,25 +26,30 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.item_arc_count == 0 { - return vec![0; self.source_n]; - } - - if target_solution.len() < self.item_arc_count { - return vec![0; self.source_n]; - } - - target_solution[..self.item_arc_count].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let item_arc_count = self.item_arc_count.ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "the fixed infeasible target instance has no extractable witness", + ) + })?; + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + target_solution[..item_arc_count].to_vec() + }) } } -#[reduction(overhead = { - num_vertices = "num_elements + 3", - num_arcs = "2 * num_elements + 1", - max_capacity = "total_sum", - requirement = "total_sum", -})] +#[reduction( + size = exact { + num_vertices = "num_elements + 3", + num_arcs = "2 * num_elements + 1", + max_capacity = "total_sum", + requirement = "total_sum", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToIntegralFlowWithMultipliers; @@ -57,8 +61,7 @@ impl ReduceTo for Partition { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); return ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers::new(graph, 0, 2, vec![1, 2, 1], vec![1, 1], 1), - source_n, - item_arc_count: 0, + item_arc_count: None, }; } @@ -97,8 +100,7 @@ impl ReduceTo for Partition { capacities, half_sum, ), - source_n, - item_arc_count: source_n, + item_arc_count: Some(source_n), } } } diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 51d548a36..3d65493eb 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -18,8 +18,13 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -28,9 +33,9 @@ fn partition_size_to_i64(value: u64) -> i64 { .expect("Partition -> Knapsack requires all sizes and total_sum / 2 to fit in i64") } -#[reduction(overhead = { - num_items = "num_elements", -})] +#[reduction( + size = exact { num_items = "num_elements" }, +)] impl ReduceTo for Partition { type Result = ReductionPartitionToKnapsack; diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index b47843dc9..72cab906b 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -32,14 +32,20 @@ impl ReductionResult for ReductionPartitionToMPS { /// Solution extraction: identity mapping. /// Partition config (0/1 for subset) maps directly to processor assignment (0/1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_tasks = "num_elements", -})] +#[reduction( + size = exact { + num_tasks = "num_elements", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToMPS; diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index 309bd441d..4d67c8f02 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -17,77 +17,100 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_elements = self.target.num_jobs().saturating_sub(1); - let mut source_config = vec![0; num_elements]; - let Some(orders) = self.target.decode_orders(target_solution) else { - return source_config; - }; - if num_elements == 0 { - return source_config; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let num_elements = self.target.num_jobs() - 1; + let mut source_config = vec![0; num_elements]; + let Some(orders) = self.target.decode_orders(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode valid machine orders", + )); + }; + if num_elements == 0 { + return Ok(source_config); + } - let special_job = num_elements; - let half_sum = self.target.processing_times()[special_job][0]; - - // Find the middle machine and compute start times - let makespan_orders = &orders; - let n = self.target.num_jobs(); - let m = self.target.num_machines(); - - // Simulate to get start times - let mut machine_avail = vec![0usize; m]; - let mut job_avail = vec![0usize; n]; - let mut start_times = vec![vec![0usize; m]; n]; - - // Schedule by processing the orders - let mut cursor = vec![0usize; m]; - let total_ops = n * m; - for _ in 0..total_ops { - let mut best: Option<(usize, usize, usize)> = None; // (start, machine, job) - for (mi, order) in makespan_orders.iter().enumerate() { - if cursor[mi] < order.len() { - let job = order[cursor[mi]]; - let start = machine_avail[mi].max(job_avail[job]); - if best.is_none_or(|(bs, _, _)| start < bs) { - best = Some((start, mi, job)); + let special_job = num_elements; + let half_sum = self.target.processing_times()[special_job][0]; + + // Find the middle machine and compute start times + let makespan_orders = &orders; + let n = self.target.num_jobs(); + let m = self.target.num_machines(); + + // Simulate to get start times + let mut machine_avail = vec![0usize; m]; + let mut job_avail = vec![0usize; n]; + let mut start_times = vec![vec![0usize; m]; n]; + + // Schedule by processing the orders + let mut cursor = vec![0usize; m]; + let total_ops = n * m; + for _ in 0..total_ops { + let mut best: Option<(usize, usize, usize)> = None; // (start, machine, job) + for (mi, order) in makespan_orders.iter().enumerate() { + if cursor[mi] < order.len() { + let job = order[cursor[mi]]; + let start = machine_avail[mi].max(job_avail[job]); + if best.is_none_or(|(bs, _, _)| start < bs) { + best = Some((start, mi, job)); + } } } + let (start, mi, job) = best.ok_or_else(|| { + crate::rules::ExtractionError::invalid("target schedule is incomplete") + })?; + start_times[job][mi] = start; + let end = start + .checked_add(self.target.processing_times()[job][mi]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule time overflows usize", + ) + })?; + machine_avail[mi] = end; + job_avail[job] = end; + cursor[mi] += 1; } - let (start, mi, job) = best.expect("schedule incomplete"); - start_times[job][mi] = start; - let end = start + self.target.processing_times()[job][mi]; - machine_avail[mi] = end; - job_avail[job] = end; - cursor[mi] += 1; - } - // Find the middle machine where the special job starts at half_sum - let middle_machine = (0..m) - .find(|&machine| start_times[special_job][machine] == half_sum) - .unwrap_or_else(|| { - let mut machines: Vec = (0..m).collect(); - machines.sort_by_key(|&machine| (start_times[special_job][machine], machine)); - machines[m / 2] - }); - let pivot = start_times[special_job][middle_machine]; - - for (job, slot) in source_config.iter_mut().enumerate() { - let completion = start_times[job][middle_machine] - + self.target.processing_times()[job][middle_machine]; - if completion <= pivot { - *slot = 1; + // Find the middle machine where the special job starts at half_sum + let middle_machine = (0..m) + .find(|&machine| start_times[special_job][machine] == half_sum) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule has no machine at the partition boundary", + ) + })?; + let pivot = start_times[special_job][middle_machine]; + + for (job, slot) in source_config.iter_mut().enumerate() { + let completion = start_times[job][middle_machine] + .checked_add(self.target.processing_times()[job][middle_machine]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule time overflows usize", + ) + })?; + if completion <= pivot { + *slot = 1; + } } - } - source_config + source_config + }) } } -#[reduction(overhead = { - num_jobs = "num_elements + 1", - num_machines = "3", -})] +#[reduction( + size = exact { + num_jobs = "num_elements + 1", + num_machines = "3", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToOpenShopScheduling; diff --git a/src/rules/partition_productionplanning.rs b/src/rules/partition_productionplanning.rs index c4ddcd3d3..cafb6f5bb 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -17,18 +17,23 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.target.num_periods() - 1] .iter() - .take(self.target.num_periods().saturating_sub(1)) .map(|&production| usize::from(production > 0)) - .collect() + .collect()) } } -#[reduction(overhead = { - num_periods = "num_elements + 1", -})] +#[reduction( + size = exact { + num_periods = "num_elements + 1", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToProductionPlanning; diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index f47be5bc8..362f37927 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -10,21 +10,6 @@ pub struct ReductionPartitionToSequencingToMinimizeTardyTaskWeight { target: SequencingToMinimizeTardyTaskWeight, } -impl ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - fn decode_schedule(&self, target_solution: &[usize]) -> Vec { - let n = self.target.num_tasks(); - assert_eq!( - target_solution.len(), - n, - "target solution length must equal target num_tasks" - ); - - // The target model uses direct permutation encoding (dims = [n; n]). - // Each position is a task index; the solver returns a valid permutation. - target_solution.to_vec() - } -} - impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { type Source = Partition; type Target = SequencingToMinimizeTardyTaskWeight; @@ -33,27 +18,47 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let schedule = self.decode_schedule(target_solution); - let mut source_config = vec![1; self.target.num_tasks()]; - let mut completion_time = 0u64; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - for task in schedule { - completion_time = completion_time - .checked_add(self.target.lengths()[task]) - .expect("completion time overflowed u64"); - if completion_time <= self.target.deadlines()[task] { - source_config[task] = 0; + Ok({ + let mut seen = vec![false; self.target.num_tasks()]; + for &task in target_solution { + if std::mem::replace(&mut seen[task], true) { + return Err(crate::rules::ExtractionError::invalid(format!( + "target schedule contains task {task} more than once" + ))); + } + } + + let mut source_config = vec![1; self.target.num_tasks()]; + let mut completion_time = 0u64; + + for &task in target_solution { + completion_time = completion_time + .checked_add(self.target.lengths()[task]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule completion time overflows u64", + ) + })?; + if completion_time <= self.target.deadlines()[task] { + source_config[task] = 0; + } } - } - source_config + source_config + }) } } -#[reduction(overhead = { - num_tasks = "num_elements", -})] +#[reduction( + size = exact { + num_tasks = "num_elements", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToSequencingToMinimizeTardyTaskWeight; diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index a092d46a0..aa9d91a1e 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -26,21 +26,27 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_n { - // Normal case: same elements, same binary vector. - target_solution.to_vec() - } else { - // Odd-sum case: target is trivially infeasible (0 elements). - // Return all-zero config for the source (which also won't satisfy it). - vec![0; self.source_n] + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + if target_solution.len() != self.source_n { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} subset-selection values, got {}", + self.source_n, + target_solution.len() + ))); } + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_elements = "num_elements", -})] +#[reduction( + size = exact { + num_elements = "num_elements", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToSubsetSum; diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index f8fded1f9..e061baa33 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -42,24 +42,24 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self.target } - /// Solution extraction: identity mapping in the normal case. - /// In the sentinel case (source has fewer than two elements) the target's - /// witness has a different length, so we return an all-zero source-sized - /// vector; `Partition::evaluate` then yields `Or(false)`, which is the - /// correct answer because a single positive element cannot be balanced. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_n { - target_solution.to_vec() - } else { - vec![0; self.source_n] - } + /// Solution extraction preserves the source elements. The sentinel target + /// appends elements, so only the prefix corresponding to actual source + /// elements is mapped back. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_n].to_vec()) } } -#[reduction(overhead = { - num_elements = "num_elements", - num_groups = "2", -})] +#[reduction( + size = exact { + num_elements = "num_elements", + num_groups = "2", + })] impl ReduceTo for Partition { type Result = ReductionPartitionToSumOfSquaresPartition; diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index d73b71533..0d2f64795 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -88,10 +88,6 @@ fn add_clique_edges(vertices: &[usize], edges: &mut Vec<(usize, usize)>) { } } -fn invalid_source_solution(num_source_vertices: usize, num_source_cliques: usize) -> Vec { - vec![num_source_cliques; num_source_vertices] -} - /// Result of reducing PartitionIntoCliques to MinimumCoveringByCliques. #[derive(Debug, Clone)] pub struct ReductionPartitionIntoCliquesToMinimumCoveringByCliques { @@ -108,63 +104,72 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.source_graph.num_vertices(); - let target_edges = self.target.graph().edges(); - if target_solution.len() != target_edges.len() { - return invalid_source_solution(n, self.source_num_cliques); - } - - let mut matching_labels = vec![None; n]; - for ((u, v), &label) in target_edges.iter().zip(target_solution.iter()) { - let matching_index = if *u < n && *v == n + *u { - Some(*u) - } else if *v < n && *u == n + *v { - Some(*v) - } else { - None - }; - - if let Some(i) = matching_index { - matching_labels[i] = Some(label); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.source_graph.num_vertices(); + let target_edges = self.target.graph().edges(); + let mut matching_labels = vec![None; n]; + for ((u, v), &label) in target_edges.iter().zip(target_solution.iter()) { + let matching_index = if *u < n && *v == n + *u { + Some(*u) + } else if *v < n && *u == n + *v { + Some(*v) + } else { + None + }; + + if let Some(i) = matching_index { + matching_labels[i] = Some(label); + } } - } - if matching_labels.iter().any(Option::is_none) { - return invalid_source_solution(n, self.source_num_cliques); - } - - let mut label_map = BTreeMap::new(); - let extracted = matching_labels - .into_iter() - .map(|label| { - let label = label.expect("checked above"); - let next = label_map.len(); - *label_map.entry(label).or_insert(next) - }) - .collect::>(); - - if label_map.len() > self.source_num_cliques { - return invalid_source_solution(n, self.source_num_cliques); - } + let mut label_map = BTreeMap::new(); + let extracted = matching_labels + .into_iter() + .map(|label| { + let label = label.ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target cover does not label every matching gadget edge", + ) + })?; + let next = label_map.len(); + Ok(*label_map.entry(label).or_insert(next)) + }) + .collect::>>()?; + + if label_map.len() > self.source_num_cliques { + return Err(crate::rules::ExtractionError::invalid(format!( + "target cover uses {} cliques, exceeding source bound {}", + label_map.len(), + self.source_num_cliques + ))); + } - let source_problem = - PartitionIntoCliques::new(self.source_graph.clone(), self.source_num_cliques); - if as crate::traits::Problem>::evaluate( - &source_problem, - &extracted, - ) - .0 - { - extracted - } else { - invalid_source_solution(n, self.source_num_cliques) - } + let source_problem = + PartitionIntoCliques::new(self.source_graph.clone(), self.source_num_cliques); + if as crate::traits::Problem>::evaluate( + &source_problem, + &extracted, + ) + .0 + { + extracted + } else { + return Err(crate::rules::ExtractionError::invalid( + "target cover maps to an invalid source clique partition", + )); + } + }) } } #[reduction( - overhead = { + size = exact { num_vertices = "2 * num_vertices + 4 * num_edges + 2", num_edges = "(num_vertices + 2 * num_edges)^2 + 2 * num_vertices + 10 * num_edges", } diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index df158820c..375e7623c 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -33,13 +33,18 @@ impl ReductionResult for ReductionPPL2ToBCSF { /// /// Both problems use the same vertex-to-group assignment encoding, /// so the solution mapping is identity. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices", num_edges = "num_edges", max_components = "num_vertices / 3", diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index 2e3c3ccc6..7ac4bb1ae 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -43,25 +43,25 @@ impl ReductionResult for ReductionPIPL2ToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_groups, + 0, + ) } } #[reduction( - overhead = { - num_vars = "num_vertices^2 + num_edges * num_vertices", - num_constraints = "num_vertices^2 + num_edges * num_vertices + num_vertices", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for PartitionIntoPathsOfLength2 { diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index 18d32c5ca..39f431d63 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -37,25 +37,25 @@ impl ReductionResult for ReductionPITToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_groups, + 0, + ) } } #[reduction( - overhead = { - num_vars = "num_vertices^2", - num_constraints = "num_vertices^2 * num_vertices", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for PartitionIntoTriangles { diff --git a/src/rules/pathconstrainednetworkflow_ilp.rs b/src/rules/pathconstrainednetworkflow_ilp.rs index ce761eb79..75397687e 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -22,16 +22,21 @@ impl ReductionResult for ReductionPCNFToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_paths", num_constraints = "num_arcs + 1", - } + }, )] impl ReduceTo> for PathConstrainedNetworkFlow { type Result = ReductionPCNFToILP; diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index d464cc2a6..bd7721e52 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -38,22 +38,28 @@ impl ReductionResult for ReductionPCSToILP { /// /// For each task j, find the time slot t where x_{j,t} = 1. /// Returns the time slot for each task (matching the `dims()` encoding of PCS). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_tasks, + self.deadline, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks * deadline", - num_constraints = "num_tasks + deadline + num_tasks^2", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for PrecedenceConstrainedScheduling { diff --git a/src/rules/preemptivescheduling_ilp.rs b/src/rules/preemptivescheduling_ilp.rs index 3c068ec71..bcf221634 100644 --- a/src/rules/preemptivescheduling_ilp.rs +++ b/src/rules/preemptivescheduling_ilp.rs @@ -51,17 +51,24 @@ impl ReductionResult for ReductionPSToILP { /// Extract schedule from ILP solution. /// /// Returns a binary config of length n * D_max: `config[t * D_max + u] = x_{t,u}`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let nd = self.num_tasks * self.d_max; - target_solution[..nd.min(target_solution.len())].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let nd = self.num_tasks * self.d_max; + target_solution[..nd].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks * d_max + 1", num_constraints = "num_tasks + d_max + num_precedences * d_max + 2 * num_tasks * d_max", - } + }, )] impl ReduceTo> for PreemptiveScheduling { type Result = ReductionPSToILP; diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index a298b86ac..89bf789f6 100644 --- a/src/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/rules/prizecollectingsteinerforest_steinertree.rs @@ -69,41 +69,48 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_source_vertices; - let m = self.num_source_edges; - let mut source_config = vec![0usize; n + m]; - - // Mark vertices included via their gadget include-edge `(v, t_v)`, - // and edges via the matching original edge. - for (target_idx, &selected) in target_solution.iter().enumerate() { - if selected != 1 { - continue; - } - if let Some(v) = self.target_to_include_vertex[target_idx] { - source_config[v] = 1; - } else if let Some(src_edge) = self.target_to_source_edge[target_idx] { - source_config[n + src_edge] = 1; - } - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // Any original edge selected in `T*` forces both endpoints into - // `V_F`. The PCSF model rejects configurations where a selected - // edge has an unselected endpoint, so we mark endpoints explicitly - // (this also covers prize-zero endpoints, which have no gadget). - let edges = self.target.graph().edges(); - for (target_idx, &(_, _)) in edges.iter().enumerate() { - if target_solution.get(target_idx).copied() != Some(1) { - continue; + Ok({ + let n = self.num_source_vertices; + let m = self.num_source_edges; + let mut source_config = vec![0usize; n + m]; + + // Mark vertices included via their gadget include-edge `(v, t_v)`, + // and edges via the matching original edge. + for (target_idx, &selected) in target_solution.iter().enumerate() { + if selected != 1 { + continue; + } + if let Some(v) = self.target_to_include_vertex[target_idx] { + source_config[v] = 1; + } else if let Some(src_edge) = self.target_to_source_edge[target_idx] { + source_config[n + src_edge] = 1; + } } - if let Some(src_edge) = self.target_to_source_edge[target_idx] { - let (u, v) = self.source_edge_pair(src_edge); - source_config[u] = 1; - source_config[v] = 1; + + // Any original edge selected in `T*` forces both endpoints into + // `V_F`. The PCSF model rejects configurations where a selected + // edge has an unselected endpoint, so we mark endpoints explicitly + // (this also covers prize-zero endpoints, which have no gadget). + let edges = self.target.graph().edges(); + for (target_idx, &(_, _)) in edges.iter().enumerate() { + if target_solution[target_idx] != 1 { + continue; + } + if let Some(src_edge) = self.target_to_source_edge[target_idx] { + let (u, v) = self.source_edge_pair(src_edge); + source_config[u] = 1; + source_config[v] = 1; + } } - } - source_config + source_config + }) } } @@ -116,7 +123,7 @@ impl ReductionPCSFToSteinerTree { } #[reduction( - overhead = { + size = exact { num_vertices = "num_vertices + num_vertices_with_prize + 1", num_edges = "num_edges + num_vertices + 2 * num_vertices_with_prize", num_terminals = "num_vertices_with_prize + 1", @@ -231,7 +238,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec SteinerTree example must have an optimal target tree"); - let source_config = reduction.extract_solution(&target_config); + let source_config = reduction.extract_solution(&target_config).unwrap(); crate::example_db::specs::assemble_rule_example( &source, target, diff --git a/src/rules/quadraticassignment_ilp.rs b/src/rules/quadraticassignment_ilp.rs index 62a3c9916..8da7639f2 100644 --- a/src/rules/quadraticassignment_ilp.rs +++ b/src/rules/quadraticassignment_ilp.rs @@ -34,22 +34,25 @@ impl ReductionResult for ReductionQAPToILP { } /// Extract: for each facility i, output the unique location p with x_{i,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let loc = self.num_locations; - (0..self.num_facilities) - .map(|i| { - (0..loc) - .find(|&p| target_solution[i * loc + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_facilities, + self.num_locations, + 0, + ) } } #[reduction( - overhead = { - num_vars = "num_facilities * num_locations + num_facilities^2 * num_locations^2", - num_constraints = "num_facilities + num_locations + 3 * num_facilities^2 * num_locations^2", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for QuadraticAssignment { diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 249df5886..5504c0ef4 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -33,15 +33,20 @@ impl ReductionResult for ReductionQUBOToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_original].to_vec()) } } #[reduction( - overhead = { - num_vars = "num_vars^2", - num_constraints = "num_vars^2", + size = unavailable { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for QUBO { diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index 063eb3264..2246ae644 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -21,15 +21,23 @@ impl ReductionResult for ReductionRPCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { - num_vars = "num_rows * num_cols", + size = exact { + num_constraints = "num_rows * num_cols + 1", + }, + unavailable = { + num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for RectilinearPictureCompression { diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 0a625ea0a..c2c38e095 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -26,15 +26,21 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_vertices].to_vec()) } } -#[reduction(overhead = { - num_vars = "3 * num_vertices^2 + num_vertices * (num_vertices - 1) / 2 + 2 * num_vertices", - num_constraints = "9 * num_vertices^2 + 3 * num_vertices * (num_vertices - 1) / 2 + 3 * num_vertices + 2 * num_arcs + num_sinks", -})] +#[reduction( + size = exact { + num_vars = "3 * num_vertices^2 + num_vertices * (num_vertices - 1) / 2 + 2 * num_vertices", + num_constraints = "9 * num_vertices^2 + 3 * num_vertices * (num_vertices - 1) / 2 + 3 * num_vertices + 2 * num_arcs + num_sinks", + },)] impl ReduceTo> for RegisterSufficiency { type Result = ReductionRegisterSufficiencyToILP; diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 8048022da..4affeb70b 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -2,84 +2,132 @@ use crate::expr::Expr; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; +use crate::size::{SizeRelation, SizeTransform, SizeTransformError}; use crate::types::ProblemSize; use std::any::Any; use std::collections::HashSet; -/// Overhead specification for a reduction. -#[derive(Clone, Debug, Default, serde::Serialize)] -pub struct ReductionOverhead { - /// Output size as expressions of input size variables. - /// Each entry is (output_field_name, expression). - pub output_size: Vec<(&'static str, Expr)>, +/// One target field whose size cannot be propagated through a reduction. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub struct UnavailableSizeField { + pub field: &'static str, + pub reason: &'static str, } -impl ReductionOverhead { - pub fn new(output_size: Vec<(&'static str, Expr)>) -> Self { - Self { output_size } - } +/// Raw symbolic declaration emitted by the reduction proc macro. +#[derive(Clone, Debug, Default)] +pub struct ReductionSizeDeclarations { + pub relation: Option, + pub fields: Vec<(&'static str, Expr)>, + pub unavailable: Vec, +} - /// Identity overhead: each output field equals the same-named input field. - /// Used by variant cast reductions where problem size doesn't change. - pub fn identity(fields: &[&'static str]) -> Self { - Self { - output_size: fields.iter().map(|&f| (f, Expr::Var(f))).collect(), - } - } +/// Validated size metadata for one reduction edge. +#[derive(Clone, Debug)] +pub struct ReductionSizeContract { + transform: Option, + unavailable: Vec, +} - /// Evaluate output size given input size. - /// - /// Uses `round()` for the f64 to usize conversion because expression values - /// are typically integers and any fractional results come from floating-point - /// arithmetic imprecision, not intentional fractions. - pub fn evaluate_output_size(&self, input: &ProblemSize) -> ProblemSize { - let fields: Vec<_> = self - .output_size +impl ReductionSizeContract { + pub fn new( + edge: impl Into>, + declarations: ReductionSizeDeclarations, + ) -> Result { + let edge = edge.into(); + let formula_names: HashSet<_> = declarations + .fields .iter() - .map(|(name, expr)| (*name, expr.eval(input).round() as usize)) + .map(|(field, _)| *field) .collect(); - ProblemSize::new(fields) + let mut unavailable_names = HashSet::new(); + for unavailable in &declarations.unavailable { + if unavailable.reason.trim().is_empty() { + return Err(SizeContractError::EmptyUnavailableReason { + edge, + field: unavailable.field.into(), + }); + } + if !unavailable_names.insert(unavailable.field) + || formula_names.contains(unavailable.field) + { + return Err(SizeContractError::DuplicateClassification { + edge, + field: unavailable.field.into(), + }); + } + } + let transform = match (declarations.relation, declarations.fields.is_empty()) { + (Some(relation), false) => { + Some(SizeTransform::new(edge, relation, declarations.fields)?) + } + (None, true) if !declarations.unavailable.is_empty() => None, + (None, true) => return Err(SizeContractError::EmptyContract { edge }), + (Some(_), true) => return Err(SizeContractError::EmptyTransform { edge }), + (None, false) => return Err(SizeContractError::MissingRelation { edge }), + }; + Ok(Self { + transform, + unavailable: declarations.unavailable, + }) } - /// Collect all input variable names referenced by the overhead expressions. - pub fn input_variable_names(&self) -> HashSet<&'static str> { - self.output_size - .iter() - .flat_map(|(_, expr)| expr.variables()) - .collect() + pub fn transform(&self) -> Option<&SizeTransform> { + self.transform.as_ref() } - /// Compose two overheads: substitute self's output into `next`'s input. - /// - /// Returns a new overhead whose expressions map from self's input variables - /// directly to `next`'s output variables. - pub fn compose(&self, next: &ReductionOverhead) -> ReductionOverhead { - use std::collections::HashMap; - - // Build substitution map: output field name → output expression - let mapping: HashMap<&str, &Expr> = self - .output_size - .iter() - .map(|(name, expr)| (*name, expr)) - .collect(); + pub fn unavailable(&self) -> &[UnavailableSizeField] { + &self.unavailable + } +} - let composed = next - .output_size - .iter() - .map(|(name, expr)| (*name, expr.substitute(&mapping))) - .collect(); +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SizeContractError { + Transform(SizeTransformError), + EmptyContract { edge: Box }, + EmptyTransform { edge: Box }, + MissingRelation { edge: Box }, + DuplicateClassification { edge: Box, field: Box }, + EmptyUnavailableReason { edge: Box, field: Box }, +} - ReductionOverhead { - output_size: composed, +impl std::fmt::Display for SizeContractError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transform(error) => write!(formatter, "invalid size transform: {error}"), + Self::EmptyContract { edge } => write!( + formatter, + "reduction `{edge}` has no size formulas or unavailable fields" + ), + Self::EmptyTransform { edge } => { + write!( + formatter, + "reduction `{edge}` declares an empty size transform" + ) + } + Self::MissingRelation { edge } => write!( + formatter, + "reduction `{edge}` declares size formulas without a relation" + ), + Self::DuplicateClassification { edge, field } => { + write!( + formatter, + "reduction `{edge}` classifies target field `{field}` more than once" + ) + } + Self::EmptyUnavailableReason { edge, field } => write!( + formatter, + "reduction `{edge}` marks target field `{field}` unavailable without a reason" + ), } } +} - /// Get the expression for a named output field. - pub fn get(&self, name: &str) -> Option<&Expr> { - self.output_size - .iter() - .find(|(n, _)| *n == name) - .map(|(_, e)| e) +impl std::error::Error for SizeContractError {} + +impl From for SizeContractError { + fn from(error: SizeTransformError) -> Self { + Self::Transform(error) } } @@ -101,53 +149,17 @@ pub struct EdgeCapabilities { } impl EdgeCapabilities { - pub const fn none() -> Self { + pub(crate) const fn from_executors( + reduce_fn: Option, + reduce_aggregate_fn: Option, + turing: bool, + ) -> Self { Self { - witness: false, - aggregate: false, - turing: false, + witness: reduce_fn.is_some(), + aggregate: reduce_aggregate_fn.is_some(), + turing, } } - - pub const fn witness_only() -> Self { - Self { - witness: true, - aggregate: false, - turing: false, - } - } - - pub const fn aggregate_only() -> Self { - Self { - witness: false, - aggregate: true, - turing: false, - } - } - - pub const fn both() -> Self { - Self { - witness: true, - aggregate: true, - turing: false, - } - } - - pub const fn turing() -> Self { - Self { - witness: false, - aggregate: false, - turing: true, - } - } -} - -/// Defaults to `witness_only()` — the conservative choice for edges registered -/// via `#[reduction]`, which are witness/config reductions. -impl Default for EdgeCapabilities { - fn default() -> Self { - Self::witness_only() - } } /// A registered reduction entry for static inventory registration. @@ -161,8 +173,8 @@ pub struct ReductionEntry { pub source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, /// Function to derive target variant attributes from `Problem::variant()`. pub target_variant_fn: fn() -> Vec<(&'static str, &'static str)>, - /// Function to create overhead information (lazy evaluation for static context). - pub overhead_fn: fn() -> ReductionOverhead, + /// The rule's single size relation, formulas, and unavailable target fields. + pub size_declarations_fn: fn() -> ReductionSizeDeclarations, /// Module path where the reduction is defined (from `module_path!()`). pub module_path: &'static str, /// Type-erased reduction executor. @@ -174,22 +186,18 @@ pub struct ReductionEntry { /// `ReduceToAggregate::reduce_to_aggregate()`, and returns the result as a /// boxed `DynAggregateReductionResult`. pub reduce_aggregate_fn: Option, - /// Capability metadata for runtime path filtering. - pub capabilities: EdgeCapabilities, - /// Compiled overhead evaluation function. - /// Takes a `&dyn Any` (must be `&SourceType`), calls getter methods directly, - /// and returns the computed target problem size. - pub overhead_eval_fn: fn(&dyn Any) -> ProblemSize, - /// Extract source problem size from a type-erased instance. - /// Takes a `&dyn Any` (must be `&SourceType`), calls getter methods, - /// and returns the source problem's size fields as a `ProblemSize`. - pub source_size_fn: fn(&dyn Any) -> ProblemSize, + /// Whether this is a Turing (multi-query) reduction. + pub turing: bool, + /// Measure the source fields referenced by this rule's size formulas. + pub source_size_measure_fn: fn(&dyn Any) -> ProblemSize, + /// Measure the target fields declared by this rule's size contract. + pub target_size_measure_fn: fn(&dyn Any) -> ProblemSize, } impl ReductionEntry { - /// Get the overhead by calling the function. - pub fn overhead(&self) -> ReductionOverhead { - (self.overhead_fn)() + pub fn size_contract(&self) -> Result { + let edge: Box = format!("{} -> {}", self.source_name, self.target_name).into(); + ReductionSizeContract::new(edge, (self.size_declarations_fn)()) } /// Get the source variant by calling the function. @@ -202,6 +210,11 @@ impl ReductionEntry { (self.target_variant_fn)() } + /// Return the modes backed by this entry's executors. + pub fn capabilities(&self) -> EdgeCapabilities { + EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing) + } + /// Check if this reduction involves only the base (unweighted) variants. pub fn is_base_reduction(&self) -> bool { let source = self.source_variant(); @@ -227,9 +240,9 @@ impl std::fmt::Debug for ReductionEntry { .field("target_name", &self.target_name) .field("source_variant", &self.source_variant()) .field("target_variant", &self.target_variant()) - .field("overhead", &self.overhead()) + .field("size_contract", &self.size_contract()) .field("module_path", &self.module_path) - .field("capabilities", &self.capabilities) + .field("capabilities", &self.capabilities()) .finish() } } diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index 61e2037bb..a44efbd8c 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -29,22 +29,26 @@ impl ReductionResult for ReductionRCSToILP { } /// Extract: for each task j, find the unique slot t with x_{j,t} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_tasks, + self.deadline, + 0, + ) } } -#[reduction(overhead = { - num_vars = "num_tasks * deadline", - num_constraints = "num_tasks + deadline + num_resources * deadline", -})] +#[reduction( + size = exact { + num_vars = "num_tasks * deadline", + num_constraints = "num_tasks + deadline + num_resources * deadline", + },)] impl ReduceTo> for ResourceConstrainedScheduling { type Result = ReductionRCSToILP; diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 91f4d4a19..728ac4d11 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -36,19 +36,26 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign /// The target config is a parent array defining a rooted tree on X = V. /// The source config is [parent_array | identity_mapping] since X = V /// means the mapping f is the identity. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - // target_solution is the parent array of the rooted tree on X = V - // Source config = [parent_array, identity_mapping] - let mut source_config = target_solution.to_vec(); - // Append identity mapping: f(v) = v for all v - source_config.extend(0..n); - source_config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_vertices; + // target_solution is the parent array of the rooted tree on X = V + // Source config = [parent_array, identity_mapping] + let mut source_config = target_solution.to_vec(); + // Append identity mapping: f(v) = v for all v + source_config.extend(0..n); + source_config + }) } } #[reduction( - overhead = { + size = exact { universe_size = "num_vertices", num_subsets = "num_edges", } diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index ee137d38a..5a3947e23 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -7,6 +7,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::RootedTreeStorageAssignment; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; // Index helpers @@ -71,22 +72,23 @@ impl ReductionResult for ReductionRTSAToILP { } /// Decode parent array from one-hot parent indicators p_{v,u}. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&u| target_solution[idx_p(n, v, u)] == 1) - .unwrap_or(v) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.n, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "universe_size * universe_size * universe_size + 2 * universe_size * universe_size + universe_size + num_subsets * (universe_size * universe_size + 2 * universe_size + 3)", - num_constraints = "universe_size * universe_size * universe_size + universe_size * universe_size + universe_size * universe_size + num_subsets * universe_size * universe_size", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for RootedTreeStorageAssignment { @@ -423,7 +425,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/ruralpostman_ilp.rs b/src/rules/ruralpostman_ilp.rs index cc5ba5758..cae4d0536 100644 --- a/src/rules/ruralpostman_ilp.rs +++ b/src/rules/ruralpostman_ilp.rs @@ -26,17 +26,24 @@ impl ReductionResult for ReductionRPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Output the traversal multiplicities t_e - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Output the traversal multiplicities t_e + target_solution[..self.num_edges].to_vec() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + num_vertices + num_edges + num_vertices + 2 * num_edges", num_constraints = "2 * num_edges + num_required_edges + num_vertices + 2 * num_edges + num_vertices + 2 * num_edges + num_vertices + num_edges + num_edges + num_vertices", - } + }, )] impl ReduceTo> for RuralPostman { type Result = ReductionRPToILP; diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index f0a2eb5a8..6eb85a671 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -26,18 +26,25 @@ impl ReductionResult for ReductionSATToCircuit { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_var_indices - .iter() - .map(|&idx| target_solution[idx]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.source_var_indices + .iter() + .map(|&idx| target_solution[idx]) + .collect() + }) } } #[reduction( - overhead = { - num_variables = "num_vars + num_clauses", - num_assignments = "num_vars + num_clauses", + size = unavailable { + num_variables = "the exact circuit variable count depends on used-variable and clause-expression incidence absent from the source size vector", + num_assignments = "the exact assignment count depends on the number of source variables unused by every clause", } )] impl ReduceTo for Satisfiability { diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 5426f57b2..71d789bec 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -240,40 +240,44 @@ impl ReductionResult for ReductionSATToColoring { /// /// For each variable, we check if its positive literal vertex has TRUE color (0). /// If so, the variable is assigned true (1); otherwise false (0). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First determine which color is TRUE, FALSE, and AUX - // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively - assert!( - target_solution.len() >= 3, - "Invalid solution: coloring must have at least 3 vertices" - ); - let true_color = target_solution[0]; - let false_color = target_solution[1]; - let aux_color = target_solution[2]; - - // Sanity checks - assert!( - true_color != false_color && true_color != aux_color, - "Invalid coloring solution: special vertices must have distinct colors" - ); - - let mut assignment = vec![0usize; self.num_source_variables]; - - for (i, &pos_vertex) in self.pos_vertices.iter().enumerate() { - let vertex_color = target_solution[pos_vertex]; - - // Sanity check: variable vertices should not have AUX color - assert!( - vertex_color != aux_color, - "Invalid coloring solution: variable vertex has auxiliary color" - ); - - // If positive literal has TRUE color, variable is true (1) - // Otherwise, variable is false (0) - assignment[i] = if vertex_color == true_color { 1 } else { 0 }; - } - - assignment + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // First determine which color is TRUE, FALSE, and AUX + // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively + let true_color = target_solution[0]; + let false_color = target_solution[1]; + let aux_color = target_solution[2]; + + if true_color == false_color || true_color == aux_color || false_color == aux_color { + return Err(crate::rules::ExtractionError::invalid( + "target coloring does not distinguish true, false, and auxiliary colors", + )); + } + + let mut assignment = vec![0usize; self.num_source_variables]; + + for (i, &pos_vertex) in self.pos_vertices.iter().enumerate() { + let vertex_color = target_solution[pos_vertex]; + + // Sanity check: variable vertices should not have AUX color + if vertex_color == aux_color { + return Err(crate::rules::ExtractionError::invalid(format!( + "variable {i} has the auxiliary color" + ))); + } + + // If positive literal has TRUE color, variable is true (1) + // Otherwise, variable is false (0) + assignment[i] = if vertex_color == true_color { 1 } else { 0 }; + } + + assignment + }) } } @@ -295,9 +299,9 @@ impl ReductionSATToColoring { } #[reduction( - overhead = { - num_vertices = "num_vars + num_literals", - num_edges = "num_vars + num_literals", + size = unavailable { + num_vertices = "the exact graph size depends on clause-length-specific coloring gadgets absent from the source size vector", + num_edges = "the exact graph size depends on clause-length-specific coloring gadgets absent from the source size vector", } )] impl ReduceTo> for Satisfiability { diff --git a/src/rules/sat_helpers.rs b/src/rules/sat_helpers.rs new file mode 100644 index 000000000..83d838735 --- /dev/null +++ b/src/rules/sat_helpers.rs @@ -0,0 +1,66 @@ +#[derive(Debug)] +pub(crate) struct SatVariableAllocator { + reduction: &'static str, + next: u64, +} + +impl SatVariableAllocator { + pub(crate) fn new(reduction: &'static str, existing: usize) -> Result { + if existing > i32::MAX as usize { + return Err(format!( + "{reduction} has {existing} source variables; SAT variable numbers are limited to {}", + i32::MAX + )); + } + Ok(Self { + reduction, + next: u64::try_from(existing).expect("usize SAT count fits u64") + 1, + }) + } + + pub(crate) fn allocate(&mut self) -> Result { + let variable = self.next; + if variable > i32::MAX as u64 { + return Err(format!( + "{} cannot allocate 1 auxiliary variable after {}; SAT variable numbers are limited to {}", + self.reduction, + self.num_vars(), + i32::MAX + )); + } + self.next += 1; + Ok(i32::try_from(variable).expect("checked SAT variable fits i32")) + } + + pub(crate) fn allocate_many(&mut self, count: usize) -> Result, String> { + if count == 0 { + return Ok(Vec::new()); + } + let count = u64::try_from(count).expect("usize allocation count fits u64"); + let last = self + .next + .checked_add(count - 1) + .ok_or_else(|| format!("{} auxiliary variable count overflow", self.reduction))?; + if last > i32::MAX as u64 { + return Err(format!( + "{} cannot allocate {count} auxiliary variables after {}; SAT variable numbers are limited to {}", + self.reduction, + self.num_vars(), + i32::MAX + )); + } + let variables = (self.next..=last) + .map(|variable| i32::try_from(variable).expect("checked SAT variable fits i32")) + .collect(); + self.next = last + 1; + Ok(variables) + } + + pub(crate) fn num_vars(&self) -> usize { + usize::try_from(self.next - 1).expect("SAT variable count fits usize") + } +} + +#[cfg(test)] +#[path = "../unit_tests/rules/sat_helpers.rs"] +mod tests; diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index 39be989d5..992ceb167 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -8,6 +8,7 @@ use crate::models::formula::{CNFClause, KSatisfiability, Satisfiability}; use crate::reduction; +use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::variant::{KValue, K2, K3, KN}; @@ -31,9 +32,16 @@ impl ReductionResult for ReductionSATToKSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Only return the original variables, discarding ancillas - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Only return the original variables, discarding ancillas + target_solution[..self.source_num_vars].to_vec() + }) } } @@ -48,16 +56,12 @@ impl ReductionResult for ReductionSATToKSAT { /// * `k` - Target number of literals per clause /// * `clause` - The clause to add /// * `result_clauses` - Output vector to append clauses to -/// * `next_var` - Next available variable number (1-indexed) -/// -/// # Returns -/// Updated next_var after any ancilla variables are created fn add_clause_to_ksat( k: usize, clause: &CNFClause, result_clauses: &mut Vec, - mut next_var: i32, -) -> i32 { + variables: &mut SatVariableAllocator, +) -> Result<(), String> { let len = clause.len(); if len == k { @@ -67,25 +71,23 @@ fn add_clause_to_ksat( // Too few literals: pad with ancilla variables // Create both positive and negative versions to maintain satisfiability // (a v b) with k=3 becomes (a v b v x) AND (a v b v -x) - let ancilla = next_var; - next_var += 1; + let ancilla = variables.allocate()?; // Add clause with positive ancilla let mut lits_pos = clause.literals.clone(); lits_pos.push(ancilla); - next_var = add_clause_to_ksat(k, &CNFClause::new(lits_pos), result_clauses, next_var); + add_clause_to_ksat(k, &CNFClause::new(lits_pos), result_clauses, variables)?; // Add clause with negative ancilla let mut lits_neg = clause.literals.clone(); lits_neg.push(-ancilla); - next_var = add_clause_to_ksat(k, &CNFClause::new(lits_neg), result_clauses, next_var); + add_clause_to_ksat(k, &CNFClause::new(lits_neg), result_clauses, variables)?; } else { // Too many literals: split using ancilla variable // (a v b v c v d) with k=3 becomes (a v b v x) AND (-x v c v d) assert!(k >= 3, "K must be at least 3 for splitting"); - let ancilla = next_var; - next_var += 1; + let ancilla = variables.allocate()?; // First clause: first k-1 literals + positive ancilla let mut first_lits: Vec = clause.literals[..k - 1].to_vec(); @@ -98,10 +100,10 @@ fn add_clause_to_ksat( let remaining_clause = CNFClause::new(remaining_lits); // Recursively process the remaining clause - next_var = add_clause_to_ksat(k, &remaining_clause, result_clauses, next_var); + add_clause_to_ksat(k, &remaining_clause, result_clauses, variables)?; } - next_var + Ok(()) } /// Implementation of SAT -> K-SAT reduction. @@ -111,26 +113,29 @@ fn add_clause_to_ksat( macro_rules! impl_sat_to_ksat { ($ktype:ty, $k:expr) => { #[rustfmt::skip] - #[reduction(overhead = { - num_clauses = "4 * num_clauses + num_literals", - num_vars = "num_vars + 3 * num_clauses + num_literals", - })] + #[reduction( + size = upper_bound { + num_clauses = "4 * num_clauses + num_literals", + num_vars = "num_vars + 3 * num_clauses + num_literals", + } + )] impl ReduceTo> for Satisfiability { type Result = ReductionSATToKSAT<$ktype>; fn reduce_to(&self) -> Self::Result { let source_num_vars = self.num_vars(); let mut result_clauses = Vec::new(); - let mut next_var = (source_num_vars + 1) as i32; // 1-indexed + let mut variables = SatVariableAllocator::new( + "Satisfiability -> KSatisfiability", + source_num_vars, + ).unwrap_or_else(|message| panic!("{message}")); for clause in self.clauses() { - next_var = add_clause_to_ksat($k, clause, &mut result_clauses, next_var); + add_clause_to_ksat($k, clause, &mut result_clauses, &mut variables) + .unwrap_or_else(|message| panic!("{message}")); } - // Calculate total number of variables (original + ancillas) - let total_vars = (next_var - 1) as usize; - - let target = KSatisfiability::<$ktype>::new(total_vars, result_clauses); + let target = KSatisfiability::<$ktype>::new(variables.num_vars(), result_clauses); ReductionSATToKSAT { source_num_vars, @@ -162,9 +167,16 @@ impl ReductionResult for ReductionKSATToSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Direct mapping - no transformation needed - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Direct mapping - no transformation needed + target_solution.to_vec() + }) } } @@ -184,11 +196,12 @@ fn reduce_ksat_to_sat(ksat: &KSatisfiability) -> ReductionKSATToSA macro_rules! impl_ksat_to_sat { ($ktype:ty) => { #[rustfmt::skip] - #[reduction(overhead = { - num_clauses = "num_clauses", - num_vars = "num_vars", - num_literals = "num_literals", - })] + #[reduction( + size = exact { + num_clauses = "num_clauses", + num_vars = "num_vars", + num_literals = "num_literals", + })] impl ReduceTo for KSatisfiability<$ktype> { type Result = ReductionKSATToSAT<$ktype>; diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index b49367747..602e8d673 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -76,23 +76,30 @@ impl ReductionResult for ReductionSATToIS { /// For each selected vertex (representing a literal), we set the corresponding /// variable to make that literal true. Variables not covered by any selected /// literal default to false. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut assignment = vec![0usize; self.num_source_variables]; - let mut covered = vec![false; self.num_source_variables]; - - for (vertex_idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let literal = &self.literals[vertex_idx]; - // If the literal is positive (neg=false), variable should be true (1) - // If the literal is negated (neg=true), variable should be false (0) - assignment[literal.name] = if literal.neg { 0 } else { 1 }; - covered[literal.name] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut assignment = vec![0usize; self.num_source_variables]; + let mut covered = vec![false; self.num_source_variables]; + + for (vertex_idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let literal = &self.literals[vertex_idx]; + // If the literal is positive (neg=false), variable should be true (1) + // If the literal is negated (neg=true), variable should be false (0) + assignment[literal.name] = if literal.neg { 0 } else { 1 }; + covered[literal.name] = true; + } } - } - // Variables not covered can be assigned any value (we use 0) - // They are already initialized to 0 - assignment + // Variables not covered can be assigned any value (we use 0) + // They are already initialized to 0 + assignment + }) } } @@ -109,7 +116,7 @@ impl ReductionSATToIS { } #[reduction( - overhead = { + size = upper_bound { num_vertices = "num_literals", num_edges = "num_literals^2", } diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index e5046ac42..ffc75229e 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -53,49 +53,36 @@ impl ReductionResult for ReductionSATToDS { /// - 3*i+1: negative literal NOT x_i (selecting means x_i = false) /// - 3*i+2: dummy vertex (selecting means x_i can be either) /// - /// If more than num_literals vertices are selected, the solution is invalid - /// and we return a default assignment. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let selected_count: usize = target_solution.iter().sum(); - - // If more vertices selected than variables, not a minimal dominating set - // corresponding to a satisfying assignment - if selected_count > self.num_literals { - // Return default assignment (all false) - return vec![0; self.num_literals]; + /// If more than num_literals vertices are selected, the target witness is invalid. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + let assignment = target_solution[..3 * self.num_literals] + .chunks_exact(3) + .enumerate() + .map(|(variable, gadget)| match gadget { + [1, 0, 0] => Ok(1), + [0, 1, 0] | [0, 0, 1] => Ok(0), + _ => Err(crate::rules::ExtractionError::invalid(format!( + "variable {variable} gadget must select exactly one vertex, got {}", + gadget.iter().sum::() + ))), + }) + .collect::>>()?; + + if let Some(clause) = target_solution[3 * self.num_literals..] + .iter() + .position(|&selected| selected == 1) + { + return Err(crate::rules::ExtractionError::invalid(format!( + "clause vertex {clause} is selected" + ))); } - let mut assignment = vec![0usize; self.num_literals]; - - for (i, &value) in target_solution.iter().enumerate() { - if value == 1 { - // Only consider variable gadget vertices (first 3*num_literals vertices) - if i >= 3 * self.num_literals { - continue; // Skip clause vertices - } - - let var_index = i / 3; - let vertex_type = i % 3; - - match vertex_type { - 0 => { - // Positive literal selected: x_i = true - assignment[var_index] = 1; - } - 1 => { - // Negative literal selected: x_i = false - assignment[var_index] = 0; - } - 2 => { - // Dummy vertex selected: variable is unconstrained - // Default to false (already 0), but could be anything - } - _ => unreachable!(), - } - } - } - - assignment + Ok(assignment) } } @@ -112,7 +99,7 @@ impl ReductionSATToDS { } #[reduction( - overhead = { + size = exact { num_vertices = "3 * num_vars + num_clauses", num_edges = "3 * num_vars + num_literals", } diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index e00849ec2..e0dada274 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -102,26 +102,26 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.variable_paths - .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.true_base_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + self.variable_paths + .iter() + .map(|paths| usize::from(target_solution[paths.true_base_arc] > 0)) + .collect() + }) } } -#[reduction(overhead = { - num_vertices = "2 * num_vars * num_clauses + 3 * num_vars + 2 * num_clauses + 2", - num_arcs = "2 * num_vars * num_clauses + 5 * num_vars + num_clauses + num_literals", -})] +#[reduction( + size = exact { + num_vertices = "2 * num_vars * num_clauses + 3 * num_vars + 2 * num_clauses + 2", + num_arcs = "2 * num_vars * num_clauses + 5 * num_vars + num_clauses + num_literals", + })] impl ReduceTo for Satisfiability { type Result = ReductionSATToIntegralFlowHomologousArcs; diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index c26d68458..947e8e0e2 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -2,6 +2,7 @@ use crate::models::formula::{CNFClause, Maximum2Satisfiability, Satisfiability}; use crate::reduction; +use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing SAT to MAX-2-SAT. @@ -19,24 +20,32 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vars].to_vec()) } } -fn add_normalized_clause(clause: &CNFClause, next_var: &mut i32, normalized: &mut Vec) { +fn add_normalized_clause( + clause: &CNFClause, + variables: &mut SatVariableAllocator, + normalized: &mut Vec, +) -> Result<(), String> { match clause.len() { 0 => { - let y = *next_var; - *next_var += 1; + let y = variables.allocate()?; normalized.push(CNFClause::new(vec![y, y, y])); normalized.push(CNFClause::new(vec![-y, -y, -y])); } 1 => { let l1 = clause.literals[0]; - let y = *next_var; - let z = *next_var + 1; - *next_var += 2; + let allocated = variables.allocate_many(2)?; + let y = allocated[0]; + let z = allocated[1]; normalized.push(CNFClause::new(vec![l1, y, z])); normalized.push(CNFClause::new(vec![l1, y, -z])); normalized.push(CNFClause::new(vec![l1, -y, z])); @@ -45,16 +54,14 @@ fn add_normalized_clause(clause: &CNFClause, next_var: &mut i32, normalized: &mu 2 => { let l1 = clause.literals[0]; let l2 = clause.literals[1]; - let y = *next_var; - *next_var += 1; + let y = variables.allocate()?; normalized.push(CNFClause::new(vec![l1, l2, y])); normalized.push(CNFClause::new(vec![l1, l2, -y])); } 3 => normalized.push(clause.clone()), k => { let literals = &clause.literals; - let y_vars: Vec = (*next_var..*next_var + (k as i32 - 3)).collect(); - *next_var += k as i32 - 3; + let y_vars = variables.allocate_many(k - 3)?; normalized.push(CNFClause::new(vec![literals[0], literals[1], y_vars[0]])); for i in 1..k - 3 { @@ -71,6 +78,7 @@ fn add_normalized_clause(clause: &CNFClause, next_var: &mut i32, normalized: &mu ])); } } + Ok(()) } fn add_gjs_gadget(clause: &CNFClause, w: i32, target_clauses: &mut Vec) { @@ -91,7 +99,7 @@ fn add_gjs_gadget(clause: &CNFClause, w: i32, target_clauses: &mut Vec for Satisfiability { fn reduce_to(&self) -> Self::Result { let mut normalized = Vec::new(); - let mut next_var = self.num_vars() as i32 + 1; + let mut variables = + SatVariableAllocator::new("Satisfiability -> Maximum2Satisfiability", self.num_vars()) + .unwrap_or_else(|message| panic!("{message}")); for clause in self.clauses() { - add_normalized_clause(clause, &mut next_var, &mut normalized); + add_normalized_clause(clause, &mut variables, &mut normalized) + .unwrap_or_else(|message| panic!("{message}")); } - let mut target_clauses = Vec::with_capacity(normalized.len() * 10); + let capacity = normalized + .len() + .checked_mul(10) + .expect("Satisfiability -> Maximum2Satisfiability clause count overflow"); + let mut target_clauses = Vec::with_capacity(capacity); for clause in &normalized { - let w = next_var; - next_var += 1; + let w = variables + .allocate() + .unwrap_or_else(|message| panic!("{message}")); add_gjs_gadget(clause, w, &mut target_clauses); } - let target = Maximum2Satisfiability::new((next_var - 1) as usize, target_clauses); + let target = Maximum2Satisfiability::new(variables.num_vars(), target_clauses); ReductionSatisfiabilityToMaximum2Satisfiability { target, diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index b17a292b0..f44cafe12 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -9,6 +9,7 @@ use crate::models::formula::{CNFClause, NAESatisfiability, Satisfiability}; use crate::reduction; +use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing Satisfiability to NAE-Satisfiability. @@ -28,35 +29,37 @@ impl ReductionResult for ReductionSATToNAESAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n = self.source_num_vars; - if target_solution.len() <= n { - return vec![0; n]; - } - // The sentinel variable is the last variable (index n). - let sentinel_value = target_solution[n]; - if sentinel_value == 0 { - // Sentinel is false: return first n variables as-is. - target_solution[..n].to_vec() - } else { - // Sentinel is true: return complement of first n variables. - target_solution[..n].iter().map(|&v| 1 - v).collect() - } + let sentinel = target_solution[n]; + Ok(target_solution[..n] + .iter() + .map(|&value| value ^ sentinel) + .collect()) } } -#[reduction(overhead = { - num_vars = "num_vars + 1", - num_clauses = "num_clauses", - num_literals = "num_literals + num_clauses", -})] +#[reduction( + size = exact { + num_vars = "num_vars + 1", + num_clauses = "num_clauses", + num_literals = "num_literals + num_clauses", + })] impl ReduceTo for Satisfiability { type Result = ReductionSATToNAESAT; fn reduce_to(&self) -> Self::Result { let n = self.num_vars(); - // Sentinel variable has 0-indexed position n, so its 1-indexed literal is n+1. - let sentinel_lit = (n + 1) as i32; + let mut variables = SatVariableAllocator::new("Satisfiability -> NAESatisfiability", n) + .unwrap_or_else(|message| panic!("{message}")); + let sentinel_lit = variables + .allocate() + .unwrap_or_else(|message| panic!("{message}")); let nae_clauses: Vec = self .clauses() @@ -74,7 +77,7 @@ impl ReduceTo for Satisfiability { }) .collect(); - let target = NAESatisfiability::new(n + 1, nae_clauses); + let target = NAESatisfiability::new(variables.num_vars(), nae_clauses); ReductionSATToNAESAT { source_num_vars: n, diff --git a/src/rules/satisfiability_nontautology.rs b/src/rules/satisfiability_nontautology.rs index be900a4a0..d8dcbfce7 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -21,15 +21,21 @@ impl ReductionResult for ReductionSATToNonTautology { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vars = "num_vars", - num_disjuncts = "num_clauses", -})] +#[reduction( + size = exact { + num_vars = "num_vars", + num_disjuncts = "num_clauses", + })] impl ReduceTo for Satisfiability { type Result = ReductionSATToNonTautology; diff --git a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index 72cc2f27a..ca985b464 100644 --- a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -8,6 +8,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SchedulingToMinimizeWeightedCompletionTime; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing SchedulingToMinimizeWeightedCompletionTime to ILP. @@ -51,22 +52,21 @@ impl ReductionResult for ReductionSMWCTToILP { } /// Extract solution: for each task, find the processor with x_{t,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_tasks) - .map(|t| { - (0..self.num_processors) - .find(|&p| target_solution[self.x_var(t, p)] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_tasks, self.num_processors, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks * num_processors + num_tasks + num_tasks * (num_tasks - 1) / 2", num_constraints = "num_tasks + num_tasks * num_processors + 2 * num_tasks + 2 * num_tasks * (num_tasks - 1) / 2 * num_processors + num_tasks * (num_tasks - 1) / 2", - } + }, )] impl ReduceTo> for SchedulingToMinimizeWeightedCompletionTime { type Result = ReductionSMWCTToILP; diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index 80f0745cd..ce9835188 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -14,6 +14,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SchedulingWithIndividualDeadlines; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing SchedulingWithIndividualDeadlines to ILP. @@ -38,23 +39,21 @@ impl ReductionResult for ReductionSWIDToILP { /// Extract schedule from ILP solution. /// /// For each task j, find the time slot t where x_{j,t} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d = self.max_deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_tasks, self.max_deadline, 0) } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks * max_deadline", num_constraints = "num_tasks + max_deadline + num_precedences", - } + }, )] impl ReduceTo> for SchedulingWithIndividualDeadlines { type Result = ReductionSWIDToILP; diff --git a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index dd5f4ab7d..424e78a2b 100644 --- a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -31,16 +31,27 @@ impl ReductionResult for ReductionSTMMCCToILP { } /// Extract: decode position assignment → permutation → Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0)?; + permutation_to_lehmer(&schedule) + }) } } -#[reduction(overhead = { - num_vars = "num_tasks * num_tasks + 1", - num_constraints = "2 * num_tasks + num_precedences + num_tasks + num_tasks * num_tasks", +#[reduction( + size = exact { + num_vars = "num_tasks * num_tasks + 1", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for SequencingToMinimizeMaximumCumulativeCost { type Result = ReductionSTMMCCToILP; diff --git a/src/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/rules/sequencingtominimizetardytaskweight_ilp.rs index 801b0369c..b2e248523 100644 --- a/src/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -25,19 +25,27 @@ impl ReductionResult for ReductionSTMTTWToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - // Decode the n*n block of x_{j,p} variables into a schedule permutation. - // The source uses direct permutation encoding (config = schedule directly), - // so return the schedule as-is (it is already a permutation of 0..n). - one_hot_decode(target_solution, n, n, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + // Decode the n*n block of x_{j,p} variables into a schedule permutation. + // The source uses direct permutation encoding (config = schedule directly), + // so return the schedule as-is (it is already a permutation of 0..n). + one_hot_decode(target_solution, n, n, 0)? + }) } } -#[reduction(overhead = { - num_vars = "num_tasks * num_tasks + num_tasks", - num_constraints = "2 * num_tasks + num_tasks * num_tasks", -})] +#[reduction( + size = exact { + num_vars = "num_tasks * num_tasks + num_tasks", + num_constraints = "2 * num_tasks + num_tasks * num_tasks", + },)] impl ReduceTo> for SequencingToMinimizeTardyTaskWeight { type Result = ReductionSTMTTWToILP; diff --git a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index ba157bffe..03651a0fc 100644 --- a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -51,17 +51,25 @@ impl ReductionResult for ReductionSTMWCTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut schedule: Vec = (0..self.num_tasks).collect(); - schedule.sort_by_key(|&task| (target_solution.get(task).copied().unwrap_or(0), task)); - Self::encode_schedule_as_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut schedule: Vec = (0..self.num_tasks).collect(); + schedule.sort_by_key(|&task| (target_solution[task], task)); + Self::encode_schedule_as_lehmer(&schedule) + }) } } -#[reduction(overhead = { - num_vars = "num_tasks + num_tasks * (num_tasks - 1) / 2", - num_constraints = "2 * num_tasks + 3 * num_tasks * (num_tasks - 1) / 2 + num_precedences", -})] +#[reduction( + size = exact { + num_vars = "num_tasks + num_tasks * (num_tasks - 1) / 2", + num_constraints = "2 * num_tasks + 3 * num_tasks * (num_tasks - 1) / 2 + num_precedences", + },)] impl ReduceTo> for SequencingToMinimizeWeightedCompletionTime { type Result = ReductionSTMWCTToILP; diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index aab00740d..df58c12f1 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -49,18 +49,29 @@ impl ReductionResult for ReductionSTMWTToILP { } /// Extract: sort jobs by completion time C_j, convert to Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let c_offset = self.num_order_vars; - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| (target_solution.get(c_offset + j).copied().unwrap_or(0), j)); - Self::encode_schedule_as_lehmer(&jobs) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + let c_offset = self.num_order_vars; + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| (target_solution[c_offset + j], j)); + Self::encode_schedule_as_lehmer(&jobs) + }) } } -#[reduction(overhead = { - num_vars = "num_tasks * (num_tasks - 1) / 2 + 2 * num_tasks", - num_constraints = "num_tasks * (num_tasks - 1) / 2 + num_tasks + num_tasks * (num_tasks - 1) + 2 * num_tasks + 1", +#[reduction( + size = exact { + num_vars = "num_tasks * (num_tasks - 1) / 2 + 2 * num_tasks", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for SequencingToMinimizeWeightedTardiness { type Result = ReductionSTMWTToILP; diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 711a5ea95..f6ef76785 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -36,16 +36,27 @@ impl ReductionResult for ReductionSWDSTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - // x_{j,p} occupies the first n*n variables: decode the permutation. - one_hot_decode(target_solution, n, n, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + // x_{j,p} occupies the first n*n variables: decode the permutation. + one_hot_decode(target_solution, n, n, 0)? + }) } } -#[reduction(overhead = { - num_vars = "num_tasks * num_tasks + (num_tasks - 1) + num_tasks * (num_tasks - 1)", - num_constraints = "2 * num_tasks + num_tasks^2 * (num_tasks - 1) + 3 * num_tasks * (num_tasks - 1) + num_tasks * num_tasks", +#[reduction( + size = exact { + num_vars = "num_tasks * num_tasks + (num_tasks - 1) + num_tasks * (num_tasks - 1)", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for SequencingWithDeadlinesAndSetUpTimes { type Result = ReductionSWDSTToILP; diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index 5d816ca44..ff9b72b3a 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -43,22 +43,38 @@ impl ReductionResult for ReductionSWIToILP { /// /// For each task j, find the offset k where x_{j,k} = 1. /// Returns config[j] = k (start time offset from release time). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + self.task_layout .iter() - .map(|&(base, count)| { - (0..count) - .find(|&k| target_solution.get(base + k).copied().unwrap_or(0) == 1) - .unwrap_or(0) + .enumerate() + .map(|(task, &(base, count))| { + let mut selected = (0..count).filter(|&offset| target_solution[base + offset] == 1); + match (selected.next(), selected.next()) { + (Some(offset), None) => Ok(offset), + (None, _) => Err(crate::rules::ExtractionError::invalid(format!( + "task {task} has no selected start time" + ))), + (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( + "task {task} has multiple selected start times" + ))), + } }) .collect() } } #[reduction( - overhead = { + size = exact { num_vars = "num_tasks^2", - num_constraints = "num_tasks^2 + num_tasks", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for SequencingWithinIntervals { @@ -139,7 +155,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index cbcbadce0..1551368f6 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -46,28 +46,34 @@ impl ReductionResult for ReductionSWRTDToILP { /// Extract: read each task's start time, sort tasks by start time, /// encode as Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let horizon = self.time_horizon; - // For each task, find the start time - let mut start_times: Vec<(usize, usize)> = (0..n) - .map(|j| { - let start = (0..horizon) - .find(|&t| target_solution.get(j * horizon + t).copied().unwrap_or(0) == 1) - .unwrap_or(0); - (j, start) - }) - .collect(); - // Sort by start time (break ties by task index) - start_times.sort_by_key(|&(j, t)| (t, j)); - let schedule: Vec = start_times.iter().map(|&(j, _)| j).collect(); - Self::encode_schedule_as_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.num_tasks; + let horizon = self.time_horizon; + // For each task, find the start time + let starts = + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, n, horizon, 0)?; + let mut start_times: Vec<_> = starts.into_iter().enumerate().collect(); + // Sort by start time (break ties by task index) + start_times.sort_by_key(|&(j, t)| (t, j)); + let schedule: Vec = start_times.iter().map(|&(j, _)| j).collect(); + Self::encode_schedule_as_lehmer(&schedule) + }) } } -#[reduction(overhead = { - num_vars = "num_tasks * time_horizon", - num_constraints = "num_tasks + time_horizon", +#[reduction( + size = exact { + num_vars = "num_tasks * time_horizon", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", })] impl ReduceTo> for SequencingWithReleaseTimesAndDeadlines { type Result = ReductionSWRTDToILP; diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index 499a03e6d..2cca26ae8 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -28,30 +28,22 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - assert!( - target_solution.len() > self.pole, - "Betweenness solution has {} positions but pole index is {}", - target_solution.len(), - self.pole - ); - assert!( - target_solution.len() >= self.source_universe_size, - "Betweenness solution has {} positions but source requires {} elements", - target_solution.len(), - self.source_universe_size - ); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; let pole_position = target_solution[self.pole]; - target_solution[..self.source_universe_size] + Ok(target_solution[..self.source_universe_size] .iter() .map(|&position| usize::from(position > pole_position)) - .collect() + .collect()) } } #[reduction( - overhead = { + size = exact { num_elements = "normalized_universe_size + 1 + normalized_num_size3_subsets", num_triples = "normalized_num_size2_subsets + 2 * normalized_num_size3_subsets", } diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index 2756b2898..87332cb5c 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -28,16 +28,21 @@ impl ReductionResult for ReductionSetSplittingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "universe_size", num_constraints = "2 * num_subsets", - } + }, )] impl ReduceTo> for SetSplitting { type Result = ReductionSetSplittingToILP; diff --git a/src/rules/shortestcommonsupersequence_ilp.rs b/src/rules/shortestcommonsupersequence_ilp.rs index 59256028f..114f4112f 100644 --- a/src/rules/shortestcommonsupersequence_ilp.rs +++ b/src/rules/shortestcommonsupersequence_ilp.rs @@ -27,23 +27,28 @@ impl ReductionResult for ReductionSCSToILP { /// At each position p, output the unique symbol a with x_{p,a} = 1. /// Uses alphabet_size + 1 symbols (last = padding). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let b = self.max_length; - let k = self.alphabet_size + 1; // includes padding symbol - (0..b) - .map(|p| { - (0..k) - .find(|&a| target_solution[p * k + a] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.max_length, + self.alphabet_size + 1, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "max_length * (alphabet_size + 1) + total_length * max_length", - num_constraints = "max_length + total_length + total_length * max_length + total_length + max_length", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for ShortestCommonSupersequence { @@ -154,7 +159,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/shortestweightconstrainedpath_ilp.rs b/src/rules/shortestweightconstrainedpath_ilp.rs index 9a7b92c44..7db2cf939 100644 --- a/src/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/rules/shortestweightconstrainedpath_ilp.rs @@ -40,30 +40,30 @@ impl ReductionResult for ReductionSWCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_edges) - .map(|edge_idx| { - usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution[Self::arc_var(edge_idx, 0)] > 0 + || target_solution[Self::arc_var(edge_idx, 1)] > 0, + ) + }) + .collect() + }) } } -#[reduction(overhead = { - num_vars = "2 * num_edges + num_vertices", - num_constraints = "5 * num_edges + 4 * num_vertices + 2", -})] +#[reduction( + size = exact { + num_vars = "2 * num_edges + num_vertices", + num_constraints = "5 * num_edges + 4 * num_vertices + 2", + },)] impl ReduceTo> for ShortestWeightConstrainedPath { type Result = ReductionSWCPToILP; diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index 3cf26a8c6..6e22b034a 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -22,22 +22,28 @@ impl ReductionResult for ReductionSMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // For each row r, output the unique zero-based shift g with x_{r,g} = 1 - (0..self.num_rows) - .map(|r| { - (0..self.bound_k) - .find(|&g| target_solution[r * self.bound_k + g] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_rows, + self.bound_k, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_rows * bound_k", - num_constraints = "num_rows + num_rows * num_rows * bound_k * bound_k", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for SparseMatrixCompression { @@ -123,7 +129,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/spinglass_maxcut.rs b/src/rules/spinglass_maxcut.rs index e3ed5a419..e3a661d50 100644 --- a/src/rules/spinglass_maxcut.rs +++ b/src/rules/spinglass_maxcut.rs @@ -36,13 +36,18 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_spins = "num_vertices", num_interactions = "num_edges", } @@ -112,27 +117,34 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match self.ancilla { - None => target_solution.to_vec(), - Some(anc) => { - // If ancilla is 1, flip all bits; then remove ancilla - let mut sol = target_solution.to_vec(); - if sol[anc] == 1 { - for x in sol.iter_mut() { - *x = 1 - *x; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + match self.ancilla { + None => target_solution.to_vec(), + Some(anc) => { + // If ancilla is 1, flip all bits; then remove ancilla + let mut sol = target_solution.to_vec(); + if sol[anc] == 1 { + for x in sol.iter_mut() { + *x = 1 - *x; + } } + sol.remove(anc); + sol } - sol.remove(anc); - sol } - } + }) } } #[reduction( - overhead = { - num_vertices = "num_spins", + size = upper_bound { + num_vertices = "num_spins + 1", num_edges = "num_interactions + num_spins", } )] diff --git a/src/rules/spinglass_qubo.rs b/src/rules/spinglass_qubo.rs index 41a670331..f0d548394 100644 --- a/src/rules/spinglass_qubo.rs +++ b/src/rules/spinglass_qubo.rs @@ -26,15 +26,21 @@ impl ReductionResult for ReductionQUBOToSG { } /// Solution maps directly (same binary encoding). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_spins = "num_vars", - } + num_interactions = "num_vars^2", + }, )] impl ReduceTo> for QUBO { type Result = ReductionQUBOToSG; @@ -101,13 +107,18 @@ impl ReductionResult for ReductionSGToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_spins", } )] diff --git a/src/rules/stackercrane_ilp.rs b/src/rules/stackercrane_ilp.rs index 5dbbd3ad0..adb6e8ef9 100644 --- a/src/rules/stackercrane_ilp.rs +++ b/src/rules/stackercrane_ilp.rs @@ -31,17 +31,24 @@ impl ReductionResult for ReductionSCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Decode the permutation: for each position p, find the arc a with x_{a,p} = 1 - one_hot_decode(target_solution, self.num_arcs, self.num_arcs, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Decode the permutation: for each position p, find the arc a with x_{a,p} = 1 + one_hot_decode(target_solution, self.num_arcs, self.num_arcs, 0)? + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_arcs * num_arcs + num_arcs * num_arcs * num_arcs", num_constraints = "num_arcs + num_arcs + 3 * num_arcs * num_arcs * num_arcs", - } + }, )] impl ReduceTo> for StackerCrane { type Result = ReductionSCToILP; diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index f9c77eb30..a1318346d 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -33,16 +33,21 @@ impl ReductionResult for ReductionSteinerTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", - } + }, )] impl ReduceTo> for SteinerTree { type Result = ReductionSteinerTreeToILP; diff --git a/src/rules/steinertreeingraphs_ilp.rs b/src/rules/steinertreeingraphs_ilp.rs index def751b4b..7b7ff209c 100644 --- a/src/rules/steinertreeingraphs_ilp.rs +++ b/src/rules/steinertreeingraphs_ilp.rs @@ -33,16 +33,21 @@ impl ReductionResult for ReductionSTIGToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", - } + }, )] impl ReduceTo> for SteinerTreeInGraphs { type Result = ReductionSTIGToILP; diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 13b33de2a..6c4b7071b 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -54,57 +54,66 @@ impl ReductionResult for ReductionSTSCToILP { } /// Extract operation sequence from ILP solution. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let k = self.bound; - let noop_code = 2 * n; - - if n == 0 { - return vec![noop_code; k]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.n; + let k = self.bound; + let noop_code = 2 * n; + + if n == 0 { + return Ok(vec![noop_code; k]); + } - let nm1 = n.saturating_sub(1); - let mut ops = Vec::with_capacity(k); + let nm1 = n.saturating_sub(1); + let mut ops = Vec::with_capacity(k); - for t in 1..=k { - // current length at step t-1 - let current_len = (0..n) - .filter(|&p| target_solution[idx_e(n, k, t - 1, p)] == 0) - .count(); - - if target_solution[idx_nu(n, k, t)] == 1 { - ops.push(noop_code); - } else { - let mut found = false; - for j in 0..n { - if target_solution[idx_d(n, k, t, j)] == 1 { - ops.push(j); - found = true; - break; - } + for t in 1..=k { + // current length at step t-1 + let current_len = (0..n) + .filter(|&p| target_solution[idx_e(n, k, t - 1, p)] == 0) + .count(); + + let mut selected = Vec::new(); + if target_solution[idx_nu(n, k, t)] == 1 { + selected.push(noop_code); } - if !found { - for j in 0..nm1 { - if target_solution[idx_s(n, k, t, j)] == 1 { - ops.push(current_len + j); - found = true; - break; - } + selected.extend((0..n).filter(|&j| target_solution[idx_d(n, k, t, j)] == 1)); + selected.extend( + (0..nm1) + .filter(|&j| target_solution[idx_s(n, k, t, j)] == 1) + .map(|j| current_len + j), + ); + match selected.as_slice() { + [operation] => ops.push(*operation), + [] => { + return Err(crate::rules::ExtractionError::invalid(format!( + "edit step {t} has no selected operation" + ))) } - if !found { - ops.push(noop_code); + _ => { + return Err(crate::rules::ExtractionError::invalid(format!( + "edit step {t} has multiple selected operations" + ))) } } } - } - ops + ops + }) } } #[reduction( - overhead = { + size = exact { num_vars = "(bound + 1) * source_length * source_length + (bound + 1) * source_length + 2 * bound * source_length", - num_constraints = "(bound + 1) * source_length * source_length", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for StringToStringCorrection { @@ -391,7 +400,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/strongconnectivityaugmentation_ilp.rs b/src/rules/strongconnectivityaugmentation_ilp.rs index 3e95e7b63..13b7ef948 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -23,16 +23,21 @@ impl ReductionResult for ReductionSCAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_candidates].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_candidates].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_potential_arcs + 2 * num_vertices * (num_arcs + num_potential_arcs)", num_constraints = "1 + 2 * num_vertices * num_potential_arcs + 2 * num_vertices * num_vertices", - } + }, )] impl ReduceTo> for StrongConnectivityAugmentation { type Result = ReductionSCAToILP; @@ -194,7 +199,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index 6868197ea..98d170c58 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -10,7 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::SubgraphIsomorphism; use crate::reduction; -use crate::rules::ilp_helpers::one_hot_assignment_constraints; +use crate::rules::ilp_helpers::{one_hot_assignment_constraints, one_hot_decode_rows}; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::Graph; @@ -34,22 +34,28 @@ impl ReductionResult for ReductionSubIsoToILP { } /// Extract: for each pattern vertex v, output the unique host vertex u with x_{v,u} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n_host = self.num_host_vertices; - (0..self.num_pattern_vertices) - .map(|v| { - (0..n_host) - .find(|&u| target_solution[v * n_host + u] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows( + target_solution, + self.num_pattern_vertices, + self.num_host_vertices, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_pattern_vertices * num_host_vertices", - num_constraints = "num_pattern_vertices + num_host_vertices + num_pattern_edges * num_host_vertices^2", + + }, + unavailable = { + num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", } )] impl ReduceTo> for SubgraphIsomorphism { diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index 2d8b9994a..2dff5a40f 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -21,8 +21,13 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -33,7 +38,7 @@ fn biguint_to_i32(value: &BigUint) -> i32 { } #[reduction( - overhead = { + size = exact { ambient_dimension = "num_elements + 1", num_basis_vectors = "num_elements", } diff --git a/src/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index dd3bef7d3..ba4b975e2 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -17,10 +17,17 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Union choice 0 = left = Atom(1) = exclude, choice 1 = right = Atom(s_i+1) = include. - // This maps directly to SubsetSum's 0/1 include/exclude encoding. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + // Union choice 0 = left = Atom(1) = exclude, choice 1 = right = Atom(s_i+1) = include. + // This maps directly to SubsetSum's 0/1 include/exclude encoding. + target_solution.to_vec() + }) } } @@ -49,9 +56,10 @@ fn build_expression(sizes: &[u64]) -> IntExpr { expr } -#[reduction(overhead = { - num_union_nodes = "num_elements", -})] +#[reduction( + size = exact { + num_union_nodes = "num_elements", + })] impl ReduceTo for SubsetSum { type Result = ReductionSubsetSumToIntegerExpressionMembership; diff --git a/src/rules/subsetsum_integerknapsack.rs b/src/rules/subsetsum_integerknapsack.rs index c79e2e976..a3f216d61 100644 --- a/src/rules/subsetsum_integerknapsack.rs +++ b/src/rules/subsetsum_integerknapsack.rs @@ -10,7 +10,8 @@ use crate::expr::Expr; use crate::models::misc::SubsetSum; use crate::models::set::IntegerKnapsack; -use crate::rules::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +use crate::rules::registry::ReductionSizeDeclarations; +use crate::rules::ReductionEntry; use crate::traits::Problem; use crate::types::ProblemSize; use num_bigint::BigUint; @@ -40,32 +41,34 @@ fn subset_sum_source_size(any: &dyn Any) -> ProblemSize { ]) } -fn subset_sum_to_integer_knapsack_overhead(any: &dyn Any) -> ProblemSize { - let source = any - .downcast_ref::() - .expect("SubsetSum -> IntegerKnapsack source type mismatch"); - ProblemSize::new(vec![ - ("num_items", source.num_elements()), - ("capacity", biguint_to_usize(source.target(), "target")), - ]) -} - inventory::submit! { ReductionEntry { source_name: SubsetSum::NAME, target_name: IntegerKnapsack::NAME, source_variant_fn: ::variant, target_variant_fn: ::variant, - overhead_fn: || ReductionOverhead::new(vec![ - ("num_items", Expr::Var("num_elements")), - ("capacity", Expr::Var("target")), - ]), + size_declarations_fn: || ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![ + ("num_items", Expr::variable("num_elements")), + ("capacity", Expr::variable("target")), + ], + unavailable: vec![], + }, module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::none(), - overhead_eval_fn: subset_sum_to_integer_knapsack_overhead, - source_size_fn: subset_sum_source_size, + turing: false, + source_size_measure_fn: subset_sum_source_size, + target_size_measure_fn: |any| { + let target = any + .downcast_ref::() + .expect("SubsetSum -> IntegerKnapsack target type mismatch"); + ProblemSize::new(vec![ + ("num_items", target.num_items()), + ("capacity", usize::try_from(target.capacity()).expect("capacity exceeds usize")), + ]) + }, } } diff --git a/src/rules/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 4bb333f87..021ae050c 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -30,26 +30,33 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let source_bits = &target_solution[..self.source_len]; - - match self.padding_relation { - PaddingRelation::None => source_bits.to_vec(), - PaddingRelation::SameSide => { - let padding_is_selected = target_solution[self.source_len] == 1; - source_bits - .iter() - .map(|&bit| if padding_is_selected { bit } else { 1 - bit }) - .collect() - } - PaddingRelation::OppositeSide => { - let padding_is_selected = target_solution[self.source_len] == 1; - source_bits - .iter() - .map(|&bit| if padding_is_selected { 1 - bit } else { bit }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let source_bits = &target_solution[..self.source_len]; + + match self.padding_relation { + PaddingRelation::None => source_bits.to_vec(), + PaddingRelation::SameSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { bit } else { 1 - bit }) + .collect() + } + PaddingRelation::OppositeSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { 1 - bit } else { bit }) + .collect() + } } - } + }) } } @@ -59,9 +66,10 @@ fn biguint_to_u64(value: &BigUint) -> u64 { .expect("SubsetSum -> Partition requires all sizes and padding to fit in u64") } -#[reduction(overhead = { - num_elements = "num_elements + 1", -})] +#[reduction( + size = exact { + num_elements = "num_elements + 1", + })] impl ReduceTo for SubsetSum { type Result = ReductionSubsetSumToPartition; diff --git a/src/rules/sumofsquarespartition_ilp.rs b/src/rules/sumofsquarespartition_ilp.rs index de6b8e02a..ce807d167 100644 --- a/src/rules/sumofsquarespartition_ilp.rs +++ b/src/rules/sumofsquarespartition_ilp.rs @@ -56,26 +56,26 @@ impl ReductionResult for ReductionSSPToILP { } /// Extract solution: for each element i, find the unique group g where x_{i,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_elements) - .map(|i| { - (0..num_groups) - .find(|&g| { - let idx = i * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_elements, + self.num_groups, + 0, + ) } } #[reduction( - overhead = { + size = exact { num_vars = "num_elements * num_groups + num_elements^2 * num_groups", num_constraints = "num_elements + 3 * num_elements^2 * num_groups", - } + }, )] impl ReduceTo> for SumOfSquaresPartition { type Result = ReductionSSPToILP; diff --git a/src/rules/test_helpers.rs b/src/rules/test_helpers.rs index 9fb71e316..484dc5d53 100644 --- a/src/rules/test_helpers.rs +++ b/src/rules/test_helpers.rs @@ -104,7 +104,7 @@ pub(crate) fn assert_optimization_round_trip_from_optimization_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -125,7 +125,7 @@ pub(crate) fn assert_optimization_round_trip_from_satisfaction_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "satisfying", context, ); @@ -145,7 +145,7 @@ pub(crate) fn assert_optimization_round_trip_chain( verify_optimization_round_trip( source, target_solutions, - |target_solution| chain.extract_solution(target_solution), + |target_solution| chain.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -166,7 +166,7 @@ pub(crate) fn assert_satisfaction_round_trip_from_optimization_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -187,13 +187,12 @@ pub(crate) fn assert_satisfaction_round_trip_from_satisfaction_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "satisfying", context, ); } -#[cfg(feature = "ilp-solver")] pub(crate) fn assert_bf_vs_ilp(source: &R::Source, reduction: &R) where R: ReductionResult, @@ -206,7 +205,7 @@ where let ilp_solution = ILPSolver::new() .solve_dyn(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), bf_value); } @@ -293,8 +292,13 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -310,8 +314,13 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -327,8 +336,13 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -344,8 +358,13 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index 0310343e5..17724a1a0 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -18,16 +18,21 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "num_triples", num_constraints = "3 * universe_size", - } + }, )] impl ReduceTo> for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToILP; diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index a4081f346..1cb6a1bcd 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -44,26 +44,24 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self.target } - /// Solution extraction: identity mapping in the main branch. The target - /// codeword `x ∈ {0,1}^m` is the source subset indicator over the same - /// triple index set. In the sentinel branch the target witness has length - /// `1` (always `[0]`); we return the all-zero source-sized vector, - /// which decodes to `S = ∅`. `ThreeDimensionalMatching::evaluate(∅)` - /// then yields `Or(true)` iff `q == 0` (the correct answer for both - /// sentinel sub-cases). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_num_triples { - target_solution.to_vec() - } else { - vec![0; self.source_num_triples] - } + /// The target codeword prefix is the source subset indicator over the same + /// triple index set. The sentinel target appends one synthetic column, so + /// an empty source maps back to the empty prefix. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_triples].to_vec()) } } -#[reduction(overhead = { - num_rows = "3 * universe_size", - num_cols = "num_triples", -})] +#[reduction( + size = exact { + num_rows = "3 * universe_size", + num_cols = "num_triples", + })] impl ReduceTo for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToMinimumWeightDecoding; diff --git a/src/rules/threedimensionalmatching_threematroidintersection.rs b/src/rules/threedimensionalmatching_threematroidintersection.rs index 2fcc9db0b..29cbc0227 100644 --- a/src/rules/threedimensionalmatching_threematroidintersection.rs +++ b/src/rules/threedimensionalmatching_threematroidintersection.rs @@ -20,16 +20,22 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreeMatroidIntersec /// Each target ground-set element is exactly one source triple, so the /// witness vector is preserved unchanged. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - ground_set_size = "num_triples", - num_groups = "3 * universe_size", - bound = "universe_size", -})] +#[reduction( + size = exact { + ground_set_size = "num_triples", + num_groups = "3 * universe_size", + bound = "universe_size", + })] impl ReduceTo for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToThreeMatroidIntersection; diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 4a43698b5..e407bb4e7 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -294,75 +294,82 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { /// Reverse the 4-Partition -> 3-Partition pairing gadget, then decode the /// surviving real ABCD groups back into selected source triples. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut groups = vec![Vec::new(); self.target.num_groups()]; - for (element_index, &group_index) in target_solution.iter().enumerate() { - groups[group_index].push(element_index); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let mut groups = vec![Vec::new(); self.target.num_groups()]; + for (element_index, &group_index) in target_solution.iter().enumerate() { + groups[group_index].push(element_index); + } - let mut pair_usage: HashMap<(usize, usize), PairUsage> = HashMap::new(); + let mut pair_usage: HashMap<(usize, usize), PairUsage> = HashMap::new(); - for members in groups.into_iter().filter(|members| !members.is_empty()) { - let mut regulars = Vec::new(); - let mut pairing = None; - let mut has_filler = false; + for members in groups.into_iter().filter(|members| !members.is_empty()) { + let mut regulars = Vec::new(); + let mut pairing = None; + let mut has_filler = false; - for element_index in members { - match self.classify_target_element(element_index) { - TargetElement::Regular { step2_index } => regulars.push(step2_index), - TargetElement::Pairing { pair_index, kind } => { - pairing = Some((pair_index, kind)) + for element_index in members { + match self.classify_target_element(element_index) { + TargetElement::Regular { step2_index } => regulars.push(step2_index), + TargetElement::Pairing { pair_index, kind } => { + pairing = Some((pair_index, kind)) + } + TargetElement::Filler => has_filler = true, } - TargetElement::Filler => has_filler = true, } - } - if has_filler || regulars.len() != 2 { - continue; - } + if has_filler || regulars.len() != 2 { + continue; + } - let Some((pair_index, kind)) = pairing else { - continue; - }; + let Some((pair_index, kind)) = pairing else { + continue; + }; - let pair_key = self.pair_keys[pair_index]; - let regular_pair = sorted_pair(regulars[0], regulars[1]); - let usage = pair_usage.entry(pair_key).or_default(); + let pair_key = self.pair_keys[pair_index]; + let regular_pair = sorted_pair(regulars[0], regulars[1]); + let usage = pair_usage.entry(pair_key).or_default(); - match kind { - PairingKind::U => { - if regular_pair == [pair_key.0, pair_key.1] { - usage.saw_u = true; + match kind { + PairingKind::U => { + if regular_pair == [pair_key.0, pair_key.1] { + usage.saw_u = true; + } + } + PairingKind::UPrime => { + usage.uprime_regulars = Some(regular_pair); } - } - PairingKind::UPrime => { - usage.uprime_regulars = Some(regular_pair); } } - } - let mut source_solution = vec![0; self.num_source_triples]; + let mut source_solution = vec![0; self.num_source_triples]; - for ((left, right), usage) in pair_usage { - let Some(other_two) = usage.uprime_regulars else { - continue; - }; - if !usage.saw_u { - continue; - } + for ((left, right), usage) in pair_usage { + let Some(other_two) = usage.uprime_regulars else { + continue; + }; + if !usage.saw_u { + continue; + } - let mut group = [left, right, other_two[0], other_two[1]]; - group.sort_unstable(); - if group.windows(2).any(|window| window[0] == window[1]) { - continue; - } + let mut group = [left, right, other_two[0], other_two[1]]; + group.sort_unstable(); + if group.windows(2).any(|window| window[0] == window[1]) { + continue; + } - if let Some(source_triple) = self.decode_real_group(group) { - source_solution[source_triple] = 1; + if let Some(source_triple) = self.decode_real_group(group) { + source_solution[source_triple] = 1; + } } - } - source_solution + source_solution + }) } } @@ -419,10 +426,11 @@ fn enumerate_pair_keys(num_regulars: usize) -> Vec<(usize, usize)> { pairs } -#[reduction(overhead = { - num_elements = "24 * num_triples * num_triples - 3 * num_triples", - num_groups = "8 * num_triples * num_triples - num_triples", -})] +#[reduction( + size = exact { + num_elements = "24 * num_triples * num_triples - 3 * num_triples", + num_groups = "8 * num_triples * num_triples - num_triples", + })] impl ReduceTo for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToThreePartition; diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 61895a45b..2654dfd11 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -38,14 +38,20 @@ impl ReductionResult for ReductionThreePartitionToRCS { /// Solution extraction: identity mapping. /// ThreePartition config (group index 0..m-1) maps directly to time slot assignment. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_tasks = "num_elements", -})] +#[reduction( + size = exact { + num_tasks = "num_elements", + })] impl ReduceTo for ThreePartition { type Result = ReductionThreePartitionToRCS; diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 976c6ee5d..8d118c491 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -48,36 +48,48 @@ impl ReductionResult for ReductionThreePartitionToSRTD { /// Decode the Lehmer code to a task permutation, simulate the schedule to /// find each task's start time, then assign each element task to its slot /// based on start_time / (B + 1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.target.num_tasks(); - // Decode Lehmer code to permutation - let schedule = crate::models::misc::decode_lehmer(target_solution, n) - .expect("target_solution must be a valid Lehmer code"); - - // Simulate the schedule to find start times - let mut current_time: u64 = 0; - let mut slot_assignment = vec![0usize; self.num_element_tasks]; - let slot_width = self.bound + 1; // B + 1 (slot width including the filler gap) - - for &task in &schedule { - let start = current_time.max(self.target.release_times()[task]); - let finish = start + self.target.lengths()[task]; - current_time = finish; - - // Only element tasks (indices 0..3m) contribute to the partition - if task < self.num_element_tasks { - let slot = (start / slot_width) as usize; - slot_assignment[task] = slot; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let n = self.target.num_tasks(); + // Decode Lehmer code to permutation + let schedule = + crate::models::misc::decode_lehmer(target_solution, n).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration is not a Lehmer code", + ) + })?; + + // Simulate the schedule to find start times + let mut current_time: u64 = 0; + let mut slot_assignment = vec![0usize; self.num_element_tasks]; + let slot_width = self.bound + 1; // B + 1 (slot width including the filler gap) + + for &task in &schedule { + let start = current_time.max(self.target.release_times()[task]); + let finish = start + self.target.lengths()[task]; + current_time = finish; + + // Only element tasks (indices 0..3m) contribute to the partition + if task < self.num_element_tasks { + let slot = (start / slot_width) as usize; + slot_assignment[task] = slot; + } } - } - slot_assignment + slot_assignment + }) } } -#[reduction(overhead = { - num_tasks = "num_elements + num_groups - 1", -})] +#[reduction( + size = exact { + num_tasks = "num_elements + num_groups - 1", + })] impl ReduceTo for ThreePartition { type Result = ReductionThreePartitionToSRTD; diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index f235918e5..068ce3b3a 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -28,15 +28,21 @@ impl ReductionResult for ReductionTDToILP { /// Extract: direct identity mapping — the ILP variable layout matches the /// source configuration layout exactly. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } -#[reduction(overhead = { - num_vars = "num_craftsmen * num_tasks * num_periods", - num_constraints = "num_craftsmen * num_periods + num_tasks * num_periods + num_craftsmen * num_tasks", -})] +#[reduction( + size = exact { + num_vars = "num_craftsmen * num_tasks * num_periods", + num_constraints = "num_craftsmen * num_periods + num_tasks * num_periods + num_craftsmen * num_tasks", + },)] impl ReduceTo> for TimetableDesign { type Result = ReductionTDToILP; diff --git a/src/rules/traits.rs b/src/rules/traits.rs index a46dc19ec..9465cc139 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -6,6 +6,66 @@ use serde::Serialize; use std::any::Any; use std::marker::PhantomData; +/// Failure to map a target witness back into the source configuration space. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ExtractionError { + #[error("{0}")] + InvalidTargetSolution(String), + #[error("{source_problem} -> {target_problem}: {message}")] + Reduction { + source_problem: &'static str, + target_problem: &'static str, + message: String, + }, +} + +impl ExtractionError { + pub fn invalid(message: impl Into) -> Self { + Self::InvalidTargetSolution(message.into()) + } + + fn for_reduction(self) -> Self { + match self { + Self::InvalidTargetSolution(message) => Self::Reduction { + source_problem: S::NAME, + target_problem: T::NAME, + message, + }, + error => error, + } + } +} + +pub type ExtractionResult = std::result::Result; + +/// Validate that a target configuration matches its declared discrete space. +pub(crate) fn validate_target_solution( + target: &P, + solution: &[usize], +) -> ExtractionResult<()> { + let dims = target.dims(); + if solution.len() != dims.len() { + return Err(ExtractionError::invalid(format!( + "expected {} target values, got {}", + dims.len(), + solution.len() + ))); + } + + if let Some((index, (&value, &dimension))) = solution + .iter() + .zip(&dims) + .enumerate() + .find(|(_, (value, dimension))| value >= dimension) + { + return Err(ExtractionError::invalid(format!( + "target value {value} at position {index} is outside dimension {dimension}" + ))); + } + + Ok(()) +} + /// Result of reducing a source problem to a target problem. /// /// This trait encapsulates the target problem and provides methods @@ -26,7 +86,7 @@ pub trait ReductionResult { /// /// # Returns /// The corresponding solution in the source problem space - fn extract_solution(&self, target_solution: &[usize]) -> Vec; + fn extract_solution(&self, target_solution: &[usize]) -> ExtractionResult>; } /// Trait for problems that can be reduced to target type T. @@ -124,8 +184,10 @@ impl ReductionResult for ReductionAutoCast { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution(&self, target_solution: &[usize]) -> ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution.to_vec()) } } @@ -152,7 +214,7 @@ pub trait DynReductionResult { /// Get the target problem as a type-erased reference. fn target_problem_any(&self) -> &dyn Any; /// Extract a solution from target space to source space. - fn extract_solution_dyn(&self, target_solution: &[usize]) -> Vec; + fn extract_solution_dyn(&self, target_solution: &[usize]) -> ExtractionResult>; } impl DynReductionResult for R @@ -162,8 +224,9 @@ where fn target_problem_any(&self) -> &dyn Any { self.target_problem() as &dyn Any } - fn extract_solution_dyn(&self, target_solution: &[usize]) -> Vec { + fn extract_solution_dyn(&self, target_solution: &[usize]) -> ExtractionResult> { self.extract_solution(target_solution) + .map_err(|error| error.for_reduction::()) } } diff --git a/src/rules/travelingsalesman_ilp.rs b/src/rules/travelingsalesman_ilp.rs index e84d9d1f9..36c85765d 100644 --- a/src/rules/travelingsalesman_ilp.rs +++ b/src/rules/travelingsalesman_ilp.rs @@ -8,6 +8,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::TravelingSalesman; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; @@ -21,13 +22,6 @@ pub struct ReductionTSPToILP { source_edges: Vec<(usize, usize)>, } -impl ReductionTSPToILP { - /// Variable index for x_{v,k}: vertex v at position k. - fn x_index(&self, v: usize, k: usize) -> usize { - v * self.num_vertices + k - } -} - impl ReductionResult for ReductionTSPToILP { type Source = TravelingSalesman; type Target = ILP; @@ -38,43 +32,44 @@ impl ReductionResult for ReductionTSPToILP { /// Extract solution: read tour permutation from x variables, /// then map to edge selection for the source problem. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // Read tour: for each position k, find vertex v with x_{v,k} = 1 - let mut tour = vec![0usize; n]; - for k in 0..n { - for v in 0..n { - if target_solution[self.x_index(v, k)] == 1 { - tour[k] = v; - break; - } - } - } + Ok({ + let n = self.num_vertices; - // Map tour to edge selection - let mut edge_selection = vec![0usize; self.source_edges.len()]; - for k in 0..n { - let u = tour[k]; - let v = tour[(k + 1) % n]; - // Find the edge index for (u, v) or (v, u) - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; - } + let tour = one_hot_decode(target_solution, n, n, 0)?; + + // Map tour to edge selection + let mut edge_selection = vec![0usize; self.source_edges.len()]; + for k in 0..n { + let u = tour[k]; + let v = tour[(k + 1) % n]; + let edge = self + .source_edges + .iter() + .position(|&(a, b)| (a == u && b == v) || (a == v && b == u)) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target tour uses absent source edge ({u}, {v})" + )) + })?; + edge_selection[edge] = 1; } - } - edge_selection + edge_selection + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2 + 2 * num_vertices * num_edges", num_constraints = "num_vertices^3 + -1 * num_vertices^2 + 2 * num_vertices + 4 * num_vertices * num_edges", - } + }, )] impl ReduceTo> for TravelingSalesman { type Result = ReductionTSPToILP; diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 89b0312ac..e3765c8d0 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -9,6 +9,7 @@ use crate::models::algebraic::QUBO; use crate::models::graph::TravelingSalesman; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; use std::collections::HashMap; @@ -34,37 +35,38 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { /// /// The QUBO solution uses n^2 binary variables x_{v,p} (vertex v at position p). /// We extract the tour order, then map consecutive pairs to edge indices. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - // For each position p, find the vertex v where x_{v,p} == 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; - } - } - } + Ok({ + let n = self.num_vertices; - // Build edge-based config: for each consecutive pair in the tour, mark the edge - let mut config = vec![0usize; self.num_edges]; - for p in 0..n { - let u = tour[p]; - let v = tour[(p + 1) % n]; - let key = (u.min(v), u.max(v)); - if let Some(&idx) = self.edge_index.get(&key) { - config[idx] = 1; + let tour = one_hot_decode(target_solution, n, n, 0)?; + + // Build edge-based config: for each consecutive pair in the tour, mark the edge + let mut config = vec![0usize; self.num_edges]; + for p in 0..n { + let u = tour[p]; + let v = tour[(p + 1) % n]; + let key = (u.min(v), u.max(v)); + let &edge = self.edge_index.get(&key).ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target tour uses absent source edge ({u}, {v})" + )) + })?; + config[edge] = 1; } - } - config + config + }) } } #[reduction( - overhead = { + size = exact { num_vars = "num_vertices^2", } )] diff --git a/src/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 7c666abca..e18253508 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -21,7 +21,7 @@ //! Flow conservation at non-terminal vertices. //! Net flow into sink ≥ requirement. //! -//! Overhead: 3*|E| variables, 4*|E| + |V| + 1 constraints (conservative for non-terminals). +//! Size upper bound: 3*|E| variables, 4*|E| + |V| + 1 constraints (conservative for non-terminals). use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::UndirectedFlowLowerBounds; @@ -54,20 +54,27 @@ impl ReductionResult for ReductionUFLBToILP { /// The model encodes orientation as config[e] = 0 for u→v, 1 for v→u. /// The ILP uses z_e = 1 for u→v, z_e = 0 for v→u. /// So we return 1 - z_e to match the model's convention. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let e = self.num_edges; - target_solution[2 * e..3 * e] - .iter() - .map(|&z| 1 - z) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok({ + let e = self.num_edges; + target_solution[2 * e..3 * e] + .iter() + .map(|&z| 1 - z) + .collect() + }) } } #[reduction( - overhead = { + size = exact { num_vars = "3 * num_edges", num_constraints = "4 * num_edges + num_vertices + 1", - } + }, )] impl ReduceTo> for UndirectedFlowLowerBounds { type Result = ReductionUFLBToILP; diff --git a/src/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/rules/undirectedtwocommodityintegralflow_ilp.rs index c5db4afa7..526062e8e 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -51,16 +51,21 @@ impl ReductionResult for ReductionU2CIFToILP { } /// Extract flow solution: first 4*|E| variables are the flow values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..4 * self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..4 * self.num_edges].to_vec()) } } #[reduction( - overhead = { + size = exact { num_vars = "6 * num_edges", num_constraints = "7 * num_edges + 2 * num_nonterminal_vertices + 2", - } + }, )] impl ReduceTo> for UndirectedTwoCommodityIntegralFlow { type Result = ReductionU2CIFToILP; @@ -234,7 +239,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/size.rs b/src/size.rs new file mode 100644 index 000000000..500e9968f --- /dev/null +++ b/src/size.rs @@ -0,0 +1,625 @@ +//! Symbolic size transformations carried by reduction rules. + +use crate::expr::{AlgebraicAnalysis, Expr, ExprNode, ExprNodeId, Symbol}; +use crate::growth::Growth; +use crate::types::ProblemSize; +use num_bigint::{BigInt, BigUint, Sign}; +use num_rational::BigRational; +use num_traits::{One, Signed, Zero}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// What one reduction rule promises about all of its declared size formulas. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SizeRelation { + Exact, + UpperBound, +} + +impl SizeRelation { + fn compose(self, next: Self) -> Self { + if self == Self::Exact && next == Self::Exact { + Self::Exact + } else { + Self::UpperBound + } + } +} + +/// Arbitrary-precision non-negative values for problem-size fields. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct SizeValues { + components: Vec<(Box, BigUint)>, +} + +impl SizeValues { + pub fn new(components: I) -> Self + where + I: IntoIterator, + N: Into>, + V: Into, + { + Self { + components: components + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + } + } + + pub fn from_problem_size(size: &ProblemSize) -> Self { + Self::new( + size.components + .iter() + .map(|(name, value)| (name.as_str(), BigUint::from(*value))), + ) + } + + pub fn get(&self, name: &str) -> Option<&BigUint> { + self.components + .iter() + .find(|(field, _)| field.as_ref() == name) + .map(|(_, value)| value) + } + + pub fn components(&self) -> impl Iterator { + self.components + .iter() + .map(|(name, value)| (name.as_ref(), value)) + } + + pub fn try_to_problem_size(&self) -> Result { + let mut values = Vec::with_capacity(self.components.len()); + for (name, value) in &self.components { + let value = + usize::try_from(value).map_err(|_| SizeTransformError::OutputOutOfRange { + field: name.clone(), + value: value.clone(), + })?; + values.push((name.as_ref(), value)); + } + Ok(ProblemSize::new(values)) + } +} + +/// Concrete size information whose relation is never erased during propagation. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct EvaluatedSize { + relation: SizeRelation, + values: SizeValues, +} + +/// Asymptotic projection of a size transform with its promise preserved. +#[derive(Clone, Debug, PartialEq)] +pub struct SizeGrowth { + relation: SizeRelation, + fields: Vec<(Box, Growth)>, +} + +impl SizeGrowth { + pub fn relation(&self) -> SizeRelation { + self.relation + } + + pub fn fields(&self) -> impl Iterator { + self.fields + .iter() + .map(|(name, growth)| (name.as_ref(), growth)) + } + + pub fn get(&self, field: &str) -> Option<&Growth> { + self.fields + .iter() + .find(|(name, _)| name.as_ref() == field) + .map(|(_, growth)| growth) + } +} + +impl EvaluatedSize { + pub fn exact(values: SizeValues) -> Self { + Self { + relation: SizeRelation::Exact, + values, + } + } + + pub fn from_problem_size(size: &ProblemSize) -> Self { + Self::exact(SizeValues::from_problem_size(size)) + } + + pub fn relation(&self) -> SizeRelation { + self.relation + } + + pub fn values(&self) -> &SizeValues { + &self.values + } +} + +/// One rule-level symbolic transformation. Its relation applies to every formula. +#[derive(Clone, Debug)] +pub struct SizeTransform { + edge: Box, + relation: SizeRelation, + fields: Vec, + analysis: AlgebraicAnalysis, +} + +#[derive(Clone, Debug)] +struct SizeField { + name: Box, + expression: Expr, + plan: Plan, + monotone: bool, +} + +#[derive(Clone, Debug)] +struct Plan(Arc); + +#[derive(Debug)] +enum PlanNode { + Const(BigRational), + Var(Symbol), + Add(Box<[Plan]>), + Mul(Box<[Plan]>), + Pow(Plan, BigInt), +} + +impl Plan { + fn identity(&self) -> usize { + Arc::as_ptr(&self.0) as usize + } +} + +impl SizeTransform { + pub fn new( + edge: impl Into>, + relation: SizeRelation, + fields: I, + ) -> Result + where + I: IntoIterator, + N: Into>, + { + let edge = edge.into(); + let mut names = HashSet::new(); + let mut raw_fields = Vec::new(); + for (name, expression) in fields { + let name = name.into(); + if let Err(error) = Symbol::new(name.clone()) { + return Err(SizeTransformError::InvalidTargetField { + edge, + field: name, + reason: error.to_string().into(), + }); + } + if !names.insert(name.clone()) { + return Err(SizeTransformError::DuplicateTargetField { edge, field: name }); + } + raw_fields.push((name, expression)); + } + + let expressions = raw_fields + .iter() + .map(|(_, expression)| expression) + .collect::>(); + let analysis = AlgebraicAnalysis::new(&expressions); + let mut plans = HashMap::new(); + let fields = raw_fields + .into_iter() + .map(|(name, expression)| { + let plan = compile(&expression, &analysis, &mut plans).map_err(|failure| { + validation_error(edge.clone(), name.clone(), expression.to_string(), failure) + })?; + let monotone = is_nonnegative_monotone(&expression, &analysis); + if relation == SizeRelation::UpperBound && !monotone { + return Err(SizeTransformError::NonMonotoneUpperBound { + edge: edge.clone(), + field: name, + expression: expression.to_string().into(), + }); + } + Ok(SizeField { + name, + expression, + plan, + monotone, + }) + }) + .collect::, _>>()?; + + Ok(Self { + edge, + relation, + fields, + analysis, + }) + } + + pub fn edge(&self) -> &str { + &self.edge + } + + pub fn relation(&self) -> SizeRelation { + self.relation + } + + pub fn expressions(&self) -> impl Iterator { + self.fields + .iter() + .map(|field| (field.name.as_ref(), &field.expression)) + } + + pub fn get(&self, target_field: &str) -> Option<&Expr> { + self.fields + .iter() + .find(|field| field.name.as_ref() == target_field) + .map(|field| &field.expression) + } + + pub fn evaluate(&self, input: &EvaluatedSize) -> Result { + if input.relation == SizeRelation::UpperBound { + if let Some(field) = self.fields.iter().find(|field| !field.monotone) { + return Err(SizeTransformError::CannotPropagateUpperBound { + edge: self.edge.clone(), + field: field.name.clone(), + expression: field.expression.to_string().into(), + }); + } + } + + let relation = input.relation.compose(self.relation); + let mut memo = HashMap::new(); + let mut output = Vec::with_capacity(self.fields.len()); + for field in &self.fields { + let value = + evaluate_plan(&field.plan, &input.values, &mut memo).map_err(|failure| { + evaluation_error(self.edge.clone(), field.name.clone(), failure) + })?; + if value.is_negative() { + return Err(SizeTransformError::NegativeResult { + edge: self.edge.clone(), + field: field.name.clone(), + value, + }); + } + let value = if relation == SizeRelation::Exact { + if !value.is_integer() { + return Err(SizeTransformError::NonIntegralResult { + edge: self.edge.clone(), + field: field.name.clone(), + value: value.to_string().into(), + }); + } + value.to_integer().magnitude().clone() + } else { + ceil_nonnegative(&value) + }; + output.push((field.name.clone(), value)); + } + Ok(EvaluatedSize { + relation, + values: SizeValues { components: output }, + }) + } + + pub fn compose( + &self, + next: &SizeTransform, + edge: impl Into>, + ) -> Result { + if self.relation == SizeRelation::UpperBound { + if let Some(field) = next.fields.iter().find(|field| !field.monotone) { + return Err(SizeTransformError::CannotPropagateUpperBound { + edge: next.edge.clone(), + field: field.name.clone(), + expression: field.expression.to_string().into(), + }); + } + } + let edge = edge.into(); + let replacements: HashMap<&str, &Expr> = self.expressions().collect(); + let fields = next + .fields + .iter() + .map(|field| { + let expression = field + .expression + .substitute_complete(&replacements) + .map_err(|error| SizeTransformError::MissingCompositionInput { + edge: edge.clone(), + field: field.name.clone(), + input_fields: error.missing_variables().map(Box::::from).collect(), + })?; + Ok((field.name.clone(), expression)) + }) + .collect::, SizeTransformError>>()?; + Self::new(edge, self.relation.compose(next.relation), fields) + } + + pub fn project_growth(&self) -> SizeGrowth { + let fields = self + .fields + .iter() + .map(|field| { + ( + field.name.clone(), + Growth::from_analysis(&field.expression, &self.analysis), + ) + }) + .collect(); + SizeGrowth { + relation: self.relation, + fields, + } + } +} + +fn compile( + expression: &Expr, + analysis: &AlgebraicAnalysis, + memo: &mut HashMap, +) -> Result { + if let Some(plan) = memo.get(&expression.node_identity()) { + return Ok(plan.clone()); + } + let node = match expression.node() { + ExprNode::Const(value) => PlanNode::Const(value.clone()), + ExprNode::Var(symbol) => PlanNode::Var(symbol.clone()), + ExprNode::Add(values) => PlanNode::Add( + values + .iter() + .map(|value| compile(value, analysis, memo)) + .collect::, _>>()? + .into_boxed_slice(), + ), + ExprNode::Mul(values) => PlanNode::Mul( + values + .iter() + .map(|value| compile(value, analysis, memo)) + .collect::, _>>()? + .into_boxed_slice(), + ), + ExprNode::Pow(base, exponent) => { + let Some(exponent) = analysis.facts(exponent).exact_rational.as_ref() else { + return Err(ValidationFailure::NonIntegralConstantExponent( + exponent.to_string().into(), + )); + }; + if !exponent.is_integer() { + return Err(ValidationFailure::NonIntegralConstantExponent( + exponent.to_string().into(), + )); + } + PlanNode::Pow(compile(base, analysis, memo)?, exponent.to_integer()) + } + ExprNode::Exp(_) => return Err(ValidationFailure::UnsupportedOperator("exp")), + ExprNode::Log(_) => return Err(ValidationFailure::UnsupportedOperator("log")), + ExprNode::Factorial(_) => { + return Err(ValidationFailure::UnsupportedOperator("factorial")); + } + }; + let plan = Plan(Arc::new(node)); + memo.insert(expression.node_identity(), plan.clone()); + Ok(plan) +} + +fn is_nonnegative_monotone(expression: &Expr, analysis: &AlgebraicAnalysis) -> bool { + if analysis + .facts(expression) + .exact_rational + .as_ref() + .is_some_and(|value| !value.is_negative()) + { + return true; + } + match expression.node() { + ExprNode::Const(value) => !value.is_negative(), + ExprNode::Var(_) => true, + ExprNode::Add(values) | ExprNode::Mul(values) => values + .iter() + .all(|value| is_nonnegative_monotone(value, analysis)), + ExprNode::Pow(base, exponent) => { + analysis + .facts(exponent) + .exact_rational + .as_ref() + .is_some_and(|exponent| exponent.is_integer() && !exponent.is_negative()) + && is_nonnegative_monotone(base, analysis) + } + ExprNode::Exp(_) | ExprNode::Log(_) | ExprNode::Factorial(_) => false, + } +} + +fn evaluate_plan( + plan: &Plan, + input: &SizeValues, + memo: &mut HashMap, +) -> Result { + if let Some(value) = memo.get(&plan.identity()) { + return Ok(value.clone()); + } + let value = match plan.0.as_ref() { + PlanNode::Const(value) => value.clone(), + PlanNode::Var(symbol) => BigRational::from_integer(BigInt::from( + input + .get(symbol.as_str()) + .ok_or_else(|| EvaluationFailure::MissingInputField(symbol.to_string().into()))? + .clone(), + )), + PlanNode::Add(values) => values.iter().try_fold(BigRational::zero(), |sum, value| { + Ok(sum + evaluate_plan(value, input, memo)?) + })?, + PlanNode::Mul(values) => values + .iter() + .try_fold(BigRational::one(), |product, value| { + Ok(product * evaluate_plan(value, input, memo)?) + })?, + PlanNode::Pow(base, exponent) => { + let base = evaluate_plan(base, input, memo)?; + if exponent.sign() == Sign::Minus && base.is_zero() { + return Err(EvaluationFailure::DivisionByZero); + } + pow_rational(base, exponent) + } + }; + memo.insert(plan.identity(), value.clone()); + Ok(value) +} + +fn pow_rational(mut base: BigRational, exponent: &BigInt) -> BigRational { + let negative = exponent.sign() == Sign::Minus; + let mut exponent = exponent.magnitude().clone(); + let mut result = BigRational::one(); + while !exponent.is_zero() { + if exponent.bit(0) { + result *= &base; + } + exponent >>= 1usize; + if !exponent.is_zero() { + base = &base * &base; + } + } + if negative { + result.recip() + } else { + result + } +} + +fn ceil_nonnegative(value: &BigRational) -> BigUint { + ((value.numer() + value.denom() - BigInt::one()) / value.denom()) + .magnitude() + .clone() +} + +#[derive(Debug)] +enum ValidationFailure { + NonIntegralConstantExponent(Box), + UnsupportedOperator(&'static str), +} + +#[derive(Debug)] +enum EvaluationFailure { + MissingInputField(Box), + DivisionByZero, +} + +fn validation_error( + edge: Box, + field: Box, + expression: String, + failure: ValidationFailure, +) -> SizeTransformError { + match failure { + ValidationFailure::NonIntegralConstantExponent(exponent) => { + SizeTransformError::NonIntegralConstantExponent { + edge, + field, + expression: expression.into(), + exponent, + } + } + ValidationFailure::UnsupportedOperator(operator) => { + SizeTransformError::UnsupportedOperator { + edge, + field, + expression: expression.into(), + operator, + } + } + } +} + +fn evaluation_error( + edge: Box, + field: Box, + failure: EvaluationFailure, +) -> SizeTransformError { + match failure { + EvaluationFailure::MissingInputField(input_field) => { + SizeTransformError::MissingInputField { + edge, + field, + input_field, + } + } + EvaluationFailure::DivisionByZero => SizeTransformError::DivisionByZero { edge, field }, + } +} + +/// Validation, composition, or evaluation failure for a [`SizeTransform`]. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum SizeTransformError { + #[error("reduction `{edge}` has invalid target size field `{field}`: {reason}")] + InvalidTargetField { + edge: Box, + field: Box, + reason: Box, + }, + #[error("reduction `{edge}` declares target size field `{field}` more than once")] + DuplicateTargetField { edge: Box, field: Box }, + #[error("reduction `{edge}` target field `{field}` has non-integral constant exponent `{exponent}` in `{expression}`")] + NonIntegralConstantExponent { + edge: Box, + field: Box, + expression: Box, + exponent: Box, + }, + #[error("reduction `{edge}` target field `{field}` uses unsupported operator `{operator}` in `{expression}`")] + UnsupportedOperator { + edge: Box, + field: Box, + expression: Box, + operator: &'static str, + }, + #[error("reduction `{edge}` target field `{field}` has a non-monotone upper-bound formula `{expression}`")] + NonMonotoneUpperBound { + edge: Box, + field: Box, + expression: Box, + }, + #[error("reduction `{edge}` target field `{field}` cannot propagate an upper bound through `{expression}`")] + CannotPropagateUpperBound { + edge: Box, + field: Box, + expression: Box, + }, + #[error( + "reduction `{edge}` target field `{field}` is missing input size field `{input_field}`" + )] + MissingInputField { + edge: Box, + field: Box, + input_field: Box, + }, + #[error( + "reduction `{edge}` target field `{field}` is missing composition inputs {input_fields:?}" + )] + MissingCompositionInput { + edge: Box, + field: Box, + input_fields: Vec>, + }, + #[error("reduction `{edge}` target field `{field}` divides by zero")] + DivisionByZero { edge: Box, field: Box }, + #[error("reduction `{edge}` target field `{field}` evaluates to non-integral size `{value}`")] + NonIntegralResult { + edge: Box, + field: Box, + value: Box, + }, + #[error("reduction `{edge}` target field `{field}` evaluates to negative size `{value}`")] + NegativeResult { + edge: Box, + field: Box, + value: BigRational, + }, + #[error("size field `{field}` value `{value}` does not fit usize")] + OutputOutOfRange { field: Box, value: BigUint }, +} + +#[cfg(test)] +#[path = "unit_tests/size.rs"] +mod tests; diff --git a/src/solvers/brute_force.rs b/src/solvers/brute_force.rs index caf8ca817..9fca85076 100644 --- a/src/solvers/brute_force.rs +++ b/src/solvers/brute_force.rs @@ -25,7 +25,16 @@ impl BruteForce { P: Problem, P::Value: Aggregate, { - self.find_all_witnesses(problem).into_iter().next() + let total = self.solve(problem); + + if !P::Value::supports_witnesses() { + return None; + } + + DimsIterator::new(problem.dims()).find(|config| { + let value = problem.evaluate(config); + P::Value::contributes_to_witnesses(&value, &total) + }) } /// Find all witness configurations for witness-supporting aggregates. diff --git a/src/solvers/customized/mod.rs b/src/solvers/customized/mod.rs deleted file mode 100644 index 3553e4d19..000000000 --- a/src/solvers/customized/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Customized solver module. -//! -//! Provides exact witness recovery for problems that have dedicated -//! structure-exploiting backends, without requiring ILP reduction paths. - -pub(crate) mod fd_subset_search; -pub(crate) mod partial_feedback_edge_set; -pub(crate) mod rooted_tree_arrangement; -mod solver; - -pub use solver::CustomizedSolver; diff --git a/src/solvers/decision_search.rs b/src/solvers/decision_search.rs index d69a9804a..7b320893c 100644 --- a/src/solvers/decision_search.rs +++ b/src/solvers/decision_search.rs @@ -16,9 +16,9 @@ where BruteForce::new().solve(problem).0 } -fn solve_via_decision_min

(problem: &P, lower: i32, upper: i32) -> Option +fn solve_via_decision_min

(problem: &P, lower: i64, upper: i64) -> Option where - P: DecisionProblemMeta + Problem> + Clone, + P: DecisionProblemMeta + Problem> + Clone, { if lower > upper { return None; @@ -42,9 +42,9 @@ where Some(lo) } -fn solve_via_decision_max

(problem: &P, lower: i32, upper: i32) -> Option +fn solve_via_decision_max

(problem: &P, lower: i64, upper: i64) -> Option where - P: DecisionProblemMeta + Problem> + Clone, + P: DecisionProblemMeta + Problem> + Clone, { if lower > upper { return None; @@ -70,15 +70,15 @@ where #[doc(hidden)] pub trait DecisionSearchValue: - OptimizationValue + Clone + fmt::Debug + Serialize + DeserializeOwned + OptimizationValue + Clone + fmt::Debug + Serialize + DeserializeOwned { - fn solve_problem

(problem: &P, lower: i32, upper: i32) -> Option + fn solve_problem

(problem: &P, lower: i64, upper: i64) -> Option where P: DecisionProblemMeta + Problem + Clone; } -impl DecisionSearchValue for Min { - fn solve_problem

(problem: &P, lower: i32, upper: i32) -> Option +impl DecisionSearchValue for Min { + fn solve_problem

(problem: &P, lower: i64, upper: i64) -> Option where P: DecisionProblemMeta + Problem + Clone, { @@ -86,8 +86,8 @@ impl DecisionSearchValue for Min { } } -impl DecisionSearchValue for Max { - fn solve_problem

(problem: &P, lower: i32, upper: i32) -> Option +impl DecisionSearchValue for Max { + fn solve_problem

(problem: &P, lower: i64, upper: i64) -> Option where P: DecisionProblemMeta + Problem + Clone, { @@ -96,7 +96,7 @@ impl DecisionSearchValue for Max { } /// Recover an optimization value by querying the problem's decision wrapper. -pub fn solve_via_decision

(problem: &P, lower: i32, upper: i32) -> Option +pub fn solve_via_decision

(problem: &P, lower: i64, upper: i64) -> Option where P: DecisionProblemMeta + Clone, P::Value: DecisionSearchValue, diff --git a/src/solvers/ilp/mod.rs b/src/solvers/ilp/mod.rs index b09109814..c061a84a7 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -23,5 +23,4 @@ mod solver; -pub use solver::ILPSolver; -pub use solver::SolveViaReductionError; +pub use solver::{ILPSolveError, ILPSolver}; diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 51b2a0df2..f3827c76d 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,15 +1,44 @@ //! ILP solver implementation using HiGHS. use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; -use crate::models::misc::TimetableDesign; -use crate::rules::{ReduceTo, ReductionMode, ReductionResult}; -#[cfg(not(feature = "ilp-highs"))] -use good_lp::default_solver; -#[cfg(feature = "ilp-highs")] +use crate::rules::{ReduceTo, ReductionResult}; use good_lp::highs; -#[cfg(feature = "ilp-highs")] use good_lp::solvers::highs::HighsParallelType; -use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; +use good_lp::{ + variable, ProblemVariables, ResolutionError, Solution, SolutionStatus, SolverModel, Variable, +}; + +/// A failure to produce a proven-optimal ILP solution. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ILPSolveError { + /// The constraints have no feasible assignment. + #[error("the ILP is infeasible")] + Infeasible, + /// The objective is unbounded. + #[error("the ILP objective is unbounded")] + Unbounded, + /// The configured time limit was reached before optimality was proven. + #[error("the ILP solver reached its time limit before proving optimality")] + Timeout, + /// The selected backend failed for another reason. + #[error("the ILP backend failed: {0}")] + BackendFailure(String), + /// Type-erased dispatch received a value other than a supported ILP variant. + #[error("the ILP backend supports only ILP and ILP")] + UnsupportedProblemType, + /// A target witness could not be mapped back to the source problem. + #[error(transparent)] + Extraction(#[from] crate::rules::ExtractionError), +} + +fn classify_backend_error(error: ResolutionError, time_limit: Option) -> ILPSolveError { + match error { + ResolutionError::Infeasible => ILPSolveError::Infeasible, + ResolutionError::Unbounded => ILPSolveError::Unbounded, + ResolutionError::Other("NoSolutionFound") if time_limit.is_some() => ILPSolveError::Timeout, + other => ILPSolveError::BackendFailure(other.to_string()), + } +} /// An ILP solver using the HiGHS backend. /// @@ -30,9 +59,9 @@ use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; /// ); /// /// let solver = ILPSolver::new(); -/// if let Some(solution) = solver.solve(&ilp) { -/// println!("Solution: {:?}", solution); -/// } +/// let solution = solver.solve(&ilp)?; +/// println!("Solution: {:?}", solution); +/// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` #[derive(Debug, Clone, Default)] pub struct ILPSolver { @@ -40,33 +69,6 @@ pub struct ILPSolver { pub time_limit: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SolveViaReductionError { - WitnessPathRequired { name: String }, - NoReductionPath { name: String }, - NoSolution { name: String }, -} - -impl std::fmt::Display for SolveViaReductionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SolveViaReductionError::WitnessPathRequired { name } => write!( - f, - "ILP solving requires a witness-capable source problem and reduction path; only aggregate-value solving is available for {}.", - name - ), - SolveViaReductionError::NoReductionPath { name } => { - write!(f, "No reduction path from {} to ILP", name) - } - SolveViaReductionError::NoSolution { name } => { - write!(f, "ILP solver found no solution for {}", name) - } - } - } -} - -impl std::error::Error for SolveViaReductionError {} - impl ILPSolver { /// Create a new ILP solver with default settings. pub fn new() -> Self { @@ -82,13 +84,17 @@ impl ILPSolver { /// Solve an ILP problem directly. /// - /// Returns `None` if the problem is infeasible or the solver fails. + /// Returns a classified error when the problem is infeasible, the time + /// limit is reached, or the backend fails. /// The returned solution is a configuration vector where each element /// is the variable value (config index = value). - pub fn solve(&self, problem: &ILP) -> Option> { + pub fn solve(&self, problem: &ILP) -> Result, ILPSolveError> { let n = problem.num_vars; if n == 0 { - return problem.is_feasible(&[]).then_some(vec![]); + return problem + .is_feasible(&[]) + .then_some(vec![]) + .ok_or(ILPSolveError::Infeasible); } // Derive tighter per-variable upper bounds from single-variable ≤ constraints. @@ -136,7 +142,6 @@ impl ILPSolver { }; // Create the solver model - #[cfg(feature = "ilp-highs")] let mut model = { let mut model = unsolved .using(highs) @@ -150,9 +155,6 @@ impl ILPSolver { model }; - #[cfg(not(feature = "ilp-highs"))] - let mut model = unsolved.using(default_solver); - // Add constraints for constraint in &problem.constraints { // Build left-hand side expression @@ -173,7 +175,20 @@ impl ILPSolver { } // Solve - let solution = model.solve().ok()?; + let effective_time_limit = self.time_limit; + let solution = model + .solve() + .map_err(|error| classify_backend_error(error, effective_time_limit))?; + + match solution.status() { + SolutionStatus::Optimal => {} + SolutionStatus::TimeLimit => return Err(ILPSolveError::Timeout), + SolutionStatus::GapLimit => { + return Err(ILPSolveError::BackendFailure( + "the backend stopped at its gap limit before proving optimality".to_string(), + )); + } + } // Extract solution: config index = value (no lower bound offset) let result: Vec = vars @@ -184,12 +199,12 @@ impl ILPSolver { }) .collect(); - Some(result) + Ok(result) } - /// Solve any problem that reduces to `ILP`. + /// Solve any problem that reduces directly to `ILP`. /// - /// This method first reduces the problem to a binary ILP, solves the ILP, + /// This method first reduces the problem to the selected ILP domain, solves the ILP, /// and then extracts the solution back to the original problem space. /// /// # Example @@ -207,143 +222,29 @@ impl ILPSolver { /// /// // Solve using ILP solver /// let solver = ILPSolver::new(); - /// if let Some(solution) = solver.solve_reduced(&problem) { - /// println!("Solution: {:?}", solution); - /// } + /// let solution = solver.solve_reduced::(&problem)?; + /// println!("Solution: {:?}", solution); + /// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` - pub fn solve_reduced

(&self, problem: &P) -> Option> + pub fn solve_reduced(&self, problem: &P) -> Result, ILPSolveError> where - P: ReduceTo>, + V: VariableDomain, + P: ReduceTo>, { let reduction = problem.reduce_to(); let ilp_solution = self.solve(reduction.target_problem())?; - Some(reduction.extract_solution(&ilp_solution)) + Ok(reduction.extract_solution(&ilp_solution)?) } - /// Solve a type-erased problem directly when a native solver hook exists. - /// - /// Returns `None` if the input type has no direct solver or the solver finds no solution. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { + /// Solve a type-erased supported ILP variant directly. + pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Result, ILPSolveError> { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } - if let Some(problem) = any.downcast_ref::() { - return problem.solve_via_required_assignments(); - } - None - } - - fn supports_direct_dyn(&self, any: &dyn std::any::Any) -> bool { - any.is::>() || any.is::>() || any.is::() - } - - /// Two-level path selection: - /// 1. Dijkstra finds the cheapest path to each ILP variant using - /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). - /// 2. Across ILP variants, we pick the path whose composed final output size - /// is smallest — this is the actual ILP problem size the solver will face. - fn best_path_to_ilp( - &self, - graph: &crate::rules::ReductionGraph, - name: &str, - variant: &std::collections::BTreeMap, - mode: ReductionMode, - instance: &dyn std::any::Any, - ) -> Option { - let ilp_variants = graph.variants_for("ILP"); - let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); - let mut best_path: Option = None; - let mut best_cost = f64::INFINITY; - - for dv in &ilp_variants { - if let Some(path) = graph.find_cheapest_path_mode( - name, - variant, - "ILP", - dv, - mode, - &input_size, - &crate::rules::MinimizeStepsThenOverhead, - ) { - // Use composed final output size for cross-variant comparison, - // since this determines the actual ILP problem size. - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .unwrap_or_default(); - let cost = final_size.total() as f64; - if cost < best_cost { - best_cost = cost; - best_path = Some(path); - } - } - } - - best_path - } - - pub fn try_solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Result, SolveViaReductionError> { - if self.supports_direct_dyn(instance) { - return self - .solve_dyn(instance) - .ok_or_else(|| SolveViaReductionError::NoSolution { - name: name.to_string(), - }); - } - - let graph = crate::rules::ReductionGraph::new(); - - let Some(path) = - self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) - else { - if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) - .is_some() - { - return Err(SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - }); - } - - return Err(SolveViaReductionError::NoReductionPath { - name: name.to_string(), - }); - }; - - let chain = graph.reduce_along_path(&path, instance).ok_or_else(|| { - SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - } - })?; - let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { - SolveViaReductionError::NoSolution { - name: name.to_string(), - } - })?; - Ok(chain.extract_solution(&ilp_solution)) - } - - /// Solve a type-erased problem by finding a reduction path to ILP. - /// - /// Tries all ILP variants, picks the cheapest path, reduces, solves, - /// and extracts the solution back. Falls back to direct ILP solve if - /// the problem is already an ILP type. - /// - /// Returns `None` if no path to ILP exists or the solver finds no solution. - pub fn solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Option> { - self.try_solve_via_reduction(name, variant, instance).ok() + Err(ILPSolveError::UnsupportedProblemType) } } diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index 9a1283cfc..c3864765b 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -1,17 +1,25 @@ //! Solvers for computational problems. mod brute_force; -pub mod customized; pub mod decision_search; +mod native; +mod pipelines; +mod registry; +mod resolver; -#[cfg(feature = "ilp-solver")] pub mod ilp; pub use brute_force::BruteForce; -pub use customized::CustomizedSolver; +pub use registry::{ + solver_capabilities, ExactProblemKey, IlpSolverCapability, NativeSolverCapability, + RegistryBuildError, SolverCapabilities, +}; +pub use resolver::{ + solve_deterministically, DeterministicSolveError, DeterministicSolveResult, SolverExecution, + SolverRequest, +}; -#[cfg(feature = "ilp-solver")] -pub use ilp::ILPSolver; +pub use ilp::{ILPSolveError, ILPSolver}; use crate::traits::Problem; diff --git a/src/solvers/customized/fd_subset_search.rs b/src/solvers/native/fd_subset_search.rs similarity index 100% rename from src/solvers/customized/fd_subset_search.rs rename to src/solvers/native/fd_subset_search.rs diff --git a/src/solvers/native/mod.rs b/src/solvers/native/mod.rs new file mode 100644 index 000000000..6625219fa --- /dev/null +++ b/src/solvers/native/mod.rs @@ -0,0 +1,9 @@ +//! Dedicated native solver backends. +//! +//! Each backend is registered for one exact problem variant. Dispatch is +//! performed by the solver capability registry rather than a downcast chain. + +pub(crate) mod fd_subset_search; +pub(crate) mod partial_feedback_edge_set; +pub(crate) mod rooted_tree_arrangement; +mod solver; diff --git a/src/solvers/customized/partial_feedback_edge_set.rs b/src/solvers/native/partial_feedback_edge_set.rs similarity index 100% rename from src/solvers/customized/partial_feedback_edge_set.rs rename to src/solvers/native/partial_feedback_edge_set.rs diff --git a/src/solvers/customized/rooted_tree_arrangement.rs b/src/solvers/native/rooted_tree_arrangement.rs similarity index 100% rename from src/solvers/customized/rooted_tree_arrangement.rs rename to src/solvers/native/rooted_tree_arrangement.rs diff --git a/src/solvers/customized/solver.rs b/src/solvers/native/solver.rs similarity index 72% rename from src/solvers/customized/solver.rs rename to src/solvers/native/solver.rs index a980a9bb6..de5dc46a0 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/native/solver.rs @@ -1,76 +1,72 @@ -//! CustomizedSolver: structure-exploiting exact witness solver. -//! -//! Uses direct downcast dispatch to call dedicated backends for -//! supported problem types, returning `None` for unsupported problems. +//! Exact native solvers and their exact-variant registrations. use super::fd_subset_search::{ self, compute_closure, find_essential_attributes, find_essential_attributes_restricted, is_minimal_key, is_superkey, BranchDecision, }; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation}; +use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation, TimetableDesign}; use crate::models::set::{MinimumCardinalityKey, PrimeAttributeName}; +use crate::solvers::registry::NativeSolverRegistration; use crate::topology::SimpleGraph; +use crate::traits::Problem; use std::collections::HashSet; -/// A solver that uses problem-specific backends for exact witness recovery. -/// -/// Unlike `BruteForce`, which enumerates all configurations, `CustomizedSolver` -/// exploits problem structure (functional-dependency closure, cycle hitting, -/// tree arrangement) to prune search and find witnesses more efficiently. -/// -/// Returns `None` for unsupported problem types. -#[derive(Default)] -pub struct CustomizedSolver; - -impl CustomizedSolver { - /// Create a new `CustomizedSolver`. - pub fn new() -> Self { - Self - } - - /// Check whether a type-erased problem is supported by the customized solver. - pub fn supports_problem(any: &dyn std::any::Any) -> bool { - any.is::() - || any.is::() - || any.is::() - || any.is::() - || any.is::>() - || any.is::>() - } - - /// Attempt to solve a type-erased problem using a dedicated backend. - /// - /// Returns `Some(config)` if a satisfying witness is found, `None` if - /// the problem type is unsupported or no witness exists. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { - if let Some(p) = any.downcast_ref::() { - return solve_minimum_cardinality_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_additional_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_prime_attribute_name(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_bcnf_violation(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::partial_feedback_edge_set::find_witness(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::rooted_tree_arrangement::find_witness(p); +macro_rules! register_native_solver { + ($problem:ty, $implementation:literal, $solve:path) => { + inventory::submit! { + NativeSolverRegistration { + source_name: <$problem as Problem>::NAME, + source_variant_fn: <$problem as Problem>::variant, + implementation: $implementation, + solve_fn: |any| { + let problem = any.downcast_ref::<$problem>().expect( + "native solver registration received the wrong concrete type", + ); + $solve(problem) + }, + } } - None - } + }; } +register_native_solver!( + MinimumCardinalityKey, + "fd-minimum-cardinality-key", + solve_minimum_cardinality_key +); +register_native_solver!(AdditionalKey, "fd-additional-key", solve_additional_key); +register_native_solver!( + PrimeAttributeName, + "fd-prime-attribute-name", + solve_prime_attribute_name +); +register_native_solver!( + BoyceCoddNormalFormViolation, + "fd-bcnf-violation", + solve_bcnf_violation +); +register_native_solver!( + PartialFeedbackEdgeSet, + "partial-feedback-edge-set", + super::partial_feedback_edge_set::find_witness +); +register_native_solver!( + RootedTreeArrangement, + "rooted-tree-arrangement", + super::rooted_tree_arrangement::find_witness +); +register_native_solver!( + TimetableDesign, + "timetable-required-assignments", + TimetableDesign::solve_via_required_assignments +); + /// Solve MinimumCardinalityKey: find a minimal key with smallest cardinality. /// /// Uses iterative deepening by cardinality to guarantee the first solution /// found has the minimum number of attributes. -fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { +pub(crate) fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); @@ -113,7 +109,7 @@ fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option Option> { +pub(crate) fn solve_additional_key(problem: &AdditionalKey) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let relation_attrs = problem.relation_attrs(); @@ -176,7 +172,7 @@ fn solve_additional_key(problem: &AdditionalKey) -> Option> { } /// Solve PrimeAttributeName: find a candidate key containing the query attribute. -fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { +pub(crate) fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let query = problem.query_attribute(); @@ -220,7 +216,7 @@ fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option /// Solve BoyceCoddNormalFormViolation: find a subset X of target_subset such that /// the closure of X contains some but not all of target_subset \ X. -fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { +pub(crate) fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.functional_deps().to_vec(); let target = problem.target_subset(); @@ -263,5 +259,5 @@ fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option { + inventory::submit! { + IlpPipelineRegistration { + path: &[ + $(StaticProblemStep { + name: $name, + variant: &[$(($key, $value)),*], + }),+ + ], + } + } + }; +} + +register_ilp_pipeline! { + ("AcyclicPartition", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BalancedCompleteBipartiteSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BicliqueCover", []), + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BiconnectivityAugmentation", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BinPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BottleneckTravelingSalesman", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BoundedComponentSpanningForest", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("CapacityAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("CircuitSAT", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ClosestString", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ClosestSubstring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveBlockMinimization", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesMatrixAugmentation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesSubmatrix", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsistencyOfDatabaseFrequencyTables", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionOptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DirectedHamiltonianPath", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DisjointConnectingPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("EulerianPath", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ExactCoverBy3Sets", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ExpectedRetrievalCost", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Factoring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FeasibleRegisterAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FlowShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("GraphPartitioning", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianCircuit", [("graph", "SimpleGraph")]), + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianPath", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HighlyConnectedDeletion", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "i32")]), +} + +// This exact variant also has a native backend. Default dispatch selects the +// native registration, while an explicit ILP override executes this pipeline. +register_ilp_pipeline! { + ("RootedTreeArrangement", [("graph", "SimpleGraph")]), + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowBundles", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowHomologousArcs", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowWithMultipliers", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IsomorphicSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KClique", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "KN")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "K3")]), + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "KN")]), + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K2")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K3")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Knapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LengthBoundedDisjointPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCommonSubsequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximalIS", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Maximum2Satisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCommonEdgeSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumContactMapOverlap", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumDomaticNumber", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "TriangularSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumLeafSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximumLikelihoodRanking", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumMatching", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "One")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinMaxMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCapacitatedSpanningTree", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumCutIntoBoundedSets", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDiscretePlanarInverseKinematics", []), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumEdgeCostFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumExternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFaultDetectionTestSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFeedbackVertexSet", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumGraphBandwidth", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumInternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMatrixCover", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMaximalMatching", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMetricDimension", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMultiwayCut", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumWeightDecoding", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MixedChinesePostman", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MonochromaticTriangle", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultipleCopyFileAllocation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Numerical3DimensionalMatching", []), + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("OpenShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimumCommunicationSpanningTree", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PaintShop", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartiallyOrderedKnapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Partition", []), + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoCliques", [("graph", "SimpleGraph")]), + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoPathsOfLength2", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoTriangles", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PathConstrainedNetworkFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("PrecedenceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PreemptiveScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("QuadraticAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RectilinearPictureCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RegisterSufficiency", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SchedulingToMinimizeWeightedCompletionTime", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SchedulingWithIndividualDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeMaximumCumulativeCost", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeTardyTaskWeight", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeWeightedTardiness", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingWithDeadlinesAndSetUpTimes", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithReleaseTimesAndDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithinIntervals", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SetSplitting", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestCommonSupersequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestWeightConstrainedPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SparseMatrixCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StackerCrane", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StringToStringCorrection", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StrongConnectivityAugmentation", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SubgraphIsomorphism", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SumOfSquaresPartition", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreeDimensionalMatching", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreePartition", []), + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("TravelingSalesman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("UndirectedFlowLowerBounds", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("UndirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs new file mode 100644 index 000000000..f69aa25de --- /dev/null +++ b/src/solvers/registry.rs @@ -0,0 +1,385 @@ +//! Deterministic solver capabilities for exact problem variants. + +use crate::registry::VariantEntry; +use crate::rules::registry::{reduction_entries, ReduceFn, ReductionEntry}; +use crate::rules::DynReductionResult; +use serde::Serialize; +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::OnceLock; + +/// Canonical identity of one concrete problem variant. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ExactProblemKey { + pub name: String, + pub variant: BTreeMap, +} + +impl ExactProblemKey { + pub fn new(name: impl Into, variant: BTreeMap) -> Self { + Self { + name: name.into(), + variant, + } + } + + fn from_static(step: &StaticProblemStep) -> Self { + Self::new( + step.name, + step.variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } + + /// Format the key using the catalog's canonical problem notation. + pub fn label(&self) -> String { + if self.variant.is_empty() { + return self.name.clone(); + } + let values = self + .variant + .values() + .cloned() + .collect::>() + .join(", "); + format!("{}<{values}>", self.name) + } + + fn is_supported_ilp(&self) -> bool { + self.name == "ILP" + && matches!( + self.variant.get("variable").map(String::as_str), + Some("bool" | "i32") + ) + } +} + +/// A compile-time path node used by fixed ILP pipeline declarations. +#[derive(Clone, Copy)] +pub(crate) struct StaticProblemStep { + pub name: &'static str, + pub variant: &'static [(&'static str, &'static str)], +} + +/// A fixed ILP pipeline declaration. +/// +/// Every adjacent pair is resolved to one exact witness reduction while the +/// registry is constructed. Runtime solving executes the resolved function +/// pointers and never searches the reduction graph. +pub(crate) struct IlpPipelineRegistration { + pub(crate) path: &'static [StaticProblemStep], +} + +inventory::collect!(IlpPipelineRegistration); + +type NativeSolveFn = fn(&dyn Any) -> Option>; + +/// A dedicated solver registered for one exact problem variant. +#[derive(Debug)] +pub(crate) struct NativeSolverRegistration { + pub(crate) source_name: &'static str, + pub(crate) source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, + pub(crate) implementation: &'static str, + pub(crate) solve_fn: NativeSolveFn, +} + +impl NativeSolverRegistration { + fn source_key(&self) -> ExactProblemKey { + ExactProblemKey::new( + self.source_name, + (self.source_variant_fn)() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } +} + +inventory::collect!(NativeSolverRegistration); + +#[derive(Debug)] +pub(crate) struct CompiledIlpPipeline { + path: Vec, + reducers: Vec, +} + +impl CompiledIlpPipeline { + pub(crate) fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub(crate) fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } + + pub(crate) fn solve( + &self, + source: &dyn Any, + solver: &super::ILPSolver, + ) -> Result, super::ILPSolveError> { + if self.reducers.is_empty() { + return solver.solve_dyn(source); + } + + let mut reductions: Vec> = Vec::new(); + for reducer in &self.reducers { + let input = reductions + .last() + .map(|step| step.target_problem_any()) + .unwrap_or(source); + reductions.push(reducer(input)); + } + + let target = reductions + .last() + .expect("non-empty fixed pipeline must produce a target") + .target_problem_any(); + let solution = solver.solve_dyn(target)?; + let mut source_solution = solution; + for step in reductions.iter().rev() { + source_solution = step.extract_solution_dyn(&source_solution)?; + } + Ok(source_solution) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct RegisteredSolverCapabilities<'a> { + pub(crate) native: Option<&'static NativeSolverRegistration>, + pub(crate) ilp: Option<&'a CompiledIlpPipeline>, +} + +impl std::fmt::Debug for RegisteredSolverCapabilities<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SolverCapabilities") + .field("native", &self.native.map(|entry| entry.implementation)) + .field("ilp", &self.ilp.map(CompiledIlpPipeline::path)) + .finish() + } +} + +/// Read-only metadata for a registered native solver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct NativeSolverCapability { + pub implementation: &'static str, +} + +/// Read-only metadata for a registered fixed ILP pipeline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IlpSolverCapability { + path: Vec, +} + +impl IlpSolverCapability { + pub fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } +} + +/// Read-only solver capabilities for one exact problem variant. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SolverCapabilities { + pub native: Option, + pub ilp: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct SolverCapabilityRegistry { + native: BTreeMap, + ilp: BTreeMap, +} + +impl SolverCapabilityRegistry { + pub(crate) fn lookup(&self, key: &ExactProblemKey) -> RegisteredSolverCapabilities<'_> { + RegisteredSolverCapabilities { + native: self.native.get(key).copied(), + ilp: self.ilp.get(key), + } + } + + #[cfg(test)] + pub(crate) fn native_entries( + &self, + ) -> impl Iterator + '_ { + self.native.iter().map(|(key, entry)| (key, *entry)) + } + + #[cfg(test)] + pub(crate) fn ilp_entries( + &self, + ) -> impl Iterator { + self.ilp.iter() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryBuildError { + #[error("solver registration references unknown exact variant {0}")] + UnknownVariant(String), + #[error("duplicate native solver registration for {0}")] + DuplicateNative(String), + #[error("duplicate ILP pipeline registration for {0}")] + DuplicateIlp(String), + #[error("ILP pipeline must contain at least one node")] + EmptyPipeline, + #[error("ILP pipeline for {0} does not end at ILP or ILP")] + UnsupportedTarget(String), + #[error("ILP pipeline for {0} continues after reaching a supported ILP node")] + ContinuesAfterIlp(String), + #[error("ILP pipeline edge {source_label} -> {target_label} resolves to {matches} witness reductions")] + InvalidEdge { + source_label: String, + target_label: String, + matches: usize, + }, +} + +fn registered_variant_keys() -> BTreeSet { + inventory::iter::() + .map(|entry| ExactProblemKey::new(entry.name, entry.variant_map())) + .collect() +} + +fn edge_key(entry: &ReductionEntry, source: bool) -> ExactProblemKey { + let (name, variant) = if source { + (entry.source_name, entry.source_variant()) + } else { + (entry.target_name, entry.target_variant()) + }; + ExactProblemKey::new( + name, + variant + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) +} + +fn build_registry( + variants: &BTreeSet, + native_entries: impl IntoIterator, + pipeline_entries: impl IntoIterator, + reductions: &[&'static ReductionEntry], +) -> Result { + let mut registry = SolverCapabilityRegistry::default(); + let mut reduction_index = + BTreeMap::<(ExactProblemKey, ExactProblemKey), Vec<&'static ReductionEntry>>::new(); + for entry in reductions + .iter() + .copied() + .filter(|entry| entry.reduce_fn.is_some()) + { + reduction_index + .entry((edge_key(entry, true), edge_key(entry, false))) + .or_default() + .push(entry); + } + + for native in native_entries { + let source = native.source_key(); + if !variants.contains(&source) { + return Err(RegistryBuildError::UnknownVariant(source.label())); + } + if registry.native.insert(source.clone(), native).is_some() { + return Err(RegistryBuildError::DuplicateNative(source.label())); + } + } + + for registration in pipeline_entries { + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let source = path + .first() + .cloned() + .ok_or(RegistryBuildError::EmptyPipeline)?; + + for step in &path { + if !variants.contains(step) { + return Err(RegistryBuildError::UnknownVariant(step.label())); + } + } + if !path.last().is_some_and(ExactProblemKey::is_supported_ilp) { + return Err(RegistryBuildError::UnsupportedTarget(source.label())); + } + if path[..path.len() - 1] + .iter() + .any(ExactProblemKey::is_supported_ilp) + { + return Err(RegistryBuildError::ContinuesAfterIlp(source.label())); + } + + let mut reducers = Vec::with_capacity(path.len().saturating_sub(1)); + for pair in path.windows(2) { + let matches = reduction_index + .get(&(pair[0].clone(), pair[1].clone())) + .map(Vec::as_slice) + .unwrap_or_default(); + if matches.len() != 1 { + return Err(RegistryBuildError::InvalidEdge { + source_label: pair[0].label(), + target_label: pair[1].label(), + matches: matches.len(), + }); + } + reducers.push( + matches[0] + .reduce_fn + .expect("indexed only entries with reduce_fn"), + ); + } + + if registry + .ilp + .insert(source.clone(), CompiledIlpPipeline { path, reducers }) + .is_some() + { + return Err(RegistryBuildError::DuplicateIlp(source.label())); + } + } + + Ok(registry) +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +pub(crate) fn solver_capability_registry( +) -> Result<&'static SolverCapabilityRegistry, &'static RegistryBuildError> { + REGISTRY + .get_or_init(|| { + build_registry( + ®istered_variant_keys(), + inventory::iter::(), + inventory::iter::(), + &reduction_entries(), + ) + }) + .as_ref() +} + +/// Return read-only solver metadata for one exact problem variant. +pub fn solver_capabilities( + key: &ExactProblemKey, +) -> Result { + let registered = solver_capability_registry()?.lookup(key); + Ok(SolverCapabilities { + native: registered.native.map(|entry| NativeSolverCapability { + implementation: entry.implementation, + }), + ilp: registered.ilp.map(|pipeline| IlpSolverCapability { + path: pipeline.path.clone(), + }), + }) +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/registry.rs"] +mod tests; diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs new file mode 100644 index 000000000..58e289001 --- /dev/null +++ b/src/solvers/resolver.rs @@ -0,0 +1,149 @@ +//! Shared deterministic solver dispatch. + +use super::registry::CompiledIlpPipeline; +use super::registry::{ + solver_capability_registry, ExactProblemKey, NativeSolverRegistration, RegistryBuildError, +}; +use crate::registry::LoadedDynProblem; +use serde::Serialize; + +/// Public solver override. Omission is represented by [`SolverRequest::Default`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SolverRequest { + #[default] + Default, + Ilp, + BruteForce, +} + +/// Information about the backend execution that produced a solve result. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum SolverExecution { + Native { implementation: &'static str }, + Ilp { reduction_path: Vec }, + BruteForce, +} + +/// Type-erased result returned by deterministic solver dispatch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeterministicSolveResult { + pub solver: SolverExecution, + pub config: Option>, + pub evaluation: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeterministicSolveError { + #[error("solver capability registry is invalid: {0}")] + InvalidRegistry(&'static RegistryBuildError), + #[error("No ILP pipeline is registered for {0}")] + MissingIlpCapability(String), + #[error("native solver found no solution for {problem}")] + NativeNoSolution { problem: String }, + #[error("ILP solver failed for {problem}: {source}")] + IlpSolve { + problem: String, + #[source] + source: super::ILPSolveError, + }, +} + +fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { + ExactProblemKey::new(problem.problem_name(), problem.variant_map()) +} + +fn solve_native( + problem: &LoadedDynProblem, + registration: &'static NativeSolverRegistration, +) -> Result { + let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { + DeterministicSolveError::NativeNoSolution { + problem: problem_key(problem).label(), + } + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Native { + implementation: registration.implementation, + }, + config: Some(config), + evaluation, + }) +} + +fn solve_ilp( + problem: &LoadedDynProblem, + pipeline: &CompiledIlpPipeline, +) -> Result { + let config = pipeline + .solve(problem.as_any(), &super::ILPSolver::new()) + .map_err(|source| DeterministicSolveError::IlpSolve { + problem: problem_key(problem).label(), + source, + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Ilp { + reduction_path: pipeline.path_labels(), + }, + config: Some(config), + evaluation, + }) +} + +fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { + match problem.solve_brute_force_witness() { + Some((config, evaluation)) => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: Some(config), + evaluation, + }, + None => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: None, + evaluation: problem.solve_brute_force_value(), + }, + } +} + +/// Solve a loaded problem using deterministic exact-variant dispatch. +/// +/// Default dispatch is native, then the registered fixed ILP pipeline, then +/// brute force. Once selected, backend failure is returned without fallback. +pub fn solve_deterministically( + problem: &LoadedDynProblem, + request: SolverRequest, +) -> Result { + if request == SolverRequest::BruteForce { + return Ok(solve_brute_force(problem)); + } + + let registry = + solver_capability_registry().map_err(DeterministicSolveError::InvalidRegistry)?; + let key = problem_key(problem); + let capabilities = registry.lookup(&key); + + match request { + SolverRequest::BruteForce => unreachable!("handled before registry initialization"), + SolverRequest::Ilp => { + let pipeline = capabilities + .ilp + .ok_or_else(|| DeterministicSolveError::MissingIlpCapability(key.label()))?; + solve_ilp(problem, pipeline) + } + SolverRequest::Default => { + if let Some(native) = capabilities.native { + return solve_native(problem, native); + } + if let Some(pipeline) = capabilities.ilp { + return solve_ilp(problem, pipeline); + } + Ok(solve_brute_force(problem)) + } + } +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/resolver.rs"] +mod tests; diff --git a/src/types.rs b/src/types.rs index cc859423b..20d257b01 100644 --- a/src/types.rs +++ b/src/types.rs @@ -32,8 +32,9 @@ impl NumericSize for T where /// Maps a weight element to its sum/metric type. /// /// This decouples the per-element weight type from the accumulation type. -/// For concrete weights (`i32`, `f64`), `Sum` is the same type. -/// For the unit weight `One`, `Sum = i32`. +/// Exact integer weights use a wider accumulation type: `i32` and the unit +/// weight [`One`] both use `i64`. Approximate `f64` weights continue to sum +/// into `f64`. pub trait WeightElement: Clone + Default + 'static { /// The numeric type used for sums and comparisons. type Sum: NumericSize; @@ -44,10 +45,10 @@ pub trait WeightElement: Clone + Default + 'static { } impl WeightElement for i32 { - type Sum = i32; + type Sum = i64; const IS_UNIT: bool = false; - fn to_sum(&self) -> i32 { - *self + fn to_sum(&self) -> i64 { + i64::from(*self) } } @@ -62,7 +63,7 @@ impl WeightElement for f64 { /// The constant 1. Unit weight for unweighted problems. /// /// When used as the weight type parameter `W`, indicates that all weights -/// are uniformly 1. `One::to_sum()` returns `1i32`. +/// are uniformly 1. `One::to_sum()` returns `1i64`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct One; @@ -142,9 +143,9 @@ impl<'de> Deserialize<'de> for One { } impl WeightElement for One { - type Sum = i32; + type Sum = i64; const IS_UNIT: bool = true; - fn to_sum(&self) -> i32 { + fn to_sum(&self) -> i64 { 1 } } @@ -557,11 +558,6 @@ impl ProblemSize { .find(|(k, _)| k == name) .map(|(_, v)| *v) } - - /// Sum of all component values. - pub fn total(&self) -> usize { - self.components.iter().map(|(_, v)| *v).sum() - } } impl fmt::Display for ProblemSize { diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index 6dab26625..ef0efafaf 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -85,7 +85,7 @@ fn test_big_o_composed_overhead_duplicate() { #[test] fn test_big_o_exp_with_polynomial() { // exp(n) dominates n^10 - let e = Expr::Exp(Box::new(Expr::Var("n"))) + Expr::pow(Expr::Var("n"), Expr::Const(10.0)); + let e = Expr::exp(Expr::variable("n")) + Expr::pow(Expr::variable("n"), Expr::integer(10)); let result = big_o_normal_form(&e).unwrap(); let s = result.to_string(); assert!(s.contains("exp"), "expected exp term to survive, got: {s}"); @@ -97,33 +97,40 @@ fn test_big_o_exp_with_polynomial() { #[test] fn test_big_o_pure_constant_returns_one() { - let e = Expr::Const(42.0); + let e = Expr::integer(42); let result = big_o_normal_form(&e).unwrap(); assert_eq!(result.to_string(), "1"); } #[test] -fn test_big_o_rejects_division() { - let e = Expr::Var("n") / Expr::Var("m"); - assert!(big_o_normal_form(&e).is_err()); +fn test_big_o_rejects_negative_symbolic_power() { + let e = Expr::variable("n") / Expr::variable("m"); + let error = big_o_normal_form(&e).unwrap_err(); + assert_eq!( + error.to_string(), + "unsupported asymptotic expression: negative exponent is unsupported: -1" + ); } #[test] -fn test_big_o_rejects_negative_dominant_term() { - let e = Expr::Const(-1.0) * Expr::Var("n"); - assert!(big_o_normal_form(&e).is_err()); +fn test_big_o_drops_negative_constant_factor() { + // The growth domain drops constant multipliers, sign included, so `-1 * n` + // widens to `n` (an upper bound on its magnitude) instead of being rejected. + let e = Expr::integer(-1) * Expr::variable("n"); + let result = big_o_normal_form(&e).unwrap(); + assert_eq!(result.to_string(), "n"); } #[test] fn test_big_o_constant_base_one_becomes_constant() { - let e = Expr::pow(Expr::Const(1.0), Expr::Var("n")); + let e = Expr::pow(Expr::integer(1), Expr::variable("n")); let result = big_o_normal_form(&e).unwrap(); assert_eq!(result.to_string(), "1"); } #[test] fn test_big_o_rejects_nonpositive_constant_base_exponential() { - let e = Expr::pow(Expr::Const(-2.0), Expr::Var("n")); + let e = Expr::pow(Expr::integer(-2), Expr::variable("n")); assert!(big_o_normal_form(&e).is_err()); } @@ -219,11 +226,17 @@ fn test_big_o_multivar_exp_dominates_poly() { } #[test] -fn test_big_o_pathological_nesting_errors_instead_of_hanging() { - // Regression for issue #1069: a deeply-nested power that expands - // exponentially must return an error promptly (so callers like `big_o_of` - // fall back to the un-expanded expression) rather than OOM/hang. - let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); - let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); - assert!(big_o_normal_form(&e).is_err()); +fn test_big_o_pathological_nesting_returns_bound_instantly() { + // A deeply nested power that the old expansion pipeline could not normalize. + // The growth domain answers it bottom-up: `((a+b+c+d)^4)^4` raises each + // variable term to degree 16, so it returns a real bound immediately. + let sum = Expr::variable("a") + Expr::variable("b") + Expr::variable("c") + Expr::variable("d"); + let e = Expr::pow(Expr::pow(sum, Expr::integer(4)), Expr::integer(4)); + let start = std::time::Instant::now(); + let result = big_o_normal_form(&e).unwrap(); + assert!(start.elapsed().as_millis() < 50, "should be instant"); + let s = result.to_string(); + for v in ["a^16", "b^16", "c^16", "d^16"] { + assert!(s.contains(v), "expected {v} in {s}"); + } } diff --git a/src/unit_tests/canonical.rs b/src/unit_tests/canonical.rs deleted file mode 100644 index dcf3f8fd0..000000000 --- a/src/unit_tests/canonical.rs +++ /dev/null @@ -1,165 +0,0 @@ -use super::*; -use crate::expr::Expr; - -#[test] -fn test_canonical_identity() { - let e = Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "n"); -} - -#[test] -fn test_canonical_add_like_terms() { - // n + n → 2 * n - let e = Expr::Var("n") + Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "2 * n"); -} - -#[test] -fn test_canonical_subtract_to_zero() { - // n - n → 0 - let e = Expr::Var("n") - Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "0"); -} - -#[test] -fn test_canonical_mixed_addition() { - // n + n - m + 2*m → 2*n + m - let e = Expr::Var("n") + Expr::Var("n") - Expr::Var("m") + Expr::Const(2.0) * Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "m + 2 * n"); -} - -#[test] -fn test_canonical_exp_product_identity() { - // exp(n) * exp(m) -> exp(m + n) (transcendental identity, alphabetical order) - let e = Expr::Exp(Box::new(Expr::Var("n"))) * Expr::Exp(Box::new(Expr::Var("m"))); - let c = canonical_form(&e).unwrap(); - // Verify numerical equivalence - let size = crate::types::ProblemSize::new(vec![("n", 2), ("m", 3)]); - assert!((c.eval(&size) - (2.0_f64.exp() * 3.0_f64.exp())).abs() < 1e-6); -} - -#[test] -fn test_canonical_constant_base_exp_identity() { - // 2^n * 2^m -> 2^(m + n) - let e = - Expr::pow(Expr::Const(2.0), Expr::Var("n")) * Expr::pow(Expr::Const(2.0), Expr::Var("m")); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 4)]); - assert!((c.eval(&size) - 2.0_f64.powf(7.0)).abs() < 1e-6); -} - -#[test] -fn test_canonical_polynomial_expansion() { - // (n + m)^2 = n^2 + 2*n*m + m^2 - let e = Expr::pow(Expr::Var("n") + Expr::Var("m"), Expr::Const(2.0)); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 4)]); - assert_eq!(c.eval(&size), 49.0); // (3+4)^2 = 49 -} - -#[test] -fn test_canonical_signed_polynomial() { - // n^3 - n^2 + 2*n + 4*n*m — should remain exact - let e = Expr::pow(Expr::Var("n"), Expr::Const(3.0)) - - Expr::pow(Expr::Var("n"), Expr::Const(2.0)) - + Expr::Const(2.0) * Expr::Var("n") - + Expr::Const(4.0) * Expr::Var("n") * Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 2)]); - // 27 - 9 + 6 + 24 = 48 - assert_eq!(c.eval(&size), 48.0); -} - -#[test] -fn test_canonical_division_becomes_negative_exponent() { - // n / m should canonicalize; the division is represented as m^(-1) - // which becomes an opaque factor (negative exponent) - let e = Expr::Var("n") / Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 6), ("m", 3)]); - assert!((c.eval(&size) - 2.0).abs() < 1e-10); -} - -#[test] -fn test_canonical_distinct_fractional_exponents_do_not_merge() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(1.0004)) - Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_ne!(c.to_string(), "0"); - let size = crate::types::ProblemSize::new(vec![("n", 2)]); - assert_ne!(c.eval(&size), 0.0); -} - -#[test] -fn test_canonical_constant_base_one_folds_to_constant() { - let e = Expr::pow(Expr::Const(1.0), Expr::Var("n")); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "1"); -} - -#[test] -fn test_canonical_negative_constant_base_with_symbolic_exponent_is_rejected() { - let e = Expr::pow(Expr::Const(-2.0), Expr::Var("n")); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_zero_constant_base_with_symbolic_exponent_is_rejected() { - let e = Expr::pow(Expr::Const(0.0), Expr::Var("n")); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_deterministic_order() { - // m + n and n + m should produce the same canonical form - let a = canonical_form(&(Expr::Var("m") + Expr::Var("n"))).unwrap(); - let b = canonical_form(&(Expr::Var("n") + Expr::Var("m"))).unwrap(); - assert_eq!(a.to_string(), b.to_string()); -} - -#[test] -fn test_canonical_constant_folding() { - // 2 + 3 → 5 - let e = Expr::Const(2.0) + Expr::Const(3.0); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "5"); -} - -#[test] -fn test_canonical_sqrt_as_power() { - // sqrt(n) should canonicalize the same as n^0.5 - let a = canonical_form(&Expr::Sqrt(Box::new(Expr::Var("n")))).unwrap(); - let b = canonical_form(&Expr::pow(Expr::Var("n"), Expr::Const(0.5))).unwrap(); - assert_eq!(a.to_string(), b.to_string()); -} - -#[test] -fn test_canonical_nested_power_blowup_is_capped() { - // Regression for issue #1069: a "square of a square of a sum" structure — - // the shape composed-path overheads take when they traverse - // quadratic-overhead reductions — expands exponentially. Before the cap - // this OOM'd / hung indefinitely; now it must fail fast with Unsupported - // rather than try to materialize the blown-up monomial expansion. - let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); - // ((a+b+c+d)^4)^4 expands to >50_000 intermediate terms. - let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_moderate_power_still_expands() { - // The cap must not perturb legitimate, modestly-sized expressions: - // (a+b)^3 stays well under the cap and expands normally. - let e = Expr::pow(Expr::Var("a") + Expr::Var("b"), Expr::Const(3.0)); - let c = canonical_form(&e).unwrap(); - // a^3 + 3 a^2 b + 3 a b^2 + b^3 — compare against the same expansion - // written out flat (both go through canonical_form for identical ordering). - let expected = canonical_form(&Expr::parse("a^3 + 3*a^2*b + 3*a*b^2 + b^3")).unwrap(); - assert_eq!(c.to_string(), expected.to_string()); -} diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 43dc121f4..8094254af 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -265,7 +265,6 @@ fn test_find_rule_example_sat_to_kcoloring_contains_full_instances() { ); } -#[cfg(feature = "ilp-solver")] #[test] fn test_find_rule_example_integral_flow_bundles_to_ilp_contains_full_instances() { let source = ProblemRef { @@ -285,7 +284,6 @@ fn test_find_rule_example_integral_flow_bundles_to_ilp_contains_full_instances() assert!(!example.solutions[0].target_config.is_empty()); } -#[cfg(feature = "ilp-solver")] #[test] fn test_find_rule_example_threedimensionalmatching_to_ilp_contains_full_instances() { let source = ProblemRef { @@ -421,7 +419,7 @@ fn canonical_rule_examples_cover_exactly_authored_direct_reductions() { .into_iter() .filter(|entry| entry.source_name != entry.target_name) // Turing (multi-query) edges have no single-shot reduction to demonstrate - .filter(|entry| !entry.capabilities.turing) + .filter(|entry| !entry.turing) .map(|entry| { ( ProblemRef { @@ -499,13 +497,10 @@ fn model_specs_are_self_consistent() { } } -#[cfg(feature = "ilp-solver")] #[test] fn model_specs_are_optimal() { - use crate::registry::find_variant_entry; - use crate::solvers::ILPSolver; - - let ilp_solver = ILPSolver::new(); + use crate::registry::{find_variant_entry, load_dyn}; + use crate::solvers::{solve_deterministically, SolverRequest}; let specs = crate::models::graph::canonical_model_example_specs() .into_iter() @@ -520,13 +515,19 @@ fn model_specs_are_optimal() { // Try brute force first for small instances (fast, avoids expensive ILP chains) let dims = spec.instance.dims_dyn(); let log_space: f64 = dims.iter().map(|&d| (d as f64).log2()).sum(); + let solve_registered_ilp = || { + let loaded = load_dyn(name, &variant, spec.instance.serialize_json()).ok()?; + solve_deterministically(&loaded, SolverRequest::Ilp) + .ok()? + .config + }; let best_config = if log_space <= 20.0 { find_variant_entry(name, &variant) .and_then(|entry| (entry.solve_witness_fn)(spec.instance.as_any())) .map(|(config, _)| config) - .or_else(|| ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any())) + .or_else(solve_registered_ilp) } else { - ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any()) + solve_registered_ilp() }; if let Some(best_config) = best_config { @@ -583,31 +584,35 @@ fn rule_specs_solution_pairs_are_consistent() { ) .unwrap_or_else(|e| panic!("Failed to load target for {label}: {e}")); - // Try witness path first; fall back to aggregate for aggregate-only edges. - // Some authored direct reductions are proof-only and intentionally have - // no runtime capability in any mode. - let witness_path = graph.find_cheapest_path( - &example.source.problem, - &example.source.variant, - &example.target.problem, - &example.target.variant, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ); - if witness_path.is_none() { - let aggregate_path = graph.find_cheapest_path_mode( + // Inspect the authored direct reduction. Indirect paths between the same + // problem variants do not implement this rule's stored solution pairs. + let witness_path = graph + .find_all_paths( &example.source.problem, &example.source.variant, &example.target.problem, &example.target.variant, - crate::rules::ReductionMode::Aggregate, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ); - if aggregate_path.is_none() { + ) + .into_iter() + .find(|path| path.len() == 1); + if witness_path.is_none() { + let has_aggregate_path = graph + .find_all_paths_mode( + &example.source.problem, + &example.source.variant, + &example.target.problem, + &example.target.variant, + crate::rules::ReductionMode::Aggregate, + ) + .iter() + .any(|path| path.len() == 1); + if !has_aggregate_path { assert!( - graph.has_direct_reduction_by_name(&example.source.problem, &example.target.problem), - "No reduction path (witness or aggregate) or direct proof-only edge for {label}" + graph.has_direct_reduction_by_name( + &example.source.problem, + &example.target.problem + ), + "No direct witness, aggregate, or proof-only reduction for {label}" ); assert!( !graph.has_direct_reduction_by_name_mode( @@ -629,7 +634,9 @@ fn rule_specs_solution_pairs_are_consistent() { } // Only do witness round-trip when a witness path exists - let chain = witness_path.and_then(|path| graph.reduce_along_path(&path, source.as_any())); + let chain = witness_path + .as_ref() + .and_then(|path| graph.reduce_along_path(path, source.as_any())); for pair in &example.solutions { // Verify config lengths match problem dimensions @@ -678,7 +685,7 @@ fn rule_specs_solution_pairs_are_consistent() { // Round-trip: extract_solution(target_config) must produce a valid // source config with the same evaluation value (witness paths only) if let Some(ref chain) = chain { - let extracted = chain.extract_solution(&pair.target_config); + let extracted = chain.extract_solution(&pair.target_config).unwrap(); let extracted_val = source.evaluate_json(&extracted); assert_eq!( extracted_val, source_val, @@ -687,6 +694,29 @@ fn rule_specs_solution_pairs_are_consistent() { (extracted: {:?}, stored: {:?})", extracted_val, source_val, extracted, pair.source_config ); + + let mut wrong_length = pair.target_config.clone(); + if wrong_length.is_empty() { + wrong_length.push(0); + } else { + wrong_length.pop(); + } + assert!( + chain.extract_solution(&wrong_length).is_err(), + "Rule {label}: extraction accepted a target configuration with the wrong length" + ); + + let target_dims = target.dims_dyn(); + if let Some((&dimension, value)) = + target_dims.first().zip(pair.target_config.first()) + { + let mut out_of_domain = pair.target_config.clone(); + out_of_domain[0] = dimension; + assert!( + chain.extract_solution(&out_of_domain).is_err(), + "Rule {label}: extraction accepted out-of-domain value {dimension} in place of {value}" + ); + } } } } diff --git a/src/unit_tests/export.rs b/src/unit_tests/export.rs index c1c9a578f..5e0f2b227 100644 --- a/src/unit_tests/export.rs +++ b/src/unit_tests/export.rs @@ -22,24 +22,24 @@ fn test_variant_to_map_multiple() { } #[test] -fn test_lookup_overhead_known_reduction() { +fn test_lookup_size_contract_known_reduction() { // IS -> VC is a known registered reduction let source_variant = variant_to_map(vec![("graph", "SimpleGraph"), ("weight", "i32")]); let target_variant = variant_to_map(vec![("graph", "SimpleGraph"), ("weight", "i32")]); - let result = lookup_overhead( + let result = lookup_size_contract( "MaximumIndependentSet", &source_variant, "MinimumVertexCover", &target_variant, ); - assert!(result.is_some()); + assert!(result.unwrap().is_some()); } #[test] -fn test_lookup_overhead_unknown_reduction() { +fn test_lookup_size_contract_unknown_reduction() { let empty = variant_to_map(vec![]); - let result = lookup_overhead("NonExistent", &empty, "AlsoNonExistent", &empty); - assert!(result.is_none()); + let result = lookup_size_contract("NonExistent", &empty, "AlsoNonExistent", &empty); + assert!(result.unwrap().is_none()); } fn sample_example_db() -> ExampleDb { @@ -151,7 +151,7 @@ fn test_write_example_db_uses_one_line_per_example_entry() { } #[test] -fn rule_example_serialization_omits_overhead() { +fn rule_example_serialization_omits_reduction_metadata() { let example = RuleExample { source: ProblemSide { problem: "A".to_string(), @@ -298,10 +298,13 @@ fn write_model_example_to_creates_json_file() { } #[test] -fn lookup_overhead_rejects_target_variant_mismatch() { +fn lookup_size_contract_rejects_target_variant_mismatch() { let source = variant_to_map(vec![("graph", "SimpleGraph"), ("weight", "i32")]); // MIS -> QUBO exists, but not MIS -> QUBO let wrong_target = variant_to_map(vec![("weight", "i32")]); - let result = lookup_overhead("MaximumIndependentSet", &source, "QUBO", &wrong_target); - assert!(result.is_none(), "Should reject wrong target variant"); + let result = lookup_size_contract("MaximumIndependentSet", &source, "QUBO", &wrong_target); + assert!( + result.unwrap().is_none(), + "Should reject wrong target variant" + ); } diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index 037f39c8c..183db622c 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -1,144 +1,248 @@ use super::*; use crate::types::ProblemSize; -use std::collections::{HashMap, HashSet}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +fn eval(expression: &Expr, size: &ProblemSize) -> f64 { + evaluate_approximate(expression, size).unwrap() +} + +#[derive(Deserialize)] +struct SympyApproximateFixture { + approximate_cases: Vec, + factorial_domain_cases: Vec, +} + +#[derive(Deserialize)] +struct SympyApproximateCase { + name: String, + source: String, + bindings: BTreeMap, + decimal_result: String, + finite_f64: bool, +} + +#[derive(Deserialize)] +struct SympyFactorialDomainCase { + source: String, + exact_argument: String, + accepted: bool, + finite_f64: bool, +} + +#[test] +fn test_approximate_evaluation_against_sympy_fixture() { + let fixture: SympyApproximateFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.approximate_cases.len(), 12); + + for case in fixture.approximate_cases { + let expression = Expr::try_parse(&case.source) + .unwrap_or_else(|error| panic!("{} failed to parse: {error}", case.name)); + let size = ProblemSize::new( + case.bindings + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect(), + ); + let expected: f64 = case.decimal_result.parse().unwrap(); + let actual = evaluate_approximate(&expression, &size); + if case.finite_f64 { + let actual = + actual.unwrap_or_else(|error| panic!("{} failed to evaluate: {error}", case.name)); + let relative_error = (actual - expected).abs() / expected.abs().max(1.0); + assert!( + relative_error <= 1e-14, + "{} value: actual={actual}, expected={expected}, relative error={relative_error}", + case.name + ); + } else { + assert!( + matches!(actual, Err(ApproximationError::NonFiniteResult(_))), + "{} should report a non-finite approximation", + case.name + ); + } + } +} + +#[test] +fn test_factorial_domain_against_sympy_fixture() { + let fixture: SympyApproximateFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.factorial_domain_cases.len(), 8); + + for case in fixture.factorial_domain_cases { + let expression = Expr::try_parse(&format!("factorial({})", case.source)); + if case.accepted { + let expression = expression.unwrap_or_else(|error| { + panic!( + "valid factorial argument {} ({}) was rejected: {error}", + case.source, case.exact_argument + ) + }); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()).is_ok(), + case.finite_f64, + "factorial approximation {} ({})", + case.source, + case.exact_argument + ); + } else if let Ok(expression) = expression { + assert!( + evaluate_approximate(&expression, &ProblemSize::default()).is_err(), + "invalid factorial argument {} ({}) evaluated successfully", + case.source, + case.exact_argument + ); + } + } +} #[test] fn test_expr_const_eval() { - let e = Expr::Const(42.0); + let e = Expr::integer(42); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 42.0); + assert_eq!(eval(&e, &size), 42.0); } #[test] fn test_expr_var_eval() { - let e = Expr::Var("n"); + let e = Expr::variable("n"); let size = ProblemSize::new(vec![("n", 10)]); - assert_eq!(e.eval(&size), 10.0); + assert_eq!(eval(&e, &size), 10.0); } #[test] fn test_expr_add_eval() { // n + 3 - let e = Expr::Var("n") + Expr::Const(3.0); + let e = Expr::variable("n") + Expr::integer(3); let size = ProblemSize::new(vec![("n", 7)]); - assert_eq!(e.eval(&size), 10.0); + assert_eq!(eval(&e, &size), 10.0); } #[test] fn test_expr_mul_eval() { // 3 * n - let e = Expr::Const(3.0) * Expr::Var("n"); + let e = Expr::integer(3) * Expr::variable("n"); let size = ProblemSize::new(vec![("n", 5)]); - assert_eq!(e.eval(&size), 15.0); + assert_eq!(eval(&e, &size), 15.0); } #[test] fn test_expr_pow_eval() { // n^2 - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); let size = ProblemSize::new(vec![("n", 4)]); - assert_eq!(e.eval(&size), 16.0); + assert_eq!(eval(&e, &size), 16.0); } #[test] fn test_expr_exp_eval() { - let e = Expr::Exp(Box::new(Expr::Const(1.0))); + let e = Expr::exp(Expr::integer(1)); let size = ProblemSize::new(vec![]); - assert!((e.eval(&size) - std::f64::consts::E).abs() < 1e-10); + assert!((eval(&e, &size) - std::f64::consts::E).abs() < 1e-10); } #[test] fn test_expr_log_eval() { - let e = Expr::Log(Box::new(Expr::Const(std::f64::consts::E))); + let e = Expr::log(expression_from_approximation(std::f64::consts::E)); let size = ProblemSize::new(vec![]); - assert!((e.eval(&size) - 1.0).abs() < 1e-10); + assert!((eval(&e, &size) - 1.0).abs() < 1e-10); } #[test] fn test_expr_sqrt_eval() { - let e = Expr::Sqrt(Box::new(Expr::Const(9.0))); + let e = Expr::sqrt(Expr::integer(9)); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 3.0); + assert_eq!(eval(&e, &size), 3.0); } #[test] fn test_expr_complex() { // n^2 + 3*m - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); let size = ProblemSize::new(vec![("n", 4), ("m", 2)]); - assert_eq!(e.eval(&size), 22.0); // 16 + 6 + assert_eq!(eval(&e, &size), 22.0); // 16 + 6 } #[test] fn test_expr_variables() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); let vars = e.variables(); - assert_eq!(vars, HashSet::from(["n", "m"])); + assert_eq!(vars, BTreeSet::from(["n", "m"])); } #[test] fn test_expr_substitute() { // n^2, substitute n → (a + b) - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); - let replacement = Expr::Var("a") + Expr::Var("b"); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); + let replacement = Expr::variable("a") + Expr::variable("b"); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let result = e.substitute(&mapping); + let result = e.substitute_complete(&mapping).unwrap(); // Should be (a + b)^2 let size = ProblemSize::new(vec![("a", 3), ("b", 2)]); - assert_eq!(result.eval(&size), 25.0); // (3+2)^2 + assert_eq!(eval(&result, &size), 25.0); // (3+2)^2 } #[test] fn test_expr_display_simple() { - assert_eq!(format!("{}", Expr::Const(5.0)), "5"); - assert_eq!(format!("{}", Expr::Var("n")), "n"); + assert_eq!(format!("{}", Expr::integer(5)), "5"); + assert_eq!(format!("{}", Expr::variable("n")), "n"); } #[test] fn test_expr_display_add() { - let e = Expr::Var("n") + Expr::Const(3.0); - assert_eq!(format!("{e}"), "n + 3"); + let e = Expr::variable("n") + Expr::integer(3); + assert_eq!(format!("{e}"), "3 + n"); } #[test] fn test_expr_display_mul() { - let e = Expr::Const(3.0) * Expr::Var("n"); + let e = Expr::integer(3) * Expr::variable("n"); assert_eq!(format!("{e}"), "3 * n"); } #[test] fn test_expr_display_pow() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); assert_eq!(format!("{e}"), "n^2"); } #[test] fn test_expr_display_exp() { - let e = Expr::Exp(Box::new(Expr::Var("n"))); + let e = Expr::exp(Expr::variable("n")); assert_eq!(format!("{e}"), "exp(n)"); } #[test] fn test_expr_display_nested() { // n^2 + 3 * m - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); - assert_eq!(format!("{e}"), "n^2 + 3 * m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); + assert_eq!(format!("{e}"), "3 * m + n^2"); } #[test] fn test_expr_is_polynomial() { - assert!(Expr::Var("n").is_polynomial()); - assert!(Expr::pow(Expr::Var("n"), Expr::Const(2.0)).is_polynomial()); - assert!(!Expr::Exp(Box::new(Expr::Var("n"))).is_polynomial()); - assert!(!Expr::Log(Box::new(Expr::Var("n"))).is_polynomial()); - assert!(!Expr::Sqrt(Box::new(Expr::Var("n"))).is_polynomial()); + assert!(Expr::variable("n").is_polynomial()); + assert!(Expr::pow(Expr::variable("n"), Expr::integer(2)).is_polynomial()); + assert!(!Expr::exp(Expr::variable("n")).is_polynomial()); + assert!(!Expr::log(Expr::variable("n")).is_polynomial()); + assert!(!Expr::sqrt(Expr::variable("n")).is_polynomial()); } #[test] fn test_expr_is_valid_complexity_notation_simple() { - assert!(Expr::Var("n").is_valid_complexity_notation()); - assert!(Expr::pow(Expr::Var("n"), Expr::Const(2.0)).is_valid_complexity_notation()); + assert!(Expr::variable("n").is_valid_complexity_notation()); + assert!(Expr::pow(Expr::variable("n"), Expr::integer(2)).is_valid_complexity_notation()); assert!(Expr::parse("n + m").is_valid_complexity_notation()); assert!(Expr::parse("2^n").is_valid_complexity_notation()); assert!(Expr::parse("n^(1/3)").is_valid_complexity_notation()); @@ -158,227 +262,141 @@ fn test_expr_is_valid_complexity_notation_rejects_additive_constants() { assert!(!Expr::parse("n + 1").is_valid_complexity_notation()); assert!(!Expr::parse("log(n + 1)").is_valid_complexity_notation()); assert!(!Expr::parse("(n + 1)^2").is_valid_complexity_notation()); - assert!(!Expr::Const(5.0).is_valid_complexity_notation()); - assert!(Expr::Const(1.0).is_valid_complexity_notation()); + assert!(!Expr::integer(5).is_valid_complexity_notation()); + assert!(Expr::integer(1).is_valid_complexity_notation()); } #[test] fn test_expr_display_pow_with_complex_exponent() { - let expr = Expr::pow(Expr::Const(2.0), Expr::Var("m") + Expr::Var("n")); + let expr = Expr::pow(Expr::integer(2), Expr::variable("m") + Expr::variable("n")); assert_eq!(format!("{expr}"), "2^(m + n)"); } -#[test] -fn test_asymptotic_normal_form_drops_constant_factors() { - let expr = Expr::parse("3 * num_variables^2"); - let normalized = asymptotic_normal_form(&expr).unwrap(); - assert_eq!(normalized.to_string(), "num_variables^2"); -} - -#[test] -fn test_asymptotic_normal_form_drops_additive_constants() { - let expr = Expr::parse("num_variables + 1"); - let normalized = asymptotic_normal_form(&expr).unwrap(); - assert_eq!(normalized.to_string(), "num_variables"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_commutative_sum() { - let a = asymptotic_normal_form(&Expr::parse("n + m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("m + n")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "m + n"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_commutative_product() { - let a = asymptotic_normal_form(&Expr::parse("n * m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("m * n")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "m * n"); -} - -#[test] -fn test_asymptotic_normal_form_combines_repeated_factors() { - let normalized = asymptotic_normal_form(&Expr::parse("n * n^(1/2)")).unwrap(); - assert_eq!(normalized.to_string(), "n^1.5"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_exponential_product() { - let a = asymptotic_normal_form(&Expr::parse("exp(n) * exp(m)")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("exp(n + m)")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "exp(m + n)"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_constant_base_exponential_product() { - let a = asymptotic_normal_form(&Expr::parse("2^n * 2^m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("2^(n + m)")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "2^(m + n)"); -} - -#[test] -fn test_asymptotic_normal_form_sqrt_matches_fractional_power() { - let a = asymptotic_normal_form(&Expr::parse("sqrt(n * m)")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("(n * m)^(1/2)")).unwrap(); - assert_eq!(a, b); -} - -#[test] -fn test_asymptotic_normal_form_log_of_power() { - // log(n^2) = 2*log(n) — the new engine keeps log(n^2) which is O(log(n)) - let normalized = asymptotic_normal_form(&Expr::parse("log(n^2)")).unwrap(); - // Both log(n^2) and log(n) are asymptotically equivalent - let s = normalized.to_string(); - assert!(s.contains("log"), "expected log in result, got: {s}"); - assert!(s.contains("n"), "expected n in result, got: {s}"); -} - -#[test] -fn test_asymptotic_normal_form_substitution_is_closed() { - let notation = asymptotic_normal_form(&Expr::parse("n * m")).unwrap(); - let k = Expr::parse("k"); - let k_squared = Expr::parse("k^2"); - let mapping = HashMap::from([("n", &k), ("m", &k_squared)]); - let substituted = asymptotic_normal_form(¬ation.substitute(&mapping)).unwrap(); - assert_eq!(substituted.to_string(), "k^3"); -} - -#[test] -fn test_asymptotic_normal_form_handles_subtraction() { - // n - m: the -m term survives as a negative dominant term → unsupported - assert!(asymptotic_normal_form(&Expr::parse("n - m")).is_err()); - - // n^2 - n: -n is dominated by n^2 and eliminated → works - let result = asymptotic_normal_form(&Expr::parse("n^2 - n")).unwrap(); - assert_eq!(result.to_string(), "n^2"); -} - #[test] fn test_expr_display_fractional_constant() { - assert_eq!(format!("{}", Expr::Const(2.75)), "2.75"); - assert_eq!(format!("{}", Expr::Const(0.5)), "0.5"); + assert_eq!(format!("{}", Expr::rational(11, 4)), "2.75"); + assert_eq!(format!("{}", Expr::rational(1, 2)), "0.5"); } #[test] fn test_expr_display_log() { - let e = Expr::Log(Box::new(Expr::Var("n"))); + let e = Expr::log(Expr::variable("n")); assert_eq!(format!("{e}"), "log(n)"); } #[test] fn test_expr_display_sqrt() { - let e = Expr::Sqrt(Box::new(Expr::Var("n"))); - assert_eq!(format!("{e}"), "sqrt(n)"); + let e = Expr::sqrt(Expr::variable("n")); + assert_eq!(format!("{e}"), "n^0.5"); } #[test] -fn test_expr_display_pow_half_as_sqrt() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(0.5)); - assert_eq!(format!("{e}"), "sqrt(n)"); +fn test_expr_display_preserves_half_power() { + let e = Expr::pow(Expr::variable("n"), Expr::rational(1, 2)); + assert_eq!(format!("{e}"), "n^0.5"); } #[test] -fn test_expr_display_pow_half_complex_base() { - let e = Expr::pow(Expr::Var("n") * Expr::Var("m"), Expr::Const(0.5)); - assert_eq!(format!("{e}"), "sqrt(n * m)"); +fn test_expr_display_preserves_half_power_with_complex_base() { + let e = Expr::pow( + Expr::variable("n") * Expr::variable("m"), + Expr::rational(1, 2), + ); + assert_eq!(format!("{e}"), "(m * n)^0.5"); } #[test] -fn test_expr_display_pow_half_in_exponent() { - // 2^(n^0.5) should display as 2^sqrt(n), NOT 2^n^0.5 +fn test_expr_display_preserves_nested_half_power() { let e = Expr::pow( - Expr::Const(2.0), - Expr::pow(Expr::Var("n"), Expr::Const(0.5)), + Expr::integer(2), + Expr::pow(Expr::variable("n"), Expr::rational(1, 2)), ); - let s = format!("{e}"); - assert!(s.contains("sqrt"), "expected sqrt notation, got: {s}"); - assert!(!s.contains("0.5"), "should not contain raw 0.5, got: {s}"); + assert_eq!(format!("{e}"), "2^n^0.5"); } #[test] fn test_expr_display_mul_with_add_parenthesization() { - // (a + b) * c should parenthesize the left side - let e = (Expr::Var("a") + Expr::Var("b")) * Expr::Var("c"); - assert_eq!(format!("{e}"), "(a + b) * c"); + // Operand order is canonical, independent of construction order. + let e = (Expr::variable("a") + Expr::variable("b")) * Expr::variable("c"); + assert_eq!(format!("{e}"), "c * (a + b)"); // c * (a + b) should parenthesize the right side - let e = Expr::Var("c") * (Expr::Var("a") + Expr::Var("b")); + let e = Expr::variable("c") * (Expr::variable("a") + Expr::variable("b")); assert_eq!(format!("{e}"), "c * (a + b)"); // (a + b) * (c + d) should parenthesize both sides - let e = (Expr::Var("a") + Expr::Var("b")) * (Expr::Var("c") + Expr::Var("d")); + let e = + (Expr::variable("a") + Expr::variable("b")) * (Expr::variable("c") + Expr::variable("d")); assert_eq!(format!("{e}"), "(a + b) * (c + d)"); } #[test] fn test_expr_display_pow_with_complex_base() { // (a + b)^2 - let e = Expr::pow(Expr::Var("a") + Expr::Var("b"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("a") + Expr::variable("b"), Expr::integer(2)); assert_eq!(format!("{e}"), "(a + b)^2"); // (a * b)^2 - let e = Expr::pow(Expr::Var("a") * Expr::Var("b"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("a") * Expr::variable("b"), Expr::integer(2)); assert_eq!(format!("{e}"), "(a * b)^2"); } #[test] fn test_expr_eval_missing_variable() { - // Missing variable should default to 0 - let e = Expr::Var("missing"); + let e = Expr::variable("missing"); let size = ProblemSize::new(vec![("other", 5)]); - assert_eq!(e.eval(&size), 0.0); + assert_eq!( + evaluate_approximate(&e, &size), + Err(ApproximationError::MissingVariable("missing".to_string())) + ); } #[test] fn test_expr_scale() { - let e = Expr::Var("n").scale(3.0); + let e = Expr::integer(3) * Expr::variable("n"); let size = ProblemSize::new(vec![("n", 5)]); - assert_eq!(e.eval(&size), 15.0); + assert_eq!(eval(&e, &size), 15.0); } #[test] fn test_expr_ops_add_trait() { - let a = Expr::Var("a"); - let b = Expr::Var("b"); + let a = Expr::variable("a"); + let b = Expr::variable("b"); let e = a + b; // uses std::ops::Add let size = ProblemSize::new(vec![("a", 3), ("b", 4)]); - assert_eq!(e.eval(&size), 7.0); + assert_eq!(eval(&e, &size), 7.0); } #[test] fn test_expr_substitute_exp_log_sqrt() { - let replacement = Expr::Const(2.0); + let replacement = Expr::integer(2); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Exp(Box::new(Expr::Var("n"))); - let result = e.substitute(&mapping); + let e = Expr::exp(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); let size = ProblemSize::new(vec![]); - assert!((result.eval(&size) - 2.0_f64.exp()).abs() < 1e-10); + assert!((eval(&result, &size) - 2.0_f64.exp()).abs() < 1e-10); - let e = Expr::Log(Box::new(Expr::Var("n"))); - let result = e.substitute(&mapping); - assert!((result.eval(&size) - 2.0_f64.ln()).abs() < 1e-10); + let e = Expr::log(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); + assert!((eval(&result, &size) - 2.0_f64.ln()).abs() < 1e-10); - let e = Expr::Sqrt(Box::new(Expr::Var("n"))); - let result = e.substitute(&mapping); - assert!((result.eval(&size) - 2.0_f64.sqrt()).abs() < 1e-10); + let e = Expr::sqrt(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); + assert!((eval(&result, &size) - 2.0_f64.sqrt()).abs() < 1e-10); } #[test] fn test_expr_variables_exp_log_sqrt() { - let e = Expr::Exp(Box::new(Expr::Var("a"))); - assert_eq!(e.variables(), HashSet::from(["a"])); + let e = Expr::exp(Expr::variable("a")); + assert_eq!(e.variables(), BTreeSet::from(["a"])); - let e = Expr::Log(Box::new(Expr::Var("b"))); - assert_eq!(e.variables(), HashSet::from(["b"])); + let e = Expr::log(Expr::variable("b")); + assert_eq!(e.variables(), BTreeSet::from(["b"])); - let e = Expr::Sqrt(Box::new(Expr::Var("c"))); - assert_eq!(e.variables(), HashSet::from(["c"])); + let e = Expr::sqrt(Expr::variable("c")); + assert_eq!(e.variables(), BTreeSet::from(["c"])); } // --- Runtime parser tests (Expr::parse / parse_to_expr) --- @@ -387,7 +405,7 @@ fn test_expr_variables_exp_log_sqrt() { fn parse_eval(input: &str, vars: &[(&str, usize)]) -> f64 { let expr = Expr::parse(input); let size = ProblemSize::new(vars.to_vec()); - expr.eval(&size) + eval(&expr, &size) } /// Like parse_eval but accepts f64 variable values for testing transcendental functions. @@ -396,11 +414,17 @@ fn parse_eval_f64(input: &str, vars: &[(&str, f64)]) -> f64 { // Build a ProblemSize-compatible evaluation by using substitute + eval // Since ProblemSize only stores usize, we substitute variables with Const nodes. let mut mapping = std::collections::HashMap::new(); - let exprs: Vec = vars.iter().map(|(_, v)| Expr::Const(*v)).collect(); + let exprs: Vec = vars + .iter() + .map(|(_, value)| expression_from_approximation(*value)) + .collect(); for ((name, _), expr) in vars.iter().zip(exprs.iter()) { mapping.insert(*name, expr); } - expr.substitute(&mapping).eval(&ProblemSize::new(vec![])) + eval( + &expr.substitute_complete(&mapping).unwrap(), + &ProblemSize::new(vec![]), + ) } // -- Tokenizer coverage -- @@ -433,12 +457,12 @@ fn test_parse_whitespace_handling() { #[test] fn test_parse_tokenize_invalid_char() { - assert!(parse_to_expr("n @ m").is_err()); + assert!(Expr::try_parse("n @ m").is_err()); } #[test] fn test_parse_tokenize_invalid_number() { - assert!(parse_to_expr("1.2.3").is_err()); + assert!(Expr::try_parse("1.2.3").is_err()); } // -- Additive: +, - -- @@ -552,9 +576,9 @@ fn test_parse_sqrt() { #[test] fn test_parse_unknown_function() { - assert!(parse_to_expr("foo(3)").is_err()); - let err = parse_to_expr("foo(3)").unwrap_err(); - assert!(err.contains("unknown function"), "got: {err}"); + assert!(Expr::try_parse("foo(3)").is_err()); + let err = Expr::try_parse("foo(3)").unwrap_err(); + assert!(err.to_string().contains("unknown function"), "got: {err}"); } #[test] @@ -608,32 +632,38 @@ fn test_parse_precedence_unary_pow() { #[test] fn test_parse_trailing_tokens_error() { - let err = parse_to_expr("n m").unwrap_err(); - assert!(err.contains("trailing"), "got: {err}"); + let err = Expr::try_parse("n m").unwrap_err(); + assert!(err.to_string().contains("trailing"), "got: {err}"); } #[test] fn test_parse_unexpected_token_error() { - let err = parse_to_expr(")").unwrap_err(); - assert!(err.contains("unexpected token"), "got: {err}"); + let err = Expr::try_parse(")").unwrap_err(); + assert!( + err.to_string().contains("expected expression"), + "got: {err}" + ); } #[test] fn test_parse_empty_input_error() { - let err = parse_to_expr("").unwrap_err(); - assert!(err.contains("end of input"), "got: {err}"); + let err = Expr::try_parse("").unwrap_err(); + assert!( + err.to_string().contains("expected expression"), + "got: {err}" + ); } #[test] fn test_parse_unclosed_paren_error() { - let err = parse_to_expr("(n + m").unwrap_err(); - assert!(err.contains("expected"), "got: {err}"); + let err = Expr::try_parse("(n + m").unwrap_err(); + assert!(err.to_string().contains("expected"), "got: {err}"); } #[test] fn test_parse_unclosed_function_error() { - let err = parse_to_expr("exp(n").unwrap_err(); - assert!(err.contains("expected"), "got: {err}"); + let err = Expr::try_parse("exp(n").unwrap_err(); + assert!(err.to_string().contains("expected"), "got: {err}"); } #[test] @@ -641,9 +671,9 @@ fn test_parse_expect_mismatch() { // "exp(n]" — expects RParen, gets unexpected token ']' // Actually ']' is an invalid char so tokenizer catches it first. // Use "exp(n +" to trigger expect mismatch (expects RParen, gets Plus). - let err = parse_to_expr("exp(n +").unwrap_err(); + let err = Expr::try_parse("exp(n +").unwrap_err(); assert!( - err.contains("expected") || err.contains("end of input"), + err.to_string().contains("expected") || err.to_string().contains("end of input"), "got: {err}" ); } @@ -670,37 +700,88 @@ fn test_parse_factorial_variable() { #[test] fn test_expr_factorial_eval() { - let e = Expr::Factorial(Box::new(Expr::Const(4.0))); + let e = Expr::factorial(Expr::integer(4)); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 24.0); + assert_eq!(eval(&e, &size), 24.0); +} + +#[test] +fn test_expr_factorial_above_f64_range_is_explicit_error() { + let expression = Expr::factorial(Expr::integer(171)); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult( + "factorial(171)".to_string() + )) + ); +} + +#[test] +fn test_expr_factorial_rejects_non_integer_and_negative_arguments() { + for (expression, argument) in [ + (Expr::factorial(Expr::rational(7, 2)), "3.5"), + (Expr::factorial(Expr::integer(-1)), "-1"), + ] { + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::InvalidFactorialArgument( + argument.to_string() + )) + ); + } +} + +#[test] +fn test_non_finite_approximations_are_explicit_errors() { + for (expression, rendered) in [ + (Expr::pow(Expr::integer(0), Expr::integer(-1)), "0^-1"), + (Expr::log(Expr::integer(0)), "log(0)"), + (Expr::exp(Expr::integer(1000)), "exp(1000)"), + ] { + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult(rendered.to_string())) + ); + } +} + +#[test] +fn test_zero_does_not_hide_an_undefined_factor() { + let undefined = Expr::pow(Expr::integer(0), Expr::integer(-1)); + let expression = Expr::integer(0) * undefined; + assert_eq!(expression.to_string(), "0 * 0^-1"); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult("0^-1".to_string())) + ); } #[test] fn test_expr_factorial_display() { - let e = Expr::Factorial(Box::new(Expr::Var("n"))); + let e = Expr::factorial(Expr::variable("n")); assert_eq!(format!("{e}"), "factorial(n)"); } #[test] fn test_expr_factorial_variables() { - let e = Expr::Factorial(Box::new(Expr::Var("n"))); - assert_eq!(e.variables(), HashSet::from(["n"])); + let e = Expr::factorial(Expr::variable("n")); + assert_eq!(e.variables(), BTreeSet::from(["n"])); } #[test] fn test_expr_factorial_substitute() { - let replacement = Expr::Const(5.0); + let replacement = Expr::integer(5); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Factorial(Box::new(Expr::Var("n"))); - let result = e.substitute(&mapping); + let e = Expr::factorial(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); let size = ProblemSize::new(vec![]); - assert_eq!(result.eval(&size), 120.0); + assert_eq!(eval(&result, &size), 120.0); } #[test] fn test_expr_factorial_is_not_polynomial() { - assert!(!Expr::Factorial(Box::new(Expr::Var("n"))).is_polynomial()); + assert!(!Expr::factorial(Expr::variable("n")).is_polynomial()); } #[test] @@ -788,3 +869,36 @@ fn test_parse_real_complexity_bmf() { // 2^(3*2 + 2*4) = 2^(6+8) = 2^14 = 16384 assert_eq!(val, 16384.0); } + +#[test] +fn algebraic_analysis_preserves_exact_complexity_facts() { + let expression = Expr::parse("2^(0.7905 * n)"); + let analysis = AlgebraicAnalysis::new(&[&expression]); + let ExprNode::Pow(base, exponent) = expression.node() else { + panic!("expected power expression"); + }; + + assert_eq!( + analysis.facts(base).exact_rational, + Some(BigRational::from_integer(2.into())) + ); + assert_eq!( + analysis.facts(exponent).linear, + Some(BTreeMap::from([( + Symbol::new("n").unwrap(), + BigRational::new(1581.into(), 2000.into()), + )])) + ); +} + +#[test] +fn algebraic_analysis_separates_value_from_domain() { + let valid = Expr::parse("3^8"); + let invalid = Expr::sqrt(Expr::integer(-1)); + let analysis = AlgebraicAnalysis::new(&[&valid, &invalid]); + + assert!(analysis.facts(&valid).is_constant); + assert_eq!(analysis.facts(&valid).exact_rational, None); + assert_eq!(analysis.facts(&valid).constant_domain, Some(true)); + assert_eq!(analysis.facts(&invalid).constant_domain, Some(false)); +} diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs new file mode 100644 index 000000000..a03584708 --- /dev/null +++ b/src/unit_tests/growth.rs @@ -0,0 +1,1131 @@ +//! Unit tests for the symbolic growth domain (`src/growth.rs`). + +use super::{ + add, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthFailure, GrowthTerm, +}; +use crate::expr::{ + evaluate_approximate, expression_from_approximation, AlgebraicAnalysis, Expr, ExprNode, +}; +use crate::registry::variant_entries; +use num_rational::BigRational; +use num_traits::{FromPrimitive, One, Signed, Zero}; +use serde::Deserialize; +use std::cmp::Ordering; + +fn rat(value: f64) -> BigRational { + BigRational::from_f64(value).unwrap() +} + +/// Build a term from `(exp, poly, logs)` entry lists. +fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> GrowthTerm { + GrowthTerm { + exp: exp + .iter() + .map(|(variable, rate)| { + ( + (*variable).into(), + ExpProduct::single(ExpBase::Constant(Expr::integer(2)), rat(*rate)), + ) + }) + .collect(), + poly: poly + .iter() + .map(|(variable, degree)| ((*variable).into(), rat(*degree))) + .collect(), + logs: logs + .iter() + .map(|(variable, power)| ((*variable).into(), *power)) + .collect(), + } +} + +fn terms_of(g: &Growth) -> &[GrowthTerm] { + match g { + Growth::Terms(t) => t, + Growth::Unknown(failures) => panic!("expected Terms, got {failures:?}"), + } +} + +fn g(s: &str) -> Growth { + Growth::from_expr(&Expr::parse(s)) +} + +#[derive(Deserialize)] +struct SympyGrowthFixture { + growth_cases: Vec, +} + +#[derive(Deserialize)] +struct SympyGrowthCase { + name: String, + left: String, + right: String, + ratio_limit: String, + relation: SympyGrowthRelation, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum SympyGrowthRelation { + Equivalent, + LeftDominates, + RightDominates, +} + +#[test] +fn test_growth_relations_against_sympy_limits() { + let fixture: SympyGrowthFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.growth_cases.len(), 14); + + for case in fixture.growth_cases { + let left = g(&case.left); + let right = g(&case.right); + let actual = (left.dominates(&right), right.dominates(&left)); + let expected = match case.relation { + SympyGrowthRelation::Equivalent => (true, true), + SympyGrowthRelation::LeftDominates => (true, false), + SympyGrowthRelation::RightDominates => (false, true), + }; + assert_eq!( + actual, expected, + "{} with SymPy ratio limit {}", + case.name, case.ratio_limit + ); + } +} + +fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { + ExpProduct::new( + factors + .iter() + .map(|(base, coefficient)| ExpFactor { + base: ExpBase::Constant(Expr::constant(rat(*base))), + coefficient: rat(*coefficient), + }) + .collect(), + ) +} + +// --- Core verification cases --- + +/// 1. No-expansion regression: the nested sum-of-squares shape that OOM'd in +/// the old implementation is handled without expansion, quickly, with few terms. +#[test] +fn test_growth_no_expansion_regression() { + let e = Expr::parse("(12*(n + 3*m) + 5)^2 * (12*(n + 3*m) + 5)^2"); + let start = std::time::Instant::now(); + let result = Growth::from_expr(&e); + let elapsed = start.elapsed(); + + let ts = terms_of(&result); + assert!( + ts.contains(&term(&[], &[("n", 4.0)], &[])), + "expected n^4 in {ts:?}" + ); + assert!( + ts.contains(&term(&[], &[("m", 4.0)], &[])), + "expected m^4 in {ts:?}" + ); + assert!(ts.len() <= 6, "expected <= 6 terms, got {}", ts.len()); + assert!(elapsed.as_millis() < 10, "from_expr took {elapsed:?}"); +} + +/// 2. Dominance beats the old sampling heuristic: `1.001^n` dominates `n^100` +/// (any positive exponential rate outranks any polynomial degree). +#[test] +fn test_growth_exponential_dominates_polynomial() { + let exp = g("1.001^n"); + let poly = g("n^100"); + assert!(exp.dominates(&poly)); + assert!(!poly.dominates(&exp)); +} + +/// 3. Incomparability is honest: neither `n^2` nor `n*m` dominates the other, +/// and both are kept in the sum. +#[test] +fn test_growth_incomparable_terms_both_kept() { + let n2 = g("n^2"); + let nm = g("n*m"); + assert!(!n2.dominates(&nm)); + assert!(!nm.dominates(&n2)); + + let sum = g("n^2 + n*m"); + assert_eq!(terms_of(&sum).len(), 2); +} + +/// 4. Exponent rates are exact: `2^(2n)` dominates `2^n` (not conversely), and +/// `3^n` dominates `2^n` via direct symbolic base comparison. +#[test] +fn test_growth_exponent_rates_exact() { + let two_2n = g("2^(2*n)"); + let two_n = g("2^n"); + assert!(two_2n.dominates(&two_n)); + assert!(!two_n.dominates(&two_2n)); + + let three_n = g("3^n"); + assert!(three_n.dominates(&two_n)); + assert!(!two_n.dominates(&three_n)); + + let exp_2n = g("exp(2*n)"); + let exp_n = g("exp(n)"); + assert!(exp_2n.dominates(&exp_n)); + assert!(!exp_n.dominates(&exp_2n)); + + assert!(g("0.5^(-2*n)").dominates(&g("0.5^(-n)"))); + assert!(g("0.25^(-n)").dominates(&g("0.5^(-n)"))); +} + +#[test] +fn test_growth_exact_coefficients_do_not_cross_boundaries() { + let polynomial = g("n^1000"); + assert!(g("2^(n/9007199254740992)").dominates(&polynomial)); + assert!(g("(9007199254740993/9007199254740992)^n").dominates(&polynomial)); + + let unit_rate = g("2^n"); + let larger_rate = g("2^(9007199254740993*n/9007199254740992)"); + assert!(larger_rate.dominates(&unit_rate)); + assert!(!unit_rate.dominates(&larger_rate)); +} + +#[test] +fn test_registered_complexity_shapes_round_trip_exactly() { + for source in [ + "1.1996^n", + "2^(0.7905*n)", + "3^(n/3)", + "3^k*n + 2^k*n^2", + "n^3", + ] { + let growth = g(source); + let rendered = growth.to_expr().expect("registered shape is supported"); + assert_eq!(Growth::from_expr(&rendered), growth, "source: {source}"); + } +} + +#[test] +fn test_every_registered_complexity_uses_the_shared_analysis() { + for entry in variant_entries() { + let expression = Expr::parse(entry.complexity); + let growth = Growth::from_expr(&expression); + if let Some(rendered) = growth.to_expr() { + assert_eq!( + Growth::from_expr(&rendered), + growth, + "{}: {}", + entry.name, + entry.complexity + ); + } + } +} + +/// Multi-base products remain incomparable when the conservative symbolic +/// rules cannot prove an ordering, even when a stronger algebra system could. +#[test] +fn test_growth_unproved_multi_base_comparison_is_retained() { + let left = g("2^(2*n) * 3^n"); + let right = g("2^n * 4^n"); + assert!(!left.dominates(&right)); + assert!(!right.dominates(&left)); + assert_eq!(terms_of(&g("2^(2*n) * 3^n + 2^n * 4^n")).len(), 2); +} + +#[test] +fn test_exponential_product_proof_rules() { + let empty = ExpProduct::empty(); + let two = exp_product(&[(2.0, 1.0)]); + let two_squared = exp_product(&[(2.0, 2.0)]); + let three = exp_product(&[(3.0, 1.0)]); + + assert_eq!(empty.cmp_proven(&empty), Some(Ordering::Equal)); + assert_eq!(empty.cmp_proven(&two), Some(Ordering::Less)); + assert_eq!(two.cmp_proven(&empty), Some(Ordering::Greater)); + assert_eq!(two_squared.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&two_squared), Some(Ordering::Less)); + assert_eq!(three.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&three), Some(Ordering::Less)); + + assert_eq!( + exp_product(&[(3.0, 2.0)]).cmp_proven(&exp_product(&[(2.0, 1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(2.0, 1.0)]).cmp_proven(&exp_product(&[(3.0, 2.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(2.0, 3.0)]).cmp_proven(&exp_product(&[(3.0, 1.0)])), + None + ); + + assert_eq!( + exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -1.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(0.25, -2.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -2.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -2.0)])), + None + ); + assert_eq!(two.cmp_proven(&exp_product(&[(0.5, -1.0)])), None); + + let natural = ExpProduct::single(ExpBase::Natural, BigRational::one()); + assert_eq!(natural.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&natural), Some(Ordering::Less)); + + // Constant subtrees normalize before growth comparison. + let composite = ExpProduct::single(ExpBase::Constant(Expr::parse("1 + 2")), BigRational::one()); + assert_eq!(composite.cmp_proven(&three), Some(Ordering::Equal)); + + // Two residual products with no factorwise proof remain incomparable. + assert_eq!( + exp_product(&[(2.0, 2.0), (3.0, 1.0)]).cmp_proven(&exp_product(&[(2.0, 1.0), (4.0, 1.0)])), + None + ); +} + +#[test] +fn test_exponential_product_canonicalization() { + let combined = ExpProduct::new(vec![ + ExpFactor { + base: ExpBase::Constant(Expr::integer(2)), + coefficient: BigRational::one(), + }, + ExpFactor { + base: ExpBase::Constant(Expr::integer(2)), + coefficient: rat(2.0), + }, + ExpFactor { + base: ExpBase::Constant(Expr::integer(3)), + coefficient: BigRational::zero(), + }, + ]); + assert_eq!(combined, exp_product(&[(2.0, 3.0)])); + + let cancelled = ExpProduct::new(vec![ + ExpFactor { + base: ExpBase::Constant(Expr::integer(2)), + coefficient: BigRational::one(), + }, + ExpFactor { + base: ExpBase::Constant(Expr::integer(2)), + coefficient: -BigRational::one(), + }, + ]); + assert!(cancelled.is_empty()); +} + +#[test] +fn test_growth_multi_base_product_is_deterministic() { + let left = g("2^n * 3^n"); + let right = g("3^n * 2^n"); + assert_eq!(left, right); + assert_eq!( + serde_json::to_string(&left).unwrap(), + serde_json::to_string(&right).unwrap() + ); +} + +#[test] +fn test_proven_equal_exponential_spelling_is_deterministic() { + let natural_first = g("exp(n) + 2.718281828459045^n"); + let literal_first = g("2.718281828459045^n + exp(n)"); + assert_eq!(natural_first, literal_first); + assert_eq!(natural_first.to_big_o(), literal_first.to_big_o()); +} + +/// 5. Widening: subtraction widens to addition, including the `sqrt((a-b)^2)` +/// absolute-value idiom. +#[test] +fn test_growth_widening() { + assert_eq!(g("n - m"), g("n + m")); + assert_eq!(g("sqrt((n - m)^2)"), g("n + m")); +} + +/// 6. Determinism: the antichain is canonically sorted, so structurally +/// equivalent inputs are equal regardless of term order. +#[test] +fn test_growth_determinism() { + assert_eq!(g("n*m + m*n"), g("m*n + n*m")); +} + +// --- Negative control --- + +/// Unsupported content widens to `Unknown`, and `Unknown` absorbs through add +/// and mul — unsupported content can never silently produce a fake bound. +#[test] +fn test_growth_unknown_negative_control() { + assert_eq!( + g("2^(n*k)").failures(), + Some([GrowthFailure::NonlinearExponent("k * n".to_string())].as_slice()) + ); + assert!(matches!( + g("factorial(n)").failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + assert!(matches!( + Growth::from_expr(&Expr::factorial(Expr::rational(7, 2))).failures(), + Some([GrowthFailure::InvalidConstantDomain { .. }]) + )); + assert!(matches!( + Growth::from_expr(&Expr::factorial(Expr::integer(-1))).failures(), + Some([GrowthFailure::InvalidConstantDomain { .. }]) + )); + assert_eq!( + g("factorial(n) + 2^(n*k)").failures(), + Some( + [ + GrowthFailure::NonlinearExponent("k * n".to_string()), + GrowthFailure::FactorialOfNonconstant("factorial(n)".to_string()), + ] + .as_slice() + ) + ); + + // Absorption through the real `from_expr` add/mul paths. + let factorial_failure = g("factorial(n)"); + assert_eq!(g("factorial(n) + n^2"), factorial_failure); + assert_eq!(g("n^2 + factorial(n)"), factorial_failure); + assert_eq!(g("factorial(n) * n^2"), factorial_failure); + assert_eq!(g("n^2 * factorial(n)"), factorial_failure); + + // Absorption at the operation level too. + let n2 = g("n^2"); + assert_eq!( + add(factorial_failure.clone(), n2.clone()), + factorial_failure + ); + assert_eq!( + add(n2.clone(), factorial_failure.clone()), + factorial_failure + ); + assert_eq!( + mul(factorial_failure.clone(), n2.clone()), + factorial_failure + ); + assert_eq!(mul(n2, factorial_failure.clone()), factorial_failure); +} + +#[test] +fn test_growth_reports_nested_and_numeric_failures() { + let huge_constant = Expr::parse(&format!("1{}", "0".repeat(400))); + assert_eq!(Growth::from_expr(&huge_constant), g("1")); + + let unsupported = Expr::factorial(Expr::variable("n")); + assert!(matches!( + Growth::from_expr(&Expr::exp(unsupported.clone())).failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + assert!(matches!( + Growth::from_expr(&Expr::factorial(unsupported)).failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + + assert_eq!(Growth::from_expr(&Expr::variable("n")).failures(), None); + assert_eq!(Growth::Terms(Vec::new()).to_expr(), Some(Expr::integer(1))); +} + +#[test] +fn test_growth_rejects_invalid_internal_terms_explicitly() { + let mut invalid = GrowthTerm::one(); + invalid.poly.insert("n".into(), -BigRational::one()); + assert_eq!( + make_growth(vec![invalid]).failures(), + Some([GrowthFailure::InvalidGrowthTerm].as_slice()) + ); +} + +#[test] +fn test_exponential_base_deserialization_reports_invalid_constant_domain() { + let invalid = serde_json::json!({ + "Constant": serde_json::to_value(Expr::log(Expr::integer(0))).unwrap() + }); + let error = serde_json::from_value::(invalid).unwrap_err(); + assert!(error.to_string().contains("positive rational constant")); +} + +// --- Additional coverage --- + +/// Pure constants, constant factors, and constant division are all O(1) / dropped. +#[test] +fn test_growth_constants_are_o1() { + let c = g("42"); + assert_eq!(terms_of(&c), [GrowthTerm::one()]); + + // A wholly constant subtree (including `2^3`, `factorial(3)`, `1/2`) is O(1). + assert_eq!(g("2^3"), c); + assert_eq!(g("factorial(3)"), c); + + // Constant multiplier and constant divisor drop out. + assert_eq!(g("3 * n"), g("n")); + assert_eq!(g("n / 2"), g("n")); +} + +/// `x^0` is O(1); a negative exponent on a variable base is not admitted. +#[test] +fn test_growth_pow_special_cases() { + assert_eq!(terms_of(&g("n^0")), [GrowthTerm::one()]); + assert!(matches!( + g("n^(-1)").failures(), + Some([GrowthFailure::NegativeExponent(_)]) + )); + // Variable base with variable exponent is not representable. + assert!(matches!( + g("n^m").failures(), + Some([GrowthFailure::VariableBaseAndExponent(_)]) + )); +} + +/// Canonical Big-O rendering: bounded classes get `O()`, `Unknown` gets `O(?)`. +#[test] +fn test_growth_to_big_o() { + // The dominated `n` summand is dropped by the antichain, leaving just `n^2`. + assert_eq!(g("n^2 + n").to_big_o(), "O(n^2)"); + assert_eq!(g("2^n").to_big_o(), "O(2^n)"); + assert_eq!(g("5").to_big_o(), "O(1)"); + assert_eq!(g("factorial(n)").to_big_o(), "O(?)"); + // Renders exactly `O()` for bounded classes. + let bounded = g("n * m"); + assert_eq!( + bounded.to_big_o(), + format!("O({})", bounded.to_expr().unwrap()) + ); +} + +/// Exponential bases are authoritative symbolic data, not values reconstructed +/// from a rounded base-2 logarithm. +#[test] +fn test_growth_preserves_exponential_base() { + assert_eq!(g("3^n").to_big_o(), "O(3^n)"); + assert_eq!(g("1.0000000001^n").to_big_o(), "O(1.0000000001^n)"); + assert_eq!(g("2.7182818289^n").to_big_o(), "O(2.7182818289^n)"); + assert_eq!(g("2^(n / 2)").to_big_o(), "O(2^(0.5 * n))"); +} + +#[test] +fn test_growth_exponential_roundtrip_is_exact() { + for source in [ + "3^n", + "2^(n / 2)", + "exp(2 * n)", + "2^n * 3^n", + "3^n * n^2 * log(n)", + ] { + let growth = g(source); + let rendered = growth.to_expr().expect("growth should be representable"); + assert_eq!( + Growth::from_expr(&rendered), + growth, + "exponential growth changed while round-tripping {source} via {rendered}" + ); + } +} + +/// `exp(n)` uses base e; unit bases are constant, while decaying directions +/// remain explicit analysis failures rather than silently widening to O(1). +#[test] +fn test_growth_exponential_variants() { + // exp(n) is represented directly as e^n: exponential, dominates any polynomial. + let en = g("exp(n)"); + assert!(en.dominates(&g("n^5"))); + assert!(matches!( + g("2^(n - m)").failures(), + Some([GrowthFailure::DecayingExponential { variable, .. }]) if variable == "m" + )); + // Unit base is exactly O(1). + assert_eq!(g("1^n"), g("7")); + assert!(matches!( + g("0.5^n").failures(), + Some([GrowthFailure::DecayingExponential { + variable, + coefficient, + .. + }]) if variable == "n" && coefficient == "1" + )); + // A fractional base with a negative exponent grows and retains that exact + // symbolic base instead of being translated through a common logarithm. + assert_eq!(g("0.5^(-n)").to_big_o(), "O(0.5^(-1 * n))"); + assert!(g("0.5^(-n)").dominates(&g("n^100"))); +} + +/// `log` lowers each level: log of an exponential is linear, log of a +/// polynomial is a log, and log distributes over products as a sum. +#[test] +fn test_growth_log_levels() { + // log(2^n) ≍ n. + assert_eq!(g("log(2^n)"), g("n")); + assert_eq!(g("log(3^n)"), g("n")); + assert_eq!(g("log(exp(n))"), g("n")); + // log(n) is a single log term. + assert_eq!( + g("log(n)"), + Growth::Terms(vec![term(&[], &[], &[("n", 1)])]) + ); + // log(n*m) ≍ log n + log m (two summands, not a product). + assert_eq!(terms_of(&g("log(n*m)")).len(), 2); + // log of a constant is O(1). + assert_eq!(terms_of(&g("log(5)")), [GrowthTerm::one()]); + + // A mixed monomial's log keeps *every* factor class: log(2^n * m) ≍ n + log m. + // The exponential factor must not swallow the polynomial one. + let mixed = g("log(2^n * m)"); + let expected = make_growth(vec![ + term(&[], &[("n", 1.0)], &[]), + term(&[], &[], &[("m", 1)]), + ]); + assert_eq!(mixed, expected); + assert_eq!(terms_of(&mixed).len(), 2, "expected n + log m: {mixed:?}"); + + // When the classes share a variable the dominated summand is pruned: + // log(2^n * n^2) ≍ n + log n ≍ n (a single summand). + let shared = g("log(2^n * n^2)"); + assert_eq!(shared, g("n")); + assert_eq!(terms_of(&shared), [term(&[], &[("n", 1.0)], &[])]); +} + +/// `Unknown` is the top of the growth order. +#[test] +fn test_growth_unknown_dominance() { + let n2 = g("n^2"); + let unknown = g("factorial(n)"); + assert!(unknown.dominates(&n2)); + assert!(!n2.dominates(&unknown)); + assert!(unknown.dominates(&unknown)); +} + +/// Large antichains remain exact; growth analysis has no hidden size cap. +#[test] +fn test_growth_preserves_large_antichain() { + // 40 distinct single-variable terms are pairwise incomparable. + let vars: Vec = (0..40).map(|index| format!("v{index}")).collect(); + let many: Vec = vars + .iter() + .map(|variable| term(&[], &[(variable, 1.0)], &[])) + .collect(); + + let growth = make_growth(many.clone()); + assert_eq!(terms_of(&growth).len(), many.len()); + assert!(many.iter().all(|term| terms_of(&growth).contains(term))); +} + +/// Unproved exponential comparisons also remain as a complete antichain. +#[test] +fn test_growth_preserves_large_unproved_exponential_antichain() { + let terms = (1..=33) + .map(|i| GrowthTerm { + exp: [( + "n".into(), + exp_product(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]), + )] + .into_iter() + .collect(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + }) + .collect::>(); + + assert_eq!(terms_of(&make_growth(terms.clone())).len(), terms.len()); +} + +/// Structured serde round-trips with owned variable names, and +/// `Unknown` round-trips. +#[test] +fn test_growth_serde_roundtrip() { + let value = g("2^n * m^2 + n * log(k)"); + let json = serde_json::to_string(&value).unwrap(); + let back: Growth = serde_json::from_str(&json).unwrap(); + assert_eq!(value, back); + + let unknown = g("factorial(n)"); + let unknown_json = serde_json::to_string(&unknown).unwrap(); + assert_eq!( + serde_json::from_str::(&unknown_json).unwrap(), + unknown + ); + + // Every constant Expr form admitted as a symbolic base remains lossless. + for source in [ + "(1 + 1)^n", + "(2 * 2)^n", + "(2^2)^n", + "exp(1)^n", + "log(3)^n", + "sqrt(4)^n", + "factorial(3)^n", + "exp(n)", + ] { + let value = g(source); + let json = serde_json::to_string(&value).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), value); + } + + let variable_base = serde_json::json!({ + "Constant": serde_json::to_value(Expr::variable("n")).unwrap() + }); + let error = serde_json::from_value::(variable_base).unwrap_err(); + assert!(error + .to_string() + .contains("symbolic exponential base must be a positive rational constant")); + + let invalid = Growth::Terms(vec![GrowthTerm { + exp: [("n".into(), ExpProduct::empty())].into_iter().collect(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + }]); + let invalid_json = serde_json::to_string(&invalid).unwrap(); + assert!(serde_json::from_str::(&invalid_json).is_err()); +} + +// --- Randomized property tests --- +// +// These cross-validate the symbolic growth domain against the numeric ground +// truth (`Expr::eval`) over a large, seeded input space, in the spirit of the +// repo's `/verify-reduction` adversarial culture. Three contracts are exercised +// ≥ 5000 times each with a hand-rolled, deterministic RNG (no wall-clock, no +// entropy — CI must be byte-reproducible across platforms): +// +// 1. Upper-bound soundness: `eval(e, s) ≤ C·eval(render(growth(e)), s)` at +// sizes larger than the anchor from which `C` was calibrated. +// 2. Idempotence: `growth(render(growth(e))) == growth(e)`. +// 3. Dominance soundness: when `dominates(b, a)`, the numeric ratio +// `eval(b)/eval(a)` does not shrink and exceeds 1 at the larger size. +// +// A #[test] negative control runs the same upper-bound harness against a +// deliberately broken transfer function and asserts the harness catches it, so +// the property tests are demonstrably capable of failing. +// +// Why the domain exists at all is *why* some numeric checks are unreachable: +// crossovers like `2^n ≻ n^100` lie far beyond f64 range. The harnesses handle +// this honestly — they skip (and count) samples where numerics are +// indeterminate (both sides overflow to `inf`), never by hiding a failing +// assertion. The dominance contract additionally restricts its numeric +// cross-check to single-term, in-band growths, the regime where the crossover +// is reachable; that regime targets exactly the lexicographic per-variable +// comparison (`GrowthTerm::cmp`) at the heart of the order, so the restriction +// is well-aimed, not vacuous. + +use super::{log_growth, pow_const}; +use crate::types::ProblemSize; +use std::collections::BTreeMap; + +/// Fixed master seed. Every contract derives its own stream by offsetting this, +/// so the whole suite is deterministic and reproducible on any platform. +const MASTER_SEED: u64 = 0xD1CE_2026_A11C_E5ED; + +/// SplitMix64 — a tiny, fully specified PRNG. Hand-rolled (rather than +/// `rand::StdRng`) precisely because its output must be identical across crate +/// versions and platforms; the constants below are the published SplitMix64 +/// mixing constants and will never change. +struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + fn new(seed: u64) -> Self { + SplitMix64 { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform integer in `[0, n)`. + fn below(&mut self, n: u64) -> u64 { + self.next_u64() % n + } +} + +/// Variable pool used by generated expressions and [`joint_size`]. +const VARS: [&str; 3] = ["n", "m", "k"]; + +fn gen_var(rng: &mut SplitMix64) -> Expr { + Expr::variable(VARS[rng.below(VARS.len() as u64) as usize]) +} + +/// All variables set jointly to `s` (the contracts evaluate on the diagonal). +fn joint_size(s: usize) -> ProblemSize { + ProblemSize::new(vec![("n", s), ("m", s), ("k", s)]) +} + +// --- General expression generator (contracts 1 and 2) --- +// +// Bounded depth, variables {n, m, k}, constructors Const/Var/Add/Mul/Pow(const)/ +// Sqrt/Log plus linear `2^x` and `exp(x)` forms. A small (~1% per node) branch +// emits a nonlinear exponent (`2^(n*m)`, `2^sqrt(n)`) so the `Unknown` widening +// path is genuinely exercised while staying a minority of whole trees. + +const MAX_DEPTH: u32 = 5; + +fn gen_leaf(rng: &mut SplitMix64) -> Expr { + // Bias toward variables; keep constants small and positive. + if rng.below(4) == 0 { + Expr::integer(1 + rng.below(4)) + } else { + gen_var(rng) + } +} + +/// A linear expression in the variables (so `2^x` stays first-class in the +/// domain): a sum of 1..=3 terms `c·v` with small positive integer coefficients. +fn gen_linear(rng: &mut SplitMix64) -> Expr { + let nterms = 1 + rng.below(3); + let mut e = gen_lin_term(rng); + for _ in 1..nterms { + e = e + gen_lin_term(rng); + } + e +} + +fn gen_lin_term(rng: &mut SplitMix64) -> Expr { + let v = gen_var(rng); + let c = 1 + rng.below(3); + if c == 1 { + v + } else { + Expr::integer(c) * v + } +} + +/// A deliberately nonlinear exponent, driving `2^(·)` to `Growth::Unknown`. +fn gen_nonlinear(rng: &mut SplitMix64) -> Expr { + if rng.below(2) == 0 { + gen_var(rng) * gen_var(rng) + } else { + Expr::sqrt(gen_var(rng)) + } +} + +const E_BELOW: f64 = std::f64::consts::E - 1e-10; +const E_ABOVE: f64 = std::f64::consts::E + 1e-10; +const STABLE_EXPONENTIAL_BASES: &[f64] = &[2.0, E_BELOW, E_ABOVE, 3.0]; +const ADVERSARIAL_EXPONENTIAL_BASES: &[f64] = &[1.0000000001, 2.0, E_BELOW, E_ABOVE, 3.0]; + +fn gen_exponential_base(rng: &mut SplitMix64, bases: &[f64]) -> Expr { + expression_from_approximation(bases[rng.below(bases.len() as u64) as usize]) +} + +fn gen_expr(rng: &mut SplitMix64, depth: u32, exponential_bases: &[f64]) -> Expr { + if depth == 0 { + return gen_leaf(rng); + } + match rng.below(100) { + 0..=19 => gen_leaf(rng), + 20..=39 => { + gen_expr(rng, depth - 1, exponential_bases) + + gen_expr(rng, depth - 1, exponential_bases) + } + 40..=54 => { + gen_expr(rng, depth - 1, exponential_bases) + * gen_expr(rng, depth - 1, exponential_bases) + } + 55..=69 => Expr::pow( + gen_expr(rng, depth - 1, exponential_bases), + Expr::integer(1 + rng.below(3)), + ), + 70..=79 => Expr::sqrt(gen_expr(rng, depth - 1, exponential_bases)), + 80..=89 => Expr::log(gen_expr(rng, depth - 1, exponential_bases)), + 90..=96 => Expr::pow( + gen_exponential_base(rng, exponential_bases), + gen_linear(rng), + ), + 97..=98 => Expr::exp(gen_var(rng)), + // ~1% per node: a nonlinear exponent → Unknown (a minority of trees). + _ => Expr::pow( + gen_exponential_base(rng, exponential_bases), + gen_nonlinear(rng), + ), + } +} + +// --- Monomial generator (contract 3) --- +// +// A product of single-term factors, so its growth is always a single antichain +// term. This isolates the lexicographic per-variable dominance decision. + +fn gen_factor(rng: &mut SplitMix64) -> Expr { + let v = gen_var(rng); + match rng.below(6) { + 0 => v, + 1 => Expr::pow(v, Expr::integer(1 + rng.below(3))), + 2 => Expr::sqrt(v), + 3 => Expr::log(v), + // Keep the numeric dominance harness on one common base: different + // fixed bases can have crossovers beyond its finite observation window. + // Multi-base behavior is covered by symbolic proof tests above. + 4 => Expr::pow(Expr::integer(2), v), + _ => Expr::pow(Expr::integer(2), Expr::integer(1 + rng.below(3)) * v), + } +} + +fn gen_monomial(rng: &mut SplitMix64) -> Expr { + let nf = 1 + rng.below(4); + let mut e = gen_factor(rng); + for _ in 1..nf { + e = e * gen_factor(rng); + } + e +} + +// --- Contract 1: upper-bound soundness --- + +/// The number of independent `#[test]`-level iterations for the upper-bound and +/// idempotence contracts (each well above the 5000-meaningful-check floor after +/// `Unknown`/overflow skips). +const UB_ITERS: usize = 8_000; + +/// Outcome tallies for the upper-bound harness. `meaningful` counts samples that +/// produced at least one *conclusive* large-size comparison. +#[derive(Default)] +struct UbResult { + meaningful: usize, + unknown: usize, + skipped: usize, + violations: usize, + first_violation: Option, +} + +/// Run the upper-bound harness against an arbitrary transfer function. The real +/// test passes `Growth::from_expr`; the negative control passes +/// `broken_from_expr`. Parameterizing here is what gives the harness teeth: the +/// exact same code must accept the sound transfer and reject the broken one. +fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> UbResult { + // Anchor 2^6; check at 2^8, 2^10, 2^12 — all *larger* than the anchor. + let anchor = 64.0_f64; + let large = [256.0_f64, 1024.0, 4096.0]; + let slack = 16.0_f64; + + let mut rng = SplitMix64::new(seed); + let mut r = UbResult::default(); + + for _ in 0..iters { + let e = gen_expr(&mut rng, MAX_DEPTH, STABLE_EXPONENTIAL_BASES); + let g = transfer(&e); + let gexpr = match g.to_expr() { + Some(x) => x, + None => { + r.unknown += 1; + continue; + } + }; + + // Calibrate C from the observed ratio at the (smaller) anchor. + let sz0 = joint_size(anchor as usize); + let (Ok(ve0), Ok(vg0)) = ( + evaluate_approximate(&e, &sz0), + evaluate_approximate(&gexpr, &sz0), + ) else { + r.skipped += 1; + continue; + }; + // Nonnegativity is a domain precondition. A negative anchor value means + // the generated expression is outside the domain's contract (e.g. deeply + // nested `log`s that are negative at these sizes) — skip it, don't hold + // the domain to a bound it never promised for such inputs. + if !ve0.is_finite() || !vg0.is_finite() || ve0 <= 0.0 || vg0 <= 0.0 { + r.skipped += 1; + continue; + } + let c = (ve0 / vg0) * slack; + + let mut conclusive = false; + for &s in &large { + let sz = joint_size(s as usize); + let (Ok(ve), Ok(vg)) = ( + evaluate_approximate(&e, &sz), + evaluate_approximate(&gexpr, &sz), + ) else { + continue; + }; + if ve <= 0.0 || vg <= 0.0 { + // Out of the nonnegative domain at this size — indeterminate. + continue; + } + // Both finite and positive: a real, decidable comparison. + conclusive = true; + let bound = c * vg; + if ve > bound { + r.violations += 1; + if r.first_violation.is_none() { + r.first_violation = Some(format!( + "e = {e} | g = {gexpr} | s = {s}: eval(e) = {ve} > {c} * {vg} = {bound}" + )); + } + } + } + + if conclusive { + r.meaningful += 1; + } else { + r.skipped += 1; + } + } + r +} + +/// A deliberately broken transfer function: `Add` keeps only its *first* +/// operand's growth, dropping the second. This is an under-approximation — it +/// can miss the dominant summand — so the upper bound must fail somewhere. +/// Every other node mirrors the real `Growth::from_expr` (reusing its private +/// transfer helpers), so the only defect is the seeded `Add` bug. +fn broken_from_expr(expression: &Expr) -> Growth { + match expression.node() { + // The seeded bug: drop every summand except the first. + ExprNode::Add(values) => broken_from_expr(&values[0]), + ExprNode::Mul(values) => values + .iter() + .map(broken_from_expr) + .reduce(mul) + .expect("normalized product has at least two factors"), + ExprNode::Pow(base, exponent) => { + let analysis = AlgebraicAnalysis::new(&[expression]); + match analysis.facts(exponent).exact_rational.as_ref() { + Some(power) if power.is_negative() => { + Growth::unknown(GrowthFailure::NegativeExponent(exponent.to_string())) + } + Some(power) => pow_const(broken_from_expr(base), power), + None => Growth::from_expr(expression), + } + } + ExprNode::Log(value) => log_growth(broken_from_expr(value)), + _ => Growth::from_expr(expression), + } +} +#[test] +fn test_growth_property_upper_bound_sound() { + let r = run_upper_bound(Growth::from_expr, MASTER_SEED ^ 0x01, UB_ITERS); + + assert_eq!( + r.violations, + 0, + "upper-bound violation ({} total); first: {}", + r.violations, + r.first_violation.as_deref().unwrap_or("") + ); + assert!( + r.meaningful >= 5000, + "need >= 5000 meaningful checks, got {} (unknown {}, skipped {})", + r.meaningful, + r.unknown, + r.skipped + ); + // The generator must actually exercise the domain, not mostly produce Unknown. + let total = r.meaningful + r.unknown + r.skipped; + assert!( + r.unknown * 2 < total, + "Unknown must be a minority: {}/{}", + r.unknown, + total + ); + assert!(r.unknown > 0, "generator never exercised the Unknown path"); +} + +#[test] +fn test_growth_property_upper_bound_negative_control() { + // The SAME harness, run against the broken transfer, must detect a + // violation. If it cannot, the property tests have no teeth and this fails. + let r = run_upper_bound(broken_from_expr, MASTER_SEED ^ 0x01, UB_ITERS); + assert!( + r.violations > 0, + "harness failed to catch the seeded Add bug (meaningful {}, violations {})", + r.meaningful, + r.violations + ); +} + +// --- Contract 2: idempotence --- + +fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { + x == y +} + +fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { + match (a, b) { + (Growth::Unknown(_), Growth::Unknown(_)) => true, + (Growth::Terms(ta), Growth::Terms(tb)) => { + ta.len() == tb.len() + && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) + && tb.iter().all(|u| ta.iter().any(|t| term_approx_eq(t, u))) + } + _ => false, + } +} + +#[test] +fn test_growth_property_idempotence() { + let mut rng = SplitMix64::new(MASTER_SEED ^ 0x02); + let mut meaningful = 0usize; + let mut unknown = 0usize; + + for _ in 0..UB_ITERS { + // Idempotence is purely symbolic, so it can safely exercise bases near + // one whose numeric crossover lies far beyond the f64 test window. + let e = gen_expr(&mut rng, MAX_DEPTH, ADVERSARIAL_EXPONENTIAL_BASES); + let g = Growth::from_expr(&e); + let rendered = match g.to_expr() { + Some(x) => x, + None => { + unknown += 1; + continue; + } + }; + let g2 = Growth::from_expr(&rendered); + assert!( + growth_approx_eq(&g, &g2), + "growth not idempotent: e = {e} | render = {rendered}\n g = {g:?}\n g2 = {g2:?}" + ); + meaningful += 1; + } + + assert!( + meaningful >= 5000, + "need >= 5000 meaningful checks, got {meaningful} (unknown {unknown})" + ); +} + +// --- Contract 3: dominance soundness --- + +const DOM_ITERS: usize = 5_000; + +#[test] +fn test_growth_property_dominance_sound() { + let mut rng = SplitMix64::new(MASTER_SEED ^ 0x03); + + for _ in 0..DOM_ITERS { + let lower_expression = gen_monomial(&mut rng); + let ratio_expression = gen_factor(&mut rng); + let higher_expression = lower_expression.clone() * ratio_expression.clone(); + let lower = Growth::from_expr(&lower_expression); + let higher = Growth::from_expr(&higher_expression); + assert!(higher.dominates(&lower)); + assert!(!lower.dominates(&higher)); + + let r1 = evaluate_approximate(&ratio_expression, &joint_size(16)).unwrap(); + let r2 = evaluate_approximate(&ratio_expression, &joint_size(64)).unwrap(); + assert!( + r2 >= r1 * (1.0 - 1e-9), + "dominance ratio shrank: {higher_expression} over {lower_expression}; r(16) = {r1}, r(64) = {r2}" + ); + assert!( + r2 > 1.0, + "dominator not numerically ahead: {higher_expression} over {lower_expression}; r(64) = {r2}" + ); + } +} 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/formula/one_in_three_satisfiability.rs b/src/unit_tests/models/formula/one_in_three_satisfiability.rs index e20220e2e..c54ca0eb8 100644 --- a/src/unit_tests/models/formula/one_in_three_satisfiability.rs +++ b/src/unit_tests/models/formula/one_in_three_satisfiability.rs @@ -118,7 +118,7 @@ fn test_one_in_three_satisfiability_wrong_clause_width() { } #[test] -#[should_panic(expected = "outside range")] +#[should_panic(expected = "allowed variable numbers are 1..=2")] fn test_one_in_three_satisfiability_variable_out_of_range() { OneInThreeSatisfiability::new(2, vec![CNFClause::new(vec![1, 2, 3])]); } diff --git a/src/unit_tests/models/formula/planar_3_satisfiability.rs b/src/unit_tests/models/formula/planar_3_satisfiability.rs index ccbdffc8f..47b6a8500 100644 --- a/src/unit_tests/models/formula/planar_3_satisfiability.rs +++ b/src/unit_tests/models/formula/planar_3_satisfiability.rs @@ -135,7 +135,7 @@ fn test_planar_3_satisfiability_wrong_clause_width() { } #[test] -#[should_panic(expected = "outside range")] +#[should_panic(expected = "allowed variable numbers are 1..=2")] fn test_planar_3_satisfiability_variable_out_of_range() { Planar3Satisfiability::new(2, vec![CNFClause::new(vec![1, 2, 3])]); } diff --git a/src/unit_tests/models/formula/qbf.rs b/src/unit_tests/models/formula/qbf.rs index 136cc967d..3e408a0f0 100644 --- a/src/unit_tests/models/formula/qbf.rs +++ b/src/unit_tests/models/formula/qbf.rs @@ -133,8 +133,8 @@ fn test_qbf_zero_vars() { #[test] fn test_qbf_zero_vars_unsat() { - // Zero variables, but a clause that refers to var 1 (unsatisfiable) - let problem = QuantifiedBooleanFormulas::new(0, vec![], vec![CNFClause::new(vec![1])]); + // An empty clause is false without referring to a nonexistent variable. + let problem = QuantifiedBooleanFormulas::new(0, vec![], vec![CNFClause::new(vec![])]); assert!(!problem.evaluate(&[])); assert!(!problem.is_true()); } diff --git a/src/unit_tests/models/formula/sat.rs b/src/unit_tests/models/formula/sat.rs index 29ddad85a..54573115c 100644 --- a/src/unit_tests/models/formula/sat.rs +++ b/src/unit_tests/models/formula/sat.rs @@ -106,7 +106,7 @@ fn test_empty_formula_zero_vars_solver() { #[test] fn test_zero_vars_unsat_solver() { - let problem = Satisfiability::new(0, vec![CNFClause::new(vec![1])]); + let problem = Satisfiability::new(0, vec![CNFClause::new(vec![])]); let solver = BruteForce::new(); assert_eq!(solver.find_witness(&problem), None); 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 dcfadc6e6..1f4aef639 100644 --- a/src/unit_tests/models/graph/max_cut.rs +++ b/src/unit_tests/models/graph/max_cut.rs @@ -104,7 +104,7 @@ fn test_jl_parity_evaluation() { for eval in instance["evaluations"].as_array().unwrap() { let config = jl_parse_config(&eval["config"]); let result = problem.evaluate(&config); - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert!(result.is_valid(), "MaxCut should always be valid"); assert_eq!( result.unwrap(), @@ -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 06f2b3566..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"); @@ -141,7 +151,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, 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 4362cfc39..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; @@ -139,7 +149,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, diff --git a/src/unit_tests/models/graph/maximum_matching.rs b/src/unit_tests/models/graph/maximum_matching.rs index 17c170e8c..b3932223d 100644 --- a/src/unit_tests/models/graph/maximum_matching.rs +++ b/src/unit_tests/models/graph/maximum_matching.rs @@ -132,7 +132,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, @@ -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 a1665a701..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; @@ -138,7 +151,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, 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 39f052644..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; @@ -122,7 +135,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, 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 e5ff4fdea..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"); @@ -114,7 +127,7 @@ fn test_jl_parity_evaluation() { let jl_config = jl_parse_config(&eval["config"]); let config = jl_flip_config(&jl_config); let result = problem.evaluate(&config); - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert!(result.is_valid(), "SpinGlass should always be valid"); assert_eq!( result.unwrap(), 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 1b26eb836..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,16 +1,28 @@ -use crate::models::misc::KthLargestMTuple; +use super::*; use crate::solvers::{BruteForce, Solver}; use crate::traits::Problem; -use crate::types::Sum; +use crate::types::Or; -fn example_problem() -> KthLargestMTuple { - // m=3, X_1={2,5,8}, X_2={3,6}, X_3={1,4,7}, B=12, K=14 - KthLargestMTuple::new(vec![vec![2, 5, 8], vec![3, 6], vec![1, 4, 7]], 14, 12) +fn example_problem(k: u64) -> KthLargestMTuple { + // m=3, X_1={2,5,8}, X_2={3,6}, X_3={1,4,7}, B=12 + 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(); + let p = example_problem(14); assert_eq!(p.sets().len(), 3); assert_eq!(p.sets()[0], vec![2, 5, 8]); assert_eq!(p.sets()[1], vec![3, 6]); @@ -19,64 +31,31 @@ fn test_kth_largest_m_tuple_creation() { assert_eq!(p.bound(), 12); assert_eq!(p.num_sets(), 3); assert_eq!(p.total_tuples(), 18); - assert_eq!(p.dims(), vec![3, 2, 3]); - assert_eq!(p.num_variables(), 3); + assert_eq!(p.dims(), Vec::::new()); + assert_eq!(p.num_variables(), 0); assert_eq!(::NAME, "KthLargestMTuple"); assert_eq!(::variant(), vec![]); } #[test] -fn test_kth_largest_m_tuple_evaluate_qualifying_tuple() { - let p = example_problem(); - // (8,6,7) = sum 21 >= 12 -> Sum(1) - assert_eq!(p.evaluate(&[2, 1, 2]), Sum(1)); - // (5,6,4) = sum 15 >= 12 -> Sum(1) - assert_eq!(p.evaluate(&[1, 1, 1]), Sum(1)); -} +fn test_kth_largest_m_tuple_threshold_decision() { + let p = example_problem(14); + assert_eq!(BruteForce::new().solve(&p), Or(true)); -#[test] -fn test_kth_largest_m_tuple_evaluate_non_qualifying_tuple() { - let p = example_problem(); - // (2,3,1) = sum 6 < 12 -> Sum(0) - assert_eq!(p.evaluate(&[0, 0, 0]), Sum(0)); - // (2,3,4) = sum 9 < 12 -> Sum(0) - assert_eq!(p.evaluate(&[0, 0, 1]), Sum(0)); + let above_threshold = example_problem(15); + assert_eq!(BruteForce::new().solve(&above_threshold), Or(false)); } #[test] fn test_kth_largest_m_tuple_evaluate_invalid_configs() { - let p = example_problem(); - // Wrong length - assert_eq!(p.evaluate(&[0, 0]), Sum(0)); - assert_eq!(p.evaluate(&[0, 0, 0, 0]), Sum(0)); - // Out of range - assert_eq!(p.evaluate(&[3, 0, 0]), Sum(0)); - assert_eq!(p.evaluate(&[0, 2, 0]), Sum(0)); - assert_eq!(p.evaluate(&[0, 0, 3]), Sum(0)); -} - -#[test] -fn test_kth_largest_m_tuple_solver() { - let p = example_problem(); - let solver = BruteForce::new(); - let value = solver.solve(&p); - // 14 of 18 tuples qualify (sum >= 12) - assert_eq!(value, Sum(14)); -} - -#[test] -fn test_kth_largest_m_tuple_boundary_example() { - // K=14 and count=14, so the answer is YES (count >= K) - let p = example_problem(); - let solver = BruteForce::new(); - let count = solver.solve(&p); - assert_eq!(count, Sum(14)); - assert!(count.0 >= p.k()); + let p = example_problem(14); + assert_eq!(p.evaluate(&[0]), Or(false)); + assert_eq!(p.evaluate(&[2, 1, 2]), Or(false)); } #[test] fn test_kth_largest_m_tuple_serialization_round_trip() { - let p = example_problem(); + let p = example_problem(14); let json = serde_json::to_value(&p).unwrap(); assert_eq!( json, @@ -135,16 +114,9 @@ fn test_kth_largest_m_tuple_zero_size_panics() { fn test_kth_largest_m_tuple_paper_example() { // Issue example: m=3, X_1={2,5,8}, X_2={3,6}, X_3={1,4,7}, B=12, K=14 // 14 of 18 tuples have sum >= 12 -> YES (boundary case: count == K) - let p = example_problem(); + let p = example_problem(14); let solver = BruteForce::new(); - let count = solver.solve(&p); - assert_eq!(count, Sum(14)); - - // Verify a specific qualifying tuple: (8,6,7), sum=21 - assert_eq!(p.evaluate(&[2, 1, 2]), Sum(1)); - - // Verify a specific non-qualifying tuple: (2,3,1), sum=6 - assert_eq!(p.evaluate(&[0, 0, 0]), Sum(0)); + assert_eq!(solver.solve(&p), Or(true)); } #[test] @@ -152,7 +124,7 @@ fn test_kth_largest_m_tuple_all_qualify() { // Two sets each with one large element, B=1 -> all tuples qualify let p = KthLargestMTuple::new(vec![vec![5], vec![10]], 1, 1); let solver = BruteForce::new(); - assert_eq!(solver.solve(&p), Sum(1)); + assert_eq!(solver.solve(&p), Or(true)); assert_eq!(p.total_tuples(), 1); } @@ -161,5 +133,24 @@ fn test_kth_largest_m_tuple_none_qualify() { // B is larger than any possible sum let p = KthLargestMTuple::new(vec![vec![1, 2], vec![1, 2]], 1, 100); let solver = BruteForce::new(); - assert_eq!(solver.solve(&p), Sum(0)); + assert_eq!(solver.solve(&p), Or(false)); +} + +#[test] +fn test_kth_largest_m_tuple_sum_beyond_u64_max_qualifies() { + let p = KthLargestMTuple::new(vec![vec![u64::MAX], vec![1]], 1, u64::MAX); + assert_eq!(BruteForce::new().solve(&p), Or(true)); +} + +#[test] +fn test_kth_largest_m_tuple_many_singleton_sets_do_not_use_call_stack() { + let p = KthLargestMTuple::new(vec![vec![1]; 10_000], 1, 10_000); + assert_eq!(BruteForce::new().solve(&p), Or(true)); +} + +#[test] +#[should_panic(expected = "total tuple count exceeds usize")] +fn test_kth_largest_m_tuple_total_tuples_overflow_panics() { + let p = KthLargestMTuple::new(vec![vec![1, 2]; usize::BITS as usize], 1, 1); + p.total_tuples(); } 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 45184be81..aba2eea19 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,8 +1,20 @@ -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; -#[cfg(feature = "ilp-solver")] -use std::collections::BTreeMap; fn timetable_design_flat_index( num_tasks: usize, @@ -130,20 +142,18 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { assert!(problem.evaluate(&solution.unwrap())); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_issue_example_is_solved_via_ilp_solver_dispatch() { +fn test_timetable_design_native_backend_solves_feasible_example() { let problem = super::issue_example_problem(); - let solution = crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .expect("expected ILP solver dispatch to find a satisfying timetable"); + let solution = problem + .solve_via_required_assignments() + .expect("expected native backend to find a satisfying timetable"); assert!(problem.evaluate(&solution)); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { +fn test_timetable_design_unsat_instance_returns_none_via_native_backend() { let problem = TimetableDesign::new( 1, 2, @@ -153,9 +163,7 @@ fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { vec![vec![1], vec![1]], ); - assert!(crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .is_none()); + assert!(problem.solve_via_required_assignments().is_none()); } #[test] 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 405d55d1b..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]]); @@ -115,7 +130,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, 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 c218fc56d..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]]); @@ -85,7 +98,7 @@ fn test_jl_parity_evaluation() { config ); if jl_valid { - let jl_size = eval["size"].as_i64().unwrap() as i32; + let jl_size = eval["size"].as_i64().unwrap(); assert_eq!( result.unwrap(), jl_size, 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/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index be612b57b..ac0dcd424 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -1,19 +1,90 @@ //! Tests for ReductionGraph: discovery, path finding, and typed API. -#[cfg(feature = "ilp-solver")] use crate::models::algebraic::ILP; use crate::models::decision::Decision; use crate::models::formula::KSatisfiability; use crate::models::misc::Clustering; use crate::prelude::*; -use crate::rules::{MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow}; -use crate::topology::{KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; +use crate::rules::{ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow}; +use crate::topology::{KingsSubgraph, SimpleGraph, UnitDiskGraph}; use crate::types::ProblemSize; use crate::variant::{K3, KN}; use std::collections::BTreeMap; +#[test] +fn exact_transform_evaluates_without_path_ranking() { + let graph = ReductionGraph::new(); + let source = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let target = ReductionGraph::variant_to_map(&MaximumClique::::variant()); + let paths = graph.find_all_paths_mode( + "MaximumIndependentSet", + &source, + "MaximumClique", + &target, + ReductionMode::Witness, + ); + let path = paths.iter().find(|path| path.len() == 1).unwrap(); + let evaluated = graph + .evaluate_path_size( + path, + &ProblemSize::new(vec![("num_vertices", 5), ("num_edges", 4)]), + ) + .unwrap(); + assert_eq!( + evaluated.values().get("num_edges"), + Some(&num_bigint::BigUint::from(6u8)) + ); +} + +#[test] +fn exact_rule_exposes_one_transform() { + let graph = ReductionGraph::new(); + let source = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let target = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); + let path = graph + .find_all_paths_mode( + "MaximumIndependentSet", + &source, + "MinimumVertexCover", + &target, + ReductionMode::Witness, + ) + .into_iter() + .find(|path| path.len() == 1) + .unwrap(); + let transform = graph.compose_path_size_transform(&path).unwrap().unwrap(); + assert_eq!(transform.relation(), crate::size::SizeRelation::Exact); +} + // ---- Discovery and registration ---- +#[test] +fn compose_path_size_transform_rejects_an_empty_path() { + let graph = ReductionGraph::new(); + let error = graph + .compose_path_size_transform(&ReductionPath { steps: Vec::new() }) + .unwrap_err(); + assert!(matches!(error, crate::rules::PathSizeError::EmptyPath)); +} + +#[test] +fn compose_path_size_transform_is_absent_for_one_node() { + let graph = ReductionGraph::new(); + let variant = graph + .default_variant_for(KSatisfiability::::NAME) + .expect("K3 satisfiability is registered"); + let path = ReductionPath { + steps: vec![ReductionStep { + name: KSatisfiability::::NAME.to_string(), + variant, + }], + }; + + assert!(graph.compose_path_size_transform(&path).unwrap().is_none()); +} + #[test] fn test_reduction_graph_discovers_registered_reductions() { let graph = ReductionGraph::new(); @@ -41,7 +112,6 @@ fn test_reduction_graph_discovers_k3coloring_to_clustering() { assert!(graph.has_direct_reduction::, Clustering>()); } -#[cfg(feature = "ilp-solver")] #[test] fn test_reduction_graph_discovers_clustering_to_ilp() { let graph = ReductionGraph::new(); @@ -52,24 +122,17 @@ fn test_reduction_graph_discovers_clustering_to_ilp() { // ---- Path finding (by name) ---- #[test] -fn test_find_path_with_cost_function() { +fn test_find_direct_route_by_exact_variants() { let graph = ReductionGraph::new(); - let input_size = ProblemSize::new(vec![("num_vertices", 100), ("num_edges", 200)]); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &MinimizeSteps, - ); - - assert!(path.is_some(), "Should find path from IS to VC"); - let path = path.unwrap(); + let path = graph + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route should exist"); assert_eq!(path.len(), 1, "Should be a 1-step path"); assert_eq!(path.source(), Some("MaximumIndependentSet")); assert_eq!(path.target(), Some("MinimumVertexCover")); @@ -82,20 +145,11 @@ fn test_multi_step_path() { // Factoring -> CircuitSAT -> SpinGlass is a 2-step path let src = ReductionGraph::variant_to_map(&crate::models::misc::Factoring::variant()); let dst = ReductionGraph::variant_to_map(&SpinGlass::::variant()); - let path = graph.find_cheapest_path( - "Factoring", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - - assert!( - path.is_some(), - "Should find path from Factoring to SpinGlass" - ); - let path = path.unwrap(); + let path = graph + .find_all_paths("Factoring", &src, "SpinGlass", &dst) + .into_iter() + .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .expect("explicit CircuitSAT route should exist"); assert_eq!(path.len(), 2, "Should be a 2-step path"); assert_eq!( path.type_names(), @@ -109,28 +163,24 @@ fn aggregate_mode_rejects_witness_only_real_edge() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - assert!(graph - .find_cheapest_path_mode( + assert!(!graph + .find_all_paths_mode( "MaximumIndependentSet", &src, "MinimumVertexCover", &dst, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Witness ) - .is_some()); + .is_empty()); assert!(graph - .find_cheapest_path_mode( + .find_all_paths_mode( "MaximumIndependentSet", &src, "MinimumVertexCover", &dst, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Aggregate ) - .is_none()); + .is_empty()); } #[test] @@ -141,61 +191,62 @@ fn natural_edge_supports_both_modes_public_api() { let dst = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - assert!(graph - .find_cheapest_path_mode( + assert!(!graph + .find_all_paths_mode( "MaximumIndependentSet", &src, "MaximumIndependentSet", &dst, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Witness ) - .is_some()); - assert!(graph - .find_cheapest_path_mode( + .is_empty()); + assert!(!graph + .find_all_paths_mode( "MaximumIndependentSet", &src, "MaximumIndependentSet", &dst, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Aggregate ) - .is_some()); + .is_empty()); +} + +#[test] +fn value_changing_variant_cast_is_not_aggregate_capable() { + use crate::models::set::MaximumSetPacking; + + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); + let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); + + assert!(graph + .find_all_paths_mode( + "MaximumSetPacking", + &src, + "MaximumSetPacking", + &dst, + ReductionMode::Aggregate + ) + .is_empty()); } #[test] fn test_problem_size_propagation() { let graph = ReductionGraph::new(); - let input_size = ProblemSize::new(vec![("num_vertices", 50), ("num_edges", 100)]); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &MinimizeSteps, - ); - - assert!(path.is_some()); + assert!(!graph + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .is_empty()); let src2 = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst2 = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - let path2 = graph.find_cheapest_path( - "MaximumIndependentSet", - &src2, - "MaximumSetPacking", - &dst2, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(path2.is_some()); + assert!(!graph + .find_all_paths("MaximumIndependentSet", &src2, "MaximumSetPacking", &dst2) + .is_empty()); } // ---- JSON export ---- @@ -235,7 +286,6 @@ fn test_subsetsum_to_integerknapsack_is_proof_only() { )); } -#[cfg(feature = "ilp-solver")] #[test] fn test_integerknapsack_to_ilp_is_runtime_witness_edge() { let graph = ReductionGraph::new(); @@ -293,16 +343,7 @@ fn test_find_indirect_path() { let paths = graph.find_all_paths("MaximumSetPacking", &src, "MinimumVertexCover", &dst); assert!(!paths.is_empty()); - let shortest = graph.find_cheapest_path( - "MaximumSetPacking", - &src, - "MinimumVertexCover", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(shortest.is_some()); - assert_eq!(shortest.unwrap().len(), 2); + assert!(paths.iter().any(|path| path.len() == 2)); } #[test] @@ -323,15 +364,10 @@ fn test_reduction_path_display() { let src_var = ReductionGraph::variant_to_map(&Factoring::variant()); let dst_var = ReductionGraph::variant_to_map(&SpinGlass::::variant()); let path = graph - .find_cheapest_path( - "Factoring", - &src_var, - "SpinGlass", - &dst_var, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); + .find_all_paths("Factoring", &src_var, "SpinGlass", &dst_var) + .into_iter() + .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .expect("explicit CircuitSAT route"); let s = format!("{path}"); // Should contain arrow-separated problem names with variant info @@ -350,102 +386,6 @@ fn test_reduction_path_display() { assert!(last_s.contains("{")); } -// ---- Overhead evaluation along a path ---- - -#[test] -fn test_3sat_to_mis_triangular_overhead() { - use crate::models::formula::CNFClause; - - let graph = ReductionGraph::new(); - - let src_var = ReductionGraph::variant_to_map(&KSatisfiability::::variant()); - let dst_var = ReductionGraph::variant_to_map( - &MaximumIndependentSet::::variant(), - ); - - // 3-SAT instance: 3 variables, 2 clauses, 6 literals - let _source = KSatisfiability::::new( - 3, - vec![ - CNFClause::new(vec![1, 2, 3]), - CNFClause::new(vec![-1, -2, -3]), - ], - ); - let input_size = ProblemSize::new(vec![ - ("num_vars", 3), - ("num_clauses", 2), - ("num_literals", 6), - ]); - - // Find the shortest path - let path = graph - .find_cheapest_path( - "KSatisfiability", - &src_var, - "MaximumIndependentSet", - &dst_var, - &input_size, - &MinimizeSteps, - ) - .expect("Should find path from 3-SAT to MIS on triangular lattice"); - - // Path: K3SAT → KN_SAT (cast) → SAT → MIS{SimpleGraph,One} → MIS{TriangularSubgraph,i32} - assert_eq!( - path.type_names(), - vec!["KSatisfiability", "Satisfiability", "MaximumIndependentSet"] - ); - assert_eq!(path.len(), 4); - - // Per-edge symbolic overheads - let edges = graph.path_overheads(&path); - assert_eq!(edges.len(), 4); - - // Evaluate overheads at a test point to verify correctness - let test_size = ProblemSize::new(vec![ - ("num_vars", 3), - ("num_clauses", 2), - ("num_literals", 6), - ("num_vertices", 10), - ("num_edges", 15), - ]); - - // Edge 0: K3SAT → KN_SAT (variant cast, identity for num_vars + num_clauses) - assert_eq!(edges[0].get("num_vars").unwrap().eval(&test_size), 3.0); - assert_eq!(edges[0].get("num_clauses").unwrap().eval(&test_size), 2.0); - - // Edge 1: KN_SAT → SAT (identity) - assert_eq!(edges[1].get("num_vars").unwrap().eval(&test_size), 3.0); - assert_eq!(edges[1].get("num_clauses").unwrap().eval(&test_size), 2.0); - assert_eq!(edges[1].get("num_literals").unwrap().eval(&test_size), 6.0); - - // Edge 2: SAT → MIS{SimpleGraph,One} - // num_vertices = num_literals, num_edges = num_literals^2 - assert_eq!(edges[2].get("num_vertices").unwrap().eval(&test_size), 6.0); - assert_eq!(edges[2].get("num_edges").unwrap().eval(&test_size), 36.0); - - // Edge 3: MIS{SimpleGraph,One} → MIS{TriangularSubgraph,i32} - // num_vertices = num_vertices², num_edges = num_vertices² - assert_eq!( - edges[3].get("num_vertices").unwrap().eval(&test_size), - 100.0 - ); - assert_eq!(edges[3].get("num_edges").unwrap().eval(&test_size), 100.0); - - // Compose overheads symbolically along the path. - // The composed overhead maps 3-SAT input variables to final MIS{Triangular} output. - // - // K3SAT → KN_SAT: {num_clauses: C, num_vars: V, num_literals: L} (identity cast) - // KN_SAT → SAT: {num_clauses: C, num_vars: V, num_literals: L} (identity) - // SAT → MIS{SG,One}: {num_vertices: L, num_edges: L²} - // MIS{SG,One→Tri}: {num_vertices: V², num_edges: V²} - // - // Composed: num_vertices = L², num_edges = L² - let composed = graph.compose_path_overhead(&path); - // Evaluate composed at input: L=6, so L²=36 - assert_eq!(composed.get("num_vertices").unwrap().eval(&test_size), 36.0); - assert_eq!(composed.get("num_edges").unwrap().eval(&test_size), 36.0); -} - // ---- k-neighbor BFS ---- #[test] @@ -694,7 +634,7 @@ fn find_paths_up_to_no_path() { // ---- Exact source+target variant matching ---- #[test] -fn find_best_entry_rejects_wrong_target_variant() { +fn find_entry_rejects_wrong_target_variant() { let graph = ReductionGraph::new(); let source = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); @@ -703,7 +643,7 @@ fn find_best_entry_rejects_wrong_target_variant() { ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "f64".to_string()), ]); - let result = graph.find_best_entry( + let result = graph.find_entry( "MaximumIndependentSet", &source, "MinimumVertexCover", @@ -713,12 +653,12 @@ fn find_best_entry_rejects_wrong_target_variant() { } #[test] -fn find_best_entry_accepts_exact_source_and_target_variant() { +fn find_entry_accepts_exact_source_and_target_variant() { let graph = ReductionGraph::new(); let source = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let target = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let result = graph.find_best_entry( + let result = graph.find_entry( "MaximumIndependentSet", &source, "MinimumVertexCover", @@ -781,7 +721,6 @@ fn test_minimumvertexcover_to_minimummaximalmatching_is_proof_only_direct_edge() )); } -#[cfg(feature = "ilp-solver")] #[test] fn test_minimumcoveringbycliques_to_ilp_is_runtime_witness_edge() { let graph = ReductionGraph::new(); @@ -988,3 +927,75 @@ fn test_find_paths_bounded_limits_depth() { "MIS→QUBO has no direct edge, so bound=0 should return empty" ); } + +#[test] +fn test_find_paths_bounded_returns_shortest_when_truncated() { + use crate::expr::Expr; + use crate::rules::registry::{ReductionSizeContract, ReductionSizeDeclarations}; + use crate::rules::ReductionEdgeData; + + fn edge() -> ReductionEdgeData { + fn reduce(_source: &dyn std::any::Any) -> Box { + Box::new(crate::rules::ReductionAutoCast::< + crate::models::formula::Satisfiability, + crate::models::formula::Satisfiability, + >::new( + crate::models::formula::Satisfiability::new(0, vec![]) + )) + } + + ReductionEdgeData { + size_contract: ReductionSizeContract::new( + "synthetic edge", + ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![("n", Expr::variable("n"))], + unavailable: vec![], + }, + ), + reduce_fn: Some(reduce), + reduce_aggregate_fn: None, + turing: false, + } + } + + // Topology where DFS discovery order surfaces a LONG route before the SHORT one. + // From S the first outgoing edge (S->A) leads into a long chain A->B->C->T, while a + // later edge S->T is a direct hop. petgraph's DFS explores S->A first, so the + // 4-edge route is discovered before the 1-edge direct route. With a tight limit, + // the old `.take(limit)` in discovery order would keep the long route and drop the + // short one; length-first enumeration must return the short route. + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "C", "T"], + &[ + ("S", "A", edge()), + ("A", "B", edge()), + ("B", "C", edge()), + ("C", "T", edge()), + ("S", "T", edge()), + ], + ); + + let empty = BTreeMap::new(); + + // Sanity: both routes exist when unbounded. + let all = graph.find_paths_up_to("S", &empty, "T", &empty, 100); + assert_eq!(all.len(), 2, "expected the direct route and the long chain"); + + // With limit 1, the SHORT (direct) route must be the one returned. + let limited = graph.find_paths_up_to("S", &empty, "T", &empty, 1); + assert_eq!(limited.len(), 1); + assert_eq!( + limited[0].len(), + 1, + "truncated result must keep the shortest (direct) route, not the long chain" + ); + + // Results are length-sorted (non-decreasing edge counts). + let lens: Vec = all.iter().map(|p| p.len()).collect(); + assert!( + lens.windows(2).all(|w| w[0] <= w[1]), + "paths must be returned shortest-first, got lengths {lens:?}" + ); + assert_eq!(lens, vec![1, 4]); +} diff --git a/src/unit_tests/registry/problem_type.rs b/src/unit_tests/registry/problem_type.rs index 6ca8cfdb3..aa5aac47e 100644 --- a/src/unit_tests/registry/problem_type.rs +++ b/src/unit_tests/registry/problem_type.rs @@ -1,6 +1,6 @@ use crate::registry::{ find_problem_type, find_problem_type_by_alias, parse_catalog_problem_ref, problem_types, - ProblemRef, ProblemSchemaEntry, + ProblemCategory, ProblemRef, ProblemSchemaEntry, }; use std::collections::HashMap; @@ -66,6 +66,43 @@ fn problem_types_returns_all_registered() { .any(|t| t.canonical_name == "MaximumIndependentSet")); } +#[test] +fn problem_category_comes_from_explicit_schema_metadata() { + assert_eq!( + find_problem_type("QUBO").unwrap().category, + ProblemCategory::Algebraic + ); + assert_eq!( + find_problem_type("KSatisfiability").unwrap().category, + ProblemCategory::Formula + ); + assert_eq!( + find_problem_type("MaximumClique").unwrap().category, + ProblemCategory::Graph + ); + assert_eq!( + find_problem_type("JobShopScheduling").unwrap().category, + ProblemCategory::Misc + ); + assert_eq!( + find_problem_type("MinimumSetCovering").unwrap().category, + ProblemCategory::Set + ); + + static MISMATCHED_PATH_SCHEMA: ProblemSchemaEntry = ProblemSchemaEntry { + name: "ExplicitCategoryTest", + display_name: "Explicit category test", + aliases: &[], + dimensions: &[], + category: ProblemCategory::Set, + module_path: "problemreductions::models::graph::explicit_category_test", + description: "Test fixture", + fields: &[], + }; + let problem = super::ProblemType::from_entry(&MISMATCHED_PATH_SCHEMA); + assert_eq!(problem.category, ProblemCategory::Set); +} + #[test] fn problem_ref_from_values_no_values_uses_all_defaults() { let problem = find_problem_type("MaximumIndependentSet").unwrap(); @@ -164,10 +201,20 @@ fn every_public_problem_schema_has_dimension_defaults() { #[test] fn every_alias_is_globally_unique() { + let canonical_names = inventory::iter:: + .into_iter() + .map(|entry| (entry.name.to_lowercase(), entry.name)) + .collect::>(); let mut seen: HashMap = HashMap::new(); for entry in inventory::iter:: { for alias in entry.aliases { let lower = alias.to_lowercase(); + if let Some(canonical) = canonical_names.get(&lower) { + panic!( + "Alias '{}' on {} conflicts with canonical problem name {}", + alias, entry.name, canonical, + ); + } if let Some(prev) = seen.get(&lower) { panic!( "Alias '{}' is used by both {} and {}", diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 44bac3c0d..473759c77 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -1,6 +1,20 @@ use super::*; use crate::registry::find_variant_entry; use std::collections::BTreeMap; +use std::str::FromStr; + +#[test] +fn problem_category_parses_only_declared_values() { + for category in ProblemCategory::ALL { + assert_eq!(ProblemCategory::from_str(category.as_str()), Ok(category)); + } + assert_eq!( + ProblemCategory::from_str("unknown") + .unwrap_err() + .to_string(), + "unknown problem category `unknown`; expected one of: algebraic, formula, graph, misc, set" + ); +} #[test] fn test_collect_schemas_returns_all_problems() { @@ -70,15 +84,17 @@ fn test_schema_json_serialization() { let json = serde_json::to_string(&schemas).expect("Schemas should serialize to JSON"); assert!(json.contains("MaximumIndependentSet")); assert!(json.contains("graph")); + assert!(json.contains("\"category\":\"graph\"")); } #[test] fn test_field_info_json_fields() { let schemas = collect_schemas(); let sg = schemas.iter().find(|s| s.name == "SpinGlass").unwrap(); - assert_eq!(sg.fields.len(), 3); + assert_eq!(sg.fields.len(), 4); let field_names: Vec<&str> = sg.fields.iter().map(|f| f.name.as_str()).collect(); assert!(field_names.contains(&"graph")); + assert!(field_names.contains(&"num_vertices")); assert!(field_names.contains(&"couplings")); assert!(field_names.contains(&"fields")); for f in &sg.fields { diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index f8ec9d944..3d33b3456 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -1,5 +1,9 @@ -use crate::registry::variant::{validate_variant_aliases, variant_label}; -use std::collections::BTreeMap; +use crate::registry::variant::{ + validate_create_inputs, validate_direct_create_inputs, validate_variant_aliases, + variant_entries, variant_label, +}; +use crate::registry::{ConstructionError, CreateInputCodec, CreateInputInfo, FieldInfo}; +use std::collections::{BTreeMap, BTreeSet}; #[test] fn variant_alias_inventory_is_valid() { @@ -16,6 +20,193 @@ fn empty_problem_names() -> BTreeMap> { BTreeMap::new() } +const CREATE_INPUTS: &[CreateInputInfo] = &[ + CreateInputInfo { + name: "required_value", + type_name: "usize", + description: "A required value", + required: true, + codec: CreateInputCodec::Scalar, + }, + CreateInputInfo { + name: "optional_value", + type_name: "usize", + description: "An optional value", + required: false, + codec: CreateInputCodec::Scalar, + }, +]; + +#[test] +fn construction_contract_accepts_declared_inputs() { + let data = serde_json::json!({"required_value": 1, "optional_value": 2}); + assert_eq!(validate_create_inputs(CREATE_INPUTS, &data), Ok(())); +} + +#[test] +fn construction_contract_rejects_unknown_inputs() { + let data = serde_json::json!({"required_value": 1, "removed_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::UnknownInputs(vec![ + "removed_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_missing_required_inputs() { + let data = serde_json::json!({"optional_value": 2}); + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &data), + Err(ConstructionError::MissingInputs(vec![ + "required_value".to_string() + ])) + ); +} + +#[test] +fn construction_contract_rejects_non_object_values() { + assert_eq!( + validate_create_inputs(CREATE_INPUTS, &serde_json::json!([])), + Err(ConstructionError::ExpectedObject) + ); +} + +#[test] +fn construction_contract_rejects_duplicate_declarations() { + let duplicate = [CREATE_INPUTS[0], CREATE_INPUTS[0]]; + assert_eq!( + validate_create_inputs(&duplicate, &serde_json::json!({"required_value": 1})), + Err(ConstructionError::DuplicateInput( + "required_value".to_string() + )) + ); +} + +#[test] +fn catalog_custom_construction_metadata_is_well_formed() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + let label = variant_label(entry); + let mut names = BTreeSet::new(); + for input in inputs { + assert!( + !input.name.is_empty(), + "{label} declares an empty construction input name" + ); + assert!( + input + .name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()), + "{label} construction input `{}` must use snake_case", + input.name + ); + assert!( + names.insert(input.name), + "{label} declares construction input `{}` more than once", + input.name + ); + assert!( + !input.type_name.trim().is_empty(), + "{label} construction input `{}` has no Rust type", + input.name + ); + assert_eq!( + input.description, + input.description.trim(), + "{label} construction input `{}` has surrounding whitespace in its description", + input.name + ); + } + } +} + +#[test] +fn default_custom_construction_inputs_match_catalog_schema_fields() { + for entry in inventory::iter::() + .filter(|entry| entry.is_default && entry.create_inputs.is_some()) + { + let schema = inventory::iter::() + .find(|schema| schema.name == entry.name) + .unwrap_or_else(|| panic!("{} has no ProblemSchemaEntry", entry.name)); + let schema_names = schema + .fields + .iter() + .map(|field| field.name) + .collect::>(); + let input_names = entry + .create_inputs + .unwrap() + .iter() + .map(|input| input.name) + .collect::>(); + assert_eq!( + schema_names, + input_names, + "default variant {} catalog fields differ from its construction inputs", + variant_label(entry) + ); + } +} + +#[test] +fn every_custom_construction_contract_rejects_unknown_and_missing_inputs() { + for entry in inventory::iter::() { + let Some(inputs) = entry.create_inputs else { + continue; + }; + assert_eq!( + validate_create_inputs(inputs, &serde_json::json!({"unknown_input": null})), + Err(ConstructionError::UnknownInputs(vec![ + "unknown_input".to_string() + ])), + "{} accepted an undeclared construction input", + variant_label(entry) + ); + + let required = inputs + .iter() + .filter(|input| input.required) + .map(|input| input.name.to_string()) + .collect::>() + .into_iter() + .collect::>(); + let result = validate_create_inputs(inputs, &serde_json::json!({})); + if required.is_empty() { + assert_eq!( + result, + Ok(()), + "{} rejected an empty payload", + variant_label(entry) + ); + } else { + assert_eq!( + result, + Err(ConstructionError::MissingInputs(required)), + "{} did not report all missing required inputs", + variant_label(entry) + ); + } + } +} + +#[test] +fn construction_contract_direct_fields_are_required() { + let fields = [FieldInfo { + name: "value", + type_name: "usize", + description: "Stored value", + }]; + assert_eq!( + validate_direct_create_inputs(&fields, &serde_json::json!({})), + Err(ConstructionError::MissingInputs(vec!["value".to_string()])) + ); +} + #[test] fn validate_inner_accepts_valid_aliases() { let entries = vec![ @@ -122,3 +313,52 @@ fn variant_label_with_variant_dimensions() { "expected label to include k=K3, got: {label}" ); } + +#[test] +fn random_contract_input_names_are_unique() { + let entries = variant_entries(); + assert!(entries.iter().any(|entry| entry.random.is_some())); + + for entry in entries { + let Some(random) = entry.random else { + continue; + }; + let mut names = BTreeSet::new(); + for input in random.inputs { + assert!( + !input.name.is_empty(), + "{} has an empty random input", + variant_label(entry) + ); + assert!( + names.insert(input.name), + "{} declares random input `{}` more than once", + variant_label(entry), + input.name + ); + } + } +} + +#[test] +fn established_random_generation_models_remain_registered() { + let expected = " + DecisionMinimumVertexCover MaximumIndependentSet MinimumVertexCover MaximumClique + MinimumDominatingSet MaximalIS KClique MinimumCutIntoBoundedSets HamiltonianCircuit + HamiltonianPath HamiltonianPathBetweenTwoVertices LongestCircuit MinimumMaximalMatching + RootedTreeArrangement SteinerTree SteinerTreeInGraphs LengthBoundedDisjointPaths + MaximumAchromaticNumber MaximumDomaticNumber MinimumCoveringByCliques + MinimumIntersectionGraphBasis MaximumLeafSpanningTree GeneralizedHex + BottleneckTravelingSalesman MaxCut MaximumMatching TravelingSalesman SpinGlass KColoring + OptimalLinearArrangement MinimumSumMulticenter + "; + let registered = variant_entries() + .into_iter() + .filter(|entry| entry.random.is_some()) + .map(|entry| entry.name) + .collect::>(); + + for name in expected.split_whitespace() { + assert!(registered.contains(name), "{name} lost random generation"); + } +} diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index 97efc979f..2f514fd1f 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -31,7 +31,7 @@ fn test_acyclicpartition_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -55,7 +55,7 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } @@ -76,7 +76,7 @@ fn test_infeasible_instance() { let reduction: ReductionAcyclicPartitionToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index a97f6060e..a6dfb15d0 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -1,346 +1,5 @@ -use crate::expr::Expr; -use crate::rules::analysis::{ - check_connectivity, check_reachability_from_3sat, compare_overhead, find_dominated_rules, - ComparisonStatus, UnreachableReason, -}; -use crate::rules::graph::ReductionGraph; -use crate::rules::registry::ReductionOverhead; - -// --- Asymptotic normalization + comparison tests --- - -#[test] -fn test_compare_overhead_equal() { - let a = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let b = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - assert_eq!(compare_overhead(&a, &b), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_composite_smaller_degree() { - // primitive: num_vars = n^2, composite: num_vars = n → dominated - let prim = ReductionOverhead::new(vec![( - "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), - )]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_composite_worse() { - // primitive: num_vars = n, composite: num_vars = n^2 → not dominated - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![( - "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), - )]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_multi_field_mixed() { - // One field better, one worse → not dominated - let prim = ReductionOverhead::new(vec![ - ("num_vars", Expr::Var("n")), - ( - "num_constraints", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), - ), - ]); - let comp = ReductionOverhead::new(vec![ - ("num_vars", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), - ("num_constraints", Expr::Var("n")), - ]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_no_common_fields() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_spins", Expr::Var("n"))]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_unknown_exp() { - // Different exponential-vs-polynomial growth is still not decided by the - // monomial comparison fallback. - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Exp(Box::new(Expr::Var("n"))))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); -} - -#[test] -fn test_compare_overhead_unknown_log() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Log(Box::new(Expr::Var("n"))))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); -} - -#[test] -fn test_compare_overhead_exp_identity_after_asymptotic_normalization() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n + m)"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n) * exp(m)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_log_identity_after_asymptotic_normalization() { - // log(n) vs log(n^2): the new canonicalization engine keeps log(n^2) as-is - // (it doesn't simplify log(x^k) = k*log(x)), so polynomial comparison - // returns Unknown for non-polynomial log terms. - let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("log(n)"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("log(n^2)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); -} - -#[test] -fn test_compare_overhead_sqrt_identity_after_asymptotic_normalization() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("sqrt(n * m)"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("(n * m)^(1/2)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_additive_constant_after_asymptotic_normalization() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("n + 1"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_multivariate_product_vs_sum() { - // n * m (degree 2) vs n + m (degree 1): - // monomial n*m has exponents {n:1, m:1} - // monomials n, m each have exponent 1 in one variable - // n*m is NOT dominated by either n or m → composite is worse - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") + Expr::Var("m"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") * Expr::Var("m"))]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_multivariate_product_vs_square() { - // n * m (has m) vs n^2 (no m): incomparable - // n*m monomial {n:1, m:1} — dominated by n^2 {n:2}? - // exponent_n: 1 <= 2 ✓, exponent_m: 1 <= 0 ✗ → not dominated - let prim = ReductionOverhead::new(vec![( - "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), - )]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") * Expr::Var("m"))]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_sum_vs_single_var() { - // composite: n, primitive: n + m → composite ≤ primitive (n dominated by n) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") + Expr::Var("m"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_constant_factor() { - // 3*n vs n → same asymptotic class → dominated (equal) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Const(3.0) * Expr::Var("n"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -#[test] -fn test_compare_overhead_polynomial_expansion() { - // (n + m)^2 = n^2 + 2nm + m^2 (degree 2) vs n^3 (degree 3) - // Each monomial of composite has total degree ≤ 2, primitive has degree 3 - // n^2 dominated by n^3? exponent_n: 2 ≤ 3 ✓ → yes - // 2*n*m dominated by n^3? exponent_n: 1 ≤ 3 ✓, exponent_m: 1 ≤ 0 ✗ → no! - // So composite is NOT dominated — (n+m)^2 can exceed n^3 when m is large - let prim = ReductionOverhead::new(vec![( - "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(3.0)), - )]); - let comp = ReductionOverhead::new(vec![( - "num_vars", - Expr::pow(Expr::Var("n") + Expr::Var("m"), Expr::Const(2.0)), - )]); - assert_eq!( - compare_overhead(&prim, &comp), - ComparisonStatus::NotDominated - ); -} - -#[test] -fn test_compare_overhead_multi_field_all_smaller() { - // Both fields: composite has smaller degree → dominated - let prim = ReductionOverhead::new(vec![ - ("num_vars", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), - ( - "num_constraints", - Expr::pow(Expr::Var("n"), Expr::Const(3.0)), - ), - ]); - let comp = ReductionOverhead::new(vec![ - ("num_vars", Expr::Var("n")), - ("num_constraints", Expr::Var("n")), - ]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); -} - -// --- Integration tests: find_dominated_rules --- - -use std::collections::BTreeMap; - -#[test] -fn test_find_dominated_rules_returns_known_set() { - let graph = ReductionGraph::new(); - let (dominated, unknown) = find_dominated_rules(&graph); - - // Print for debugging - eprintln!("Dominated rules ({}):", dominated.len()); - for rule in &dominated { - let path_str: String = rule - .dominating_path - .steps - .iter() - .map(|s| s.to_string()) - .collect::>() - .join(" -> "); - eprintln!( - " {} -> {} dominated by [{}]", - rule.source_display(), - rule.target_display(), - path_str, - ); - } - eprintln!("\nUnknown comparisons ({}):", unknown.len()); - for u in &unknown { - eprintln!( - " {} -> {}: {}", - u.source_display(), - u.target_display(), - u.reason, - ); - } - - // ── Allow-list of expected dominated rules ── - // Keyed by (source_display, target_display) with full variant info. - // This list must be updated when new reductions are added. - let allowed: std::collections::HashSet<(&str, &str)> = [ - // Composite through CircuitSAT → ILP is better - ("Factoring", "ILP {variable: \"i32\"}"), - // KClique → BCBS → ILP is better than direct KClique → ILP - ( - "KClique {graph: \"SimpleGraph\"}", - "ILP {variable: \"bool\"}", - ), - // K2-SAT → QUBO via SAT → NAESAT → MaxCut → SpinGlass chain - ("KSatisfiability {k: \"K2\"}", "QUBO {weight: \"f64\"}"), - // K3-SAT → QUBO via MVC → MIS → MaxSetPacking chain - ("KSatisfiability {k: \"K3\"}", "QUBO {weight: \"f64\"}"), - // Knapsack -> ILP -> QUBO is better than the direct penalty reduction - ("Knapsack", "QUBO {weight: \"f64\"}"), - // MaxMatching → MaxSetPacking → ILP is better than direct MaxMatching → ILP - ( - "MaximumMatching {graph: \"SimpleGraph\", weight: \"i32\"}", - "ILP {variable: \"bool\"}", - ), - // ExactCoverBy3Sets → MaxSetPacking → ILP is better than direct ExactCoverBy3Sets → ILP - ("ExactCoverBy3Sets", "ILP {variable: \"bool\"}"), - // GraphPartitioning → MaxCut → SpinGlass → QUBO is better than direct GraphPartitioning → QUBO - ( - "GraphPartitioning {graph: \"SimpleGraph\"}", - "QUBO {weight: \"f64\"}", - ), - // KSat → DecisionMVC → MVC (via witness edge) dominates direct KSat → MVC - ( - "KSatisfiability {k: \"K3\"}", - "MinimumVertexCover {graph: \"SimpleGraph\", weight: \"i32\"}", - ), - ] - .into_iter() - .collect(); - - // Check: no unexpected dominated rules - for rule in &dominated { - let src = rule.source_display(); - let tgt = rule.target_display(); - assert!( - allowed.contains(&(src.as_str(), tgt.as_str())), - "Unexpected dominated rule: {} -> {} (dominated by {})", - src, - tgt, - rule.dominating_path - .steps - .iter() - .map(|s| s.to_string()) - .collect::>() - .join(" -> "), - ); - } - - // Check: no stale entries in allow-list - let found: std::collections::HashSet<(String, String)> = dominated - .iter() - .map(|r| (r.source_display(), r.target_display())) - .collect(); - for &(src, tgt) in &allowed { - assert!( - found.contains(&(src.to_string(), tgt.to_string())), - "Allow-list entry {:?} -> {:?} is stale (no longer dominated)", - src, - tgt, - ); - } -} - -#[test] -fn test_no_duplicate_primitive_rules_per_variant_pair() { - use crate::rules::registry::ReductionEntry; - use std::collections::HashSet; - - let mut seen = HashSet::new(); - for entry in inventory::iter:: { - let src_variant: BTreeMap = entry - .source_variant() - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let dst_variant: BTreeMap = entry - .target_variant() - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let key = ( - entry.source_name, - src_variant, - entry.target_name, - dst_variant, - ); - assert!( - seen.insert(key.clone()), - "Duplicate primitive rule: {} {:?} -> {} {:?}", - key.0, - key.1, - key.2, - key.3, - ); - } -} +use super::{check_connectivity, check_reachability_from_3sat, UnreachableReason}; +use crate::rules::ReductionGraph; // ---- Connectivity checks ---- diff --git a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs index 9c4cc1109..b6c29be62 100644 --- a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -46,7 +46,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = crate::solvers::ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] @@ -54,7 +54,7 @@ fn test_extract_solution_identity() { let source = small_instance(); let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source); let target_sol = vec![1, 1, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 1, 1, 0]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/bicliquecover_bmf.rs b/src/unit_tests/rules/bicliquecover_bmf.rs index 30c912988..686dbd117 100644 --- a/src/unit_tests/rules/bicliquecover_bmf.rs +++ b/src/unit_tests/rules/bicliquecover_bmf.rs @@ -28,11 +28,18 @@ fn test_bicliquecover_to_bmf_overhead_matches_target_shape() { let entry = inventory::iter::() .find(|entry| entry.source_name == "BicliqueCover" && entry.target_name == "BMF") .expect("BicliqueCover -> BMF reduction should be registered"); - let overhead = (entry.overhead_eval_fn)(&problem as &dyn std::any::Any); + let source_size = (entry.source_size_measure_fn)(&problem as &dyn std::any::Any); + let predicted = entry + .size_contract() + .unwrap() + .transform() + .unwrap() + .evaluate(&crate::size::EvaluatedSize::from_problem_size(&source_size)) + .unwrap(); - assert_eq!(overhead.get("rows"), Some(target.rows())); - assert_eq!(overhead.get("cols"), Some(target.cols())); - assert_eq!(overhead.get("rank"), Some(target.rank())); + assert_eq!(predicted.values().get("rows"), Some(&target.rows().into())); + assert_eq!(predicted.values().get("cols"), Some(&target.cols().into())); + assert_eq!(predicted.values().get("rank"), Some(&target.rank().into())); } #[test] @@ -49,7 +56,7 @@ fn test_bicliquecover_to_bmf_closed_loop_full_biclique() { let target_witness = BruteForce::new() .find_witness(target) .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); } @@ -64,7 +71,7 @@ fn test_bicliquecover_to_bmf_closed_loop_identity_rank2() { let target_witness = BruteForce::new() .find_witness(target) .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); } diff --git a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs index fa21abed9..7edabfdaf 100644 --- a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs @@ -29,7 +29,7 @@ fn test_biconnectivityaugmentation_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -44,7 +44,7 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -56,7 +56,7 @@ fn test_trivial_single_vertex() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("trivial ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -74,7 +74,7 @@ fn test_already_biconnected() { let ilp_sol = solver .solve(ilp) .expect("already biconnected should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/binpacking_ilp.rs b/src/unit_tests/rules/binpacking_ilp.rs index 772eb4601..e28c85335 100644 --- a/src/unit_tests/rules/binpacking_ilp.rs +++ b/src/unit_tests/rules/binpacking_ilp.rs @@ -34,7 +34,7 @@ fn test_binpacking_to_ilp_closed_loop() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(2))); @@ -52,7 +52,7 @@ fn test_single_item() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -67,7 +67,7 @@ fn test_same_weight_items() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); @@ -82,7 +82,7 @@ fn test_exact_fill() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -103,7 +103,7 @@ fn test_solution_extraction() { ilp_solution[9] = 1; // y_0 = 1 ilp_solution[10] = 1; // y_1 = 1 - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } @@ -135,7 +135,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/bmf_bicliquecover.rs b/src/unit_tests/rules/bmf_bicliquecover.rs index 216b79be6..cff85f3f8 100644 --- a/src/unit_tests/rules/bmf_bicliquecover.rs +++ b/src/unit_tests/rules/bmf_bicliquecover.rs @@ -29,7 +29,7 @@ fn test_bmf_to_bicliquecover_closed_loop_all_ones() { let target_witness = BruteForce::new() .find_witness(target) .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); assert!(problem.is_exact(&extracted)); @@ -46,7 +46,7 @@ fn test_bmf_to_bicliquecover_closed_loop_identity() { let target_witness = BruteForce::new() .find_witness(target) .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); assert!(problem.is_exact(&extracted)); diff --git a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs index 70452a9e1..8aa9b35a4 100644 --- a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs @@ -32,7 +32,7 @@ fn test_bottlenecktravelingsalesman_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -61,7 +61,7 @@ fn test_bottlenecktravelingsalesman_to_ilp_c4() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); @@ -76,7 +76,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); } @@ -92,7 +92,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle" ); } diff --git a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs index bd97a4a96..6ba819a21 100644 --- a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs @@ -30,7 +30,7 @@ fn test_boundedcomponentspanningforest_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -45,7 +45,7 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } @@ -65,7 +65,7 @@ fn test_single_component() { let ilp_sol = solver .solve(ilp) .expect("single component should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -81,7 +81,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCSFToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/capacityassignment_ilp.rs b/src/unit_tests/rules/capacityassignment_ilp.rs index a5efbb8bc..be844d901 100644 --- a/src/unit_tests/rules/capacityassignment_ilp.rs +++ b/src/unit_tests/rules/capacityassignment_ilp.rs @@ -57,7 +57,7 @@ fn test_capacityassignment_to_ilp_closed_loop() { let reduction: ReductionCAToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( ilp_value, bf_value, @@ -79,7 +79,7 @@ fn test_solution_extraction() { // link 0 → cap 1, link 1 → cap 0 // x_{0,0}=0, x_{0,1}=1, x_{0,2}=0, x_{1,0}=1, x_{1,1}=0, x_{1,2}=0 let ilp_solution = vec![0, 1, 0, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0]); // Verify extraction works (evaluation may or may not be feasible) let _ = problem.evaluate(&extracted); @@ -98,7 +98,7 @@ fn test_capacityassignment_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/circuit_ilp.rs b/src/unit_tests/rules/circuit_ilp.rs index 8ff85f961..6ded61762 100644 --- a/src/unit_tests/rules/circuit_ilp.rs +++ b/src/unit_tests/rules/circuit_ilp.rs @@ -119,6 +119,6 @@ fn test_circuit_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/circuit_sat.rs b/src/unit_tests/rules/circuit_sat.rs index 0f8cab804..0cc3ae6c4 100644 --- a/src/unit_tests/rules/circuit_sat.rs +++ b/src/unit_tests/rules/circuit_sat.rs @@ -26,7 +26,7 @@ fn test_circuitsat_to_satisfiability_closed_loop() { let target_solution = solve_satisfaction_problem(reduction.target_problem()) .expect("issue example should yield a SAT witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), source.num_variables()); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index e648499a3..e4220872a 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -157,7 +157,7 @@ fn test_constant_true() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // c should be 1 @@ -184,7 +184,7 @@ fn test_constant_false() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // c should be 0 @@ -215,7 +215,7 @@ fn test_multi_input_and() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // Variables sorted: c, x, y, z diff --git a/src/unit_tests/rules/closeststring_ilp.rs b/src/unit_tests/rules/closeststring_ilp.rs index 693626564..2c01604c7 100644 --- a/src/unit_tests/rules/closeststring_ilp.rs +++ b/src/unit_tests/rules/closeststring_ilp.rs @@ -57,7 +57,7 @@ fn test_closeststring_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let extracted_value = source.evaluate(&extracted); // The extracted center must be syntactically valid and match the BF optimum. @@ -87,11 +87,26 @@ fn test_closeststring_to_ilp_extract_known_center() { target_solution[4] = 1; // x_{2,0} target_solution[6] = 2; // R = 2 - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(2))); } +#[test] +fn test_closeststring_to_ilp_rejects_missing_one_hot_symbol() { + let source = ClosestString::new(2, vec![vec![0, 1]]); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = vec![0; reduction.target_problem().num_vars]; + + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "center position 0 has no selected symbol" + ); +} + #[test] fn test_closeststring_to_ilp_ternary_alphabet() { // q = 3, m = 2, three strings forcing a nonzero radius. The optimum @@ -118,7 +133,7 @@ fn test_closeststring_to_ilp_single_string_zero_radius() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 1]); assert_eq!(source.evaluate(&extracted), Min(Some(0))); } diff --git a/src/unit_tests/rules/closestsubstring_ilp.rs b/src/unit_tests/rules/closestsubstring_ilp.rs index 22fc89e24..1bae41878 100644 --- a/src/unit_tests/rules/closestsubstring_ilp.rs +++ b/src/unit_tests/rules/closestsubstring_ilp.rs @@ -70,6 +70,21 @@ fn test_closestsubstring_to_ilp_structure() { } } +#[test] +fn test_closestsubstring_to_ilp_rejects_missing_one_hot_symbol() { + let source = issue_instance(); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = vec![0; reduction.target_problem().num_vars]; + + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "center position 0 has no selected value" + ); +} + #[test] fn test_closestsubstring_to_ilp_closed_loop() { let source = issue_instance(); @@ -79,7 +94,7 @@ fn test_closestsubstring_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Extracted config must be syntactically valid (length ell + n = 6) and // match the brute-force optimum. @@ -112,7 +127,7 @@ fn test_closestsubstring_to_ilp_zero_radius_when_common_substring_exists() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let extracted_value = source.evaluate(&extracted); assert!(extracted_value.is_valid()); @@ -157,7 +172,7 @@ fn test_closestsubstring_to_ilp_extract_known_solution() { target_solution[6 + 6] = 1; // y_{3, 0} target_solution[ilp.num_vars - 1] = 1; // R = 1 - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0, 0, 1, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(1))); } diff --git a/src/unit_tests/rules/closestvectorproblem_qubo.rs b/src/unit_tests/rules/closestvectorproblem_qubo.rs index 90938593d..2bd20fd6b 100644 --- a/src/unit_tests/rules/closestvectorproblem_qubo.rs +++ b/src/unit_tests/rules/closestvectorproblem_qubo.rs @@ -50,8 +50,14 @@ fn test_closestvectorproblem_to_qubo_example_matrix_coefficients() { fn test_extract_solution_ignores_duplicate_exact_range_encodings() { let reduction = ReduceTo::>::reduce_to(&canonical_cvp()); - assert_eq!(reduction.extract_solution(&[1, 1, 0, 1, 1, 0]), vec![3, 3]); - assert_eq!(reduction.extract_solution(&[0, 0, 1, 0, 0, 1]), vec![3, 3]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0, 1, 1, 0]).unwrap(), + vec![3, 3] + ); + assert_eq!( + reduction.extract_solution(&[0, 0, 1, 0, 0, 1]).unwrap(), + vec![3, 3] + ); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/clustering_ilp.rs b/src/unit_tests/rules/clustering_ilp.rs index a35273e37..9de89081b 100644 --- a/src/unit_tests/rules/clustering_ilp.rs +++ b/src/unit_tests/rules/clustering_ilp.rs @@ -64,7 +64,9 @@ fn test_clustering_to_ilp_solution_extraction() { let problem = canonical_yes_instance(); let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem); - let extracted = reduction.extract_solution(&[1, 0, 1, 0, 0, 1, 0, 1]); + let extracted = reduction + .extract_solution(&[1, 0, 1, 0, 0, 1, 0, 1]) + .unwrap(); assert_eq!(extracted, vec![0, 0, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -74,5 +76,5 @@ fn test_clustering_to_ilp_infeasible_instance_is_infeasible() { let problem = infeasible_instance(); let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index f436f39b5..1e1fd481d 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -62,7 +62,7 @@ fn test_coloring_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify the extracted solution is valid for the original problem assert!( @@ -87,7 +87,7 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify validity assert!( @@ -113,7 +113,7 @@ fn test_ilp_infeasible_triangle_2_colors() { // ILP should be infeasible let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Triangle with 2 colors should be infeasible" ); } @@ -129,7 +129,7 @@ fn test_solution_extraction() { // vertex 2 has color 0 (x_{2,0} = 1) // Variables are indexed as: v0c0, v0c1, v0c2, v1c0, v1c1, v1c2, v2c0, v2c1, v2c2 let ilp_solution = vec![0, 1, 0, 0, 0, 1, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 2, 0]); @@ -162,7 +162,7 @@ fn test_empty_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -179,7 +179,7 @@ fn test_complete_graph_k4() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); @@ -202,7 +202,7 @@ fn test_complete_graph_k4_with_3_colors_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "K4 with 3 colors should be infeasible"); + assert!(result.is_err(), "K4 with 3 colors should be infeasible"); } #[test] @@ -216,7 +216,7 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); @@ -234,7 +234,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution)); @@ -252,7 +252,7 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); } @@ -266,7 +266,7 @@ fn test_single_edge() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); assert_ne!(extracted[0], extracted[1]); diff --git a/src/unit_tests/rules/coloring_qubo.rs b/src/unit_tests/rules/coloring_qubo.rs index daa61681a..6e6cf82bc 100644 --- a/src/unit_tests/rules/coloring_qubo.rs +++ b/src/unit_tests/rules/coloring_qubo.rs @@ -15,7 +15,7 @@ fn test_kcoloring_to_qubo_closed_loop() { // All solutions should extract to valid colorings for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } @@ -34,7 +34,7 @@ fn test_kcoloring_to_qubo_path() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } @@ -54,7 +54,7 @@ fn test_kcoloring_to_qubo_reversed_edges() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs index 42a2be730..bf4c0d3c9 100644 --- a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs +++ b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs @@ -49,7 +49,7 @@ fn test_cbm_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs index 0c7f7d62b..e9b841c4e 100644 --- a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -31,7 +31,7 @@ fn test_coma_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Also verify that brute-force on the source agrees @@ -56,7 +56,7 @@ fn test_coma_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs index 040020993..bbba2c718 100644 --- a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs @@ -41,7 +41,7 @@ fn test_cos_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Verify brute-force on source agrees @@ -70,7 +70,7 @@ fn test_cos_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -83,6 +83,6 @@ fn test_cos_to_ilp_trivial() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index 1cb1d59ad..185defcf8 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -56,7 +56,7 @@ fn test_cdft_to_ilp_solution_encoding_round_trip() { let problem = small_yes_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = reduction.encode_source_solution(&small_yes_witness()); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, small_yes_witness()); } @@ -65,7 +65,7 @@ fn test_cdft_to_ilp_unsat_instance_is_infeasible() { let problem = small_no_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let solver = ILPSolver::new(); - assert!(solver.solve(reduction.target_problem()).is_none()); + assert!(solver.solve(reduction.target_problem()).is_err()); } #[test] @@ -73,7 +73,7 @@ fn test_cdft_to_ilp_solve_reduced() { let problem = small_yes_instance(); let solver = ILPSolver::new(); let solution = solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find a satisfying assignment"); assert!(problem.evaluate(&solution)); } @@ -91,7 +91,7 @@ fn test_consistency_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -123,7 +123,7 @@ fn test_cdft_to_ilp_issue_instance_closed_loop() { let target_solution = solver .solve(reduction.target_problem()) .expect("ILP solver should find a feasible solution for the issue instance"); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert!( problem.evaluate(&source_solution), "extracted source solution must satisfy the original CDFT instance" @@ -135,6 +135,6 @@ fn test_cdft_to_ilp_issue_instance_encoding_round_trip() { let problem = issue_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = reduction.encode_source_solution(&issue_witness()); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, issue_witness()); } diff --git a/src/unit_tests/rules/cost.rs b/src/unit_tests/rules/cost.rs deleted file mode 100644 index c489b7fe3..000000000 --- a/src/unit_tests/rules/cost.rs +++ /dev/null @@ -1,86 +0,0 @@ -use super::*; -use crate::expr::Expr; - -fn test_overhead() -> ReductionOverhead { - ReductionOverhead::new(vec![ - ("n", Expr::Const(2.0) * Expr::Var("n")), - ("m", Expr::Var("m")), - ]) -} - -#[test] -fn test_minimize_single() { - let cost_fn = Minimize("n"); - let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); - let overhead = test_overhead(); - - assert_eq!(cost_fn.edge_cost(&overhead, &size), 20.0); // 2 * 10 -} - -#[test] -fn test_minimize_steps() { - let cost_fn = MinimizeSteps; - let size = ProblemSize::new(vec![("n", 100)]); - let overhead = test_overhead(); - - assert_eq!(cost_fn.edge_cost(&overhead, &size), 1.0); -} - -#[test] -fn test_custom_cost() { - let cost_fn = CustomCost(|overhead: &ReductionOverhead, size: &ProblemSize| { - let output = overhead.evaluate_output_size(size); - (output.get("n").unwrap_or(0) + output.get("m").unwrap_or(0)) as f64 - }); - let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); - let overhead = test_overhead(); - - // output n = 20, output m = 5 - // custom = 20 + 5 = 25 - assert_eq!(cost_fn.edge_cost(&overhead, &size), 25.0); -} - -#[test] -fn test_minimize_missing_field() { - let cost_fn = Minimize("nonexistent"); - let size = ProblemSize::new(vec![("n", 10)]); - let overhead = test_overhead(); - - assert_eq!(cost_fn.edge_cost(&overhead, &size), 0.0); -} - -#[test] -fn test_minimize_output_size() { - let cost_fn = MinimizeOutputSize; - let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); - let overhead = test_overhead(); - - // output n = 20, output m = 5 → total = 25 - assert_eq!(cost_fn.edge_cost(&overhead, &size), 25.0); -} - -#[test] -fn test_minimize_steps_then_overhead() { - let cost_fn = MinimizeStepsThenOverhead; - let size = ProblemSize::new(vec![("n", 10), ("m", 5)]); - let overhead = test_overhead(); - - let cost = cost_fn.edge_cost(&overhead, &size); - // Should be dominated by the step weight (1e9) with small overhead tiebreaker - assert!(cost > 1e8, "step weight should dominate"); - assert!(cost < 2e9, "should be roughly 1e9 + small tiebreaker"); - - // Two edges with different overhead should have different costs - let small_overhead = - ReductionOverhead::new(vec![("n", Expr::Const(1.0)), ("m", Expr::Const(1.0))]); - let cost_small = cost_fn.edge_cost(&small_overhead, &size); - // Both have the same step weight but different tiebreakers - assert!(cost > cost_small, "larger overhead should cost more"); -} - -#[test] -fn test_problem_size_total() { - let size = ProblemSize::new(vec![("a", 3), ("b", 7), ("c", 10)]); - assert_eq!(size.total(), 20); - assert_eq!(ProblemSize::new(vec![]).total(), 0); -} diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index c6418de96..cb4f3f056 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -9,7 +9,7 @@ use crate::types::{One, Or}; fn decision_mds( num_vertices: usize, edges: &[(usize, usize)], - k: i32, + k: i64, ) -> Decision> { Decision::new( MinimumDominatingSet::new( @@ -58,7 +58,7 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in for target_solution in target_solutions { assert_eq!(target.evaluate(&target_solution).unwrap(), 4); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -80,13 +80,14 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins "target should still have optimal K-center placements" ); - let threshold = source.inner().graph().num_vertices() as i32 - source.k() as i32; + let threshold = i64::try_from(source.inner().graph().num_vertices()).unwrap() + - i64::try_from(source.k()).unwrap(); for target_solution in target_solutions { let target_value = target.evaluate(&target_solution).unwrap(); assert_eq!(target_value, 6); assert!(target_value > threshold); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(false)); } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs index f87bf9e65..ac8e93966 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -10,7 +10,7 @@ use crate::types::{One, Or}; fn decision_mds( num_vertices: usize, edges: &[(usize, usize)], - k: i32, + k: i64, ) -> Decision> { Decision::new( MinimumDominatingSet::new( @@ -58,7 +58,7 @@ fn test_decisionminimumdominatingset_to_minmaxmulticenter_closed_loop() { ); for target_solution in target_solutions { - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index db7bf2c64..b2c19493c 100644 --- a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -10,7 +10,7 @@ fn decision_mvc( num_vertices: usize, edges: &[(usize, usize)], weights: &[i32], - k: i32, + k: i64, ) -> Decision> { Decision::new( MinimumVertexCover::new( @@ -42,7 +42,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_closed_loop() { assert!(reduction.target_problem().evaluate(&target_witness).0); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(extracted, cover); assert!(source.evaluate(&extracted).0); } @@ -55,7 +55,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_ignores_isolated_vertic let target_witness = reduction.build_target_witness(&[1, 0, 0]); assert!(reduction.target_problem().evaluate(&target_witness).0); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(extracted[2], 0); assert!(source.evaluate(&extracted).0); @@ -74,7 +74,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_yes_when_k_covers let witness = BruteForce::new() .find_witness(target) .expect("triangle should have a Hamiltonian circuit"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs index e13a85326..1dedb771b 100644 --- a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs @@ -38,7 +38,7 @@ fn test_directedhamiltonianpath_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -71,7 +71,7 @@ fn test_directedhamiltonianpath_to_ilp_issue_example() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should find a path"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -89,7 +89,7 @@ fn test_directedhamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Graph with no Hamiltonian path should be infeasible" ); } diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index 42f09bd3a..2f012e531 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -81,7 +81,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -94,7 +94,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should produce infeasible ILP" ); } @@ -106,7 +106,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_disallows_using_other_commodity_ let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "commodity 1 must conserve flow at commodity 2's source in the ILP reduction" ); } @@ -124,7 +124,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_extract_solution() { target_solution[8 + 3] = 1; // f2 on arc (1,3) target_solution[8 + 7] = 1; // f2 on arc (3,5) - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 16); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/eulerianpath_ilp.rs b/src/unit_tests/rules/eulerianpath_ilp.rs index ce690f067..f42e207f7 100644 --- a/src/unit_tests/rules/eulerianpath_ilp.rs +++ b/src/unit_tests/rules/eulerianpath_ilp.rs @@ -52,7 +52,7 @@ fn test_eulerianpath_to_ilp_empty_instance() { let solution = ILPSolver::new() .solve(ilp) .expect("Empty ILP should be feasible"); - let extracted = reduction.extract_solution(&solution); + let extracted = reduction.extract_solution(&solution).unwrap(); assert_eq!(extracted.len(), 0); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -66,7 +66,7 @@ fn test_eulerianpath_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for a YES instance"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), source.num_arcs()); assert!( @@ -88,7 +88,7 @@ fn test_eulerianpath_to_ilp_infeasible_no_instance() { // The ILP must report infeasibility for a NO instance. let solution = ILPSolver::new().solve(reduction.target_problem()); assert!( - solution.is_none(), + solution.is_err(), "ILP must be infeasible for a degree-unbalanced NO instance, got {:?}", solution ); @@ -104,7 +104,7 @@ fn test_eulerianpath_to_ilp_closed_circuit_with_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for a closed Eulerian circuit"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 3); assert!( source.is_valid_solution(&extracted), diff --git a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs index 8c0d53d7c..4902ec795 100644 --- a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -44,5 +44,8 @@ fn test_exactcoverby3sets_to_algebraicequationsovergf2_extract_solution_is_ident let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 1]).unwrap(), + vec![1, 0, 1] + ); } diff --git a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 241b00945..6905c86b0 100644 --- a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -45,7 +45,7 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_structure() { // Diameter bound is always 4 in the canonical construction. assert_eq!(target.diameter_bound(), 4); // Weight bound B = 4q + m + 2. - let expected_weight_bound = (4 * q + m + 2) as i32; + let expected_weight_bound = i64::try_from(4 * q + m + 2).unwrap(); assert_eq!(*target.weight_bound(), expected_weight_bound); // Verify the first two edges are the forced-center path with weight 1. @@ -73,13 +73,13 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_extract_solution() { let mut target_config = vec![0; reduction.target_problem().num_edges()]; target_config[2] = 1; target_config[3] = 1; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 1]); // Only s_0 selected via root edge. let mut target_config = vec![0; reduction.target_problem().num_edges()]; target_config[2] = 1; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_ilp.rs b/src/unit_tests/rules/exactcoverby3sets_ilp.rs index cb33bceb8..c9c4247b6 100644 --- a/src/unit_tests/rules/exactcoverby3sets_ilp.rs +++ b/src/unit_tests/rules/exactcoverby3sets_ilp.rs @@ -27,7 +27,7 @@ fn test_exactcoverby3sets_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -36,7 +36,7 @@ fn test_solution_extraction() { let problem = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5]]); let reduction: ReductionX3CToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![1, 1]; // select both triples - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs index 14c53bb1d..f38e59322 100644 --- a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs @@ -61,7 +61,7 @@ fn test_exactcoverby3sets_to_maximumsetpacking_unsatisfiable() { assert_eq!(target.evaluate(&best), Max(Some(1))); // q = 2, but packing value is 1 < 2, so no exact cover exists - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -78,6 +78,6 @@ fn test_exactcoverby3sets_to_maximumsetpacking_optimal_value() { // Maximum packing: S0 + S1 = 2 disjoint sets = q assert_eq!(target.evaluate(&best), Max(Some(2))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs index 074f6ddc8..d37c06594 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs @@ -65,7 +65,7 @@ fn test_exactcoverby3sets_to_minimumaxiomset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&optimal), Min(Some(3))); - let extracted = reduction.extract_solution(&optimal); + let extracted = reduction.extract_solution(&optimal).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -74,6 +74,8 @@ fn test_extract_solution_reads_only_set_sentence_axioms() { let source = issue_yes_instance(); let reduction = ReduceTo::::reduce_to(&source); - let extracted = reduction.extract_solution(&[1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1]); + let extracted = reduction + .extract_solution(&[1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1]) + .unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index a974fbe9c..9cd3098d7 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -76,7 +76,7 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&best), Min(Some(3))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -85,6 +85,9 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_extract_solution_ident let source = issue_yes_instance(); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 1, 0]), vec![1, 1, 0]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0]).unwrap(), + vec![1, 1, 0] + ); assert!(source.evaluate(&[1, 1, 0]).0); } diff --git a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs index a8e7ed6e3..4265bcb96 100644 --- a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs @@ -56,7 +56,7 @@ fn test_exactcoverby3sets_to_staffscheduling_unique_cover() { let solutions = solver.find_all_witnesses(target); // Each satisfying target config should extract to selecting all 3 subsets for sol in &solutions { - let extracted = result.extract_solution(sol); + let extracted = result.extract_solution(sol).unwrap(); assert!( source.evaluate(&extracted).0, "Extracted solution must be valid" @@ -65,7 +65,7 @@ fn test_exactcoverby3sets_to_staffscheduling_unique_cover() { // There should be exactly one satisfying assignment (up to extraction) let extracted_solutions: Vec> = solutions .iter() - .map(|s| result.extract_solution(s)) + .map(|s| result.extract_solution(s).unwrap()) .collect(); assert!( extracted_solutions.iter().all(|s| *s == vec![1, 1, 1]), @@ -81,7 +81,7 @@ fn test_exactcoverby3sets_to_staffscheduling_extract_solution() { // StaffScheduling config: [1, 1, 0, 0] means 1 worker on schedule 0 and 1 on schedule 1 let target_config = vec![1, 1, 0, 0]; - let extracted = result.extract_solution(&target_config); + let extracted = result.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 0]); // Verify the extracted solution is valid in the source @@ -89,7 +89,7 @@ fn test_exactcoverby3sets_to_staffscheduling_extract_solution() { // Config with 0 workers everywhere should extract to all-zero (no subsets selected) let empty_config = vec![0, 0, 0, 0]; - let extracted_empty = result.extract_solution(&empty_config); + let extracted_empty = result.extract_solution(&empty_config).unwrap(); assert_eq!(extracted_empty, vec![0, 0, 0, 0]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs index 6433f7dcd..4aba685b2 100644 --- a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -36,7 +36,10 @@ fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 1]).unwrap(), + vec![1, 0, 1] + ); } #[test] diff --git a/src/unit_tests/rules/expectedretrievalcost_ilp.rs b/src/unit_tests/rules/expectedretrievalcost_ilp.rs index 03fb59663..002ecd21c 100644 --- a/src/unit_tests/rules/expectedretrievalcost_ilp.rs +++ b/src/unit_tests/rules/expectedretrievalcost_ilp.rs @@ -42,7 +42,7 @@ fn test_expectedretrievalcost_to_ilp_bf_vs_ilp() { let bf_cost = problem.expected_cost(&bf_witness).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_cost = problem.expected_cost(&extracted).unwrap(); // ILP cost should match BF optimal cost @@ -70,7 +70,7 @@ fn test_solution_extraction() { // z_{1,1,1,1} = x_{1,1}*x_{1,1} = 1: offset 4 + 3*4 + 3 = 4+15=19 ilp_solution[19] = 1; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -83,7 +83,7 @@ fn test_expectedretrievalcost_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!( matches!(value, Min(Some(_))), diff --git a/src/unit_tests/rules/factoring_circuit.rs b/src/unit_tests/rules/factoring_circuit.rs index 5cea21a14..da1389982 100644 --- a/src/unit_tests/rules/factoring_circuit.rs +++ b/src/unit_tests/rules/factoring_circuit.rs @@ -210,7 +210,7 @@ fn test_extract_solution() { } } - let factoring_sol = reduction.extract_solution(&sol); + let factoring_sol = reduction.extract_solution(&sol).unwrap(); assert_eq!( factoring_sol.len(), 4, diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index 85bc86003..3cffdfdba 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -50,7 +50,7 @@ fn test_factor_6() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify it's a valid factorization assert!(problem.is_valid_factorization(&extracted)); @@ -75,7 +75,7 @@ fn test_factor_15() { let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); // 4. Extract factoring solution - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // 5. Verify: solution is valid and p × q = 15 assert!(problem.is_valid_factorization(&extracted)); @@ -92,7 +92,7 @@ fn test_factor_35() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -109,7 +109,7 @@ fn test_factor_one() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -126,7 +126,7 @@ fn test_factor_prime() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -143,7 +143,7 @@ fn test_factor_square() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -161,7 +161,7 @@ fn test_infeasible_target_too_large() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "Should be infeasible"); + assert!(result.is_err(), "Should be infeasible"); } #[test] @@ -173,7 +173,7 @@ fn test_factoring_to_ilp_closed_loop() { // Get ILP solution let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let ilp_factors = reduction.extract_solution(&ilp_solution); + let ilp_factors = reduction.extract_solution(&ilp_solution).unwrap(); // Get brute force solutions let bf = BruteForce::new(); @@ -207,7 +207,7 @@ fn test_solution_extraction() { // z_10 = p_1 * q_0 = 1, z_11 = p_1 * q_1 = 1 // Variables: [p0, p1, q0, q1, z00, z01, z10, z11, c0, c1, c2, c3] let ilp_solution = vec![0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should extract [p0, p1, q0, q1] = [0, 1, 1, 1] assert_eq!(extracted, vec![0, 1, 1, 1]); @@ -239,7 +239,7 @@ fn test_solve_reduced() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let solution = reduction.extract_solution(&ilp_solution); + let solution = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&solution)); } @@ -253,7 +253,7 @@ fn test_asymmetric_bit_widths() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); diff --git a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs index b4c0c36ef..611417ca2 100644 --- a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs +++ b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs @@ -27,7 +27,7 @@ fn test_feasible_register_assignment_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("feasible source instance should yield a feasible ILP"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); let mut sorted = extracted.clone(); @@ -41,7 +41,7 @@ fn test_feasible_register_assignment_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-conflict source instance should reduce to an infeasible ILP" ); } diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 23195381c..3bc70457c 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -19,7 +19,7 @@ fn test_flowshopscheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -33,7 +33,7 @@ fn test_flowshopscheduling_to_ilp_infeasible() { let problem = FlowShopScheduling::new(2, vec![vec![5, 5], vec![5, 5], vec![5, 5]], 6); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible FSS should produce infeasible ILP" ); } @@ -46,7 +46,7 @@ fn test_flowshopscheduling_to_ilp_single_job() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("single-job ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -62,6 +62,6 @@ fn test_flowshopscheduling_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index e915ac482..3736c0cb5 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1,4 +1,5 @@ use super::*; +use crate::expr::Expr; use crate::models::algebraic::{ILP, QUBO}; use crate::models::formula::{ CircuitSAT, Maximum2Satisfiability, NAESatisfiability, Satisfiability, @@ -7,9 +8,9 @@ use crate::models::graph::MaxCut; use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; -use crate::rules::cost::{Minimize, MinimizeSteps}; -use crate::rules::graph::{classify_problem_category, ReductionMode, ReductionStep}; -use crate::rules::registry::{EdgeCapabilities, ReductionEntry}; +use crate::registry::ProblemCategory; +use crate::rules::graph::{ReductionMode, ReductionStep}; +use crate::rules::registry::{ReductionEntry, ReductionSizeDeclarations}; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -17,7 +18,53 @@ use crate::types::{One, ProblemSize, Sum}; use petgraph::graph::DiGraph; use serde_json::json; use std::any::Any; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn empty_size_contract() -> Result { + ReductionSizeContract::new( + "synthetic edge", + ReductionSizeDeclarations { + relation: None, + fields: vec![], + unavailable: vec![crate::rules::registry::UnavailableSizeField { + field: "size", + reason: "synthetic edge does not declare a symbolic size", + }], + }, + ) +} + +fn symbolic_size_edge(fields: &[(&'static str, &str)], turing: bool) -> ReductionEdgeData { + ReductionEdgeData { + size_contract: ReductionSizeContract::new( + "synthetic edge", + ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: fields + .iter() + .map(|(field, expression)| (*field, Expr::try_parse(expression).unwrap())) + .collect(), + unavailable: vec![], + }, + ), + reduce_fn: Some(|_| panic!("size search must not execute reductions")), + reduce_aggregate_fn: None, + turing, + } +} + +fn named_path(names: &[&str]) -> ReductionPath { + ReductionPath { + steps: names + .iter() + .map(|name| ReductionStep { + name: (*name).to_string(), + variant: BTreeMap::new(), + }) + .collect(), + } +} #[derive(Clone)] struct AggregateChainSource; @@ -165,8 +212,11 @@ impl ReductionResult for SourceToMiddleWitnessResult { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -180,6 +230,45 @@ fn reduce_source_to_middle_witness( }) } +static SHARED_PREFIX_EXECUTIONS: AtomicUsize = AtomicUsize::new(0); + +fn reduce_counted_source_to_middle_witness( + any: &dyn Any, +) -> Box { + SHARED_PREFIX_EXECUTIONS.fetch_add(1, Ordering::SeqCst); + reduce_source_to_middle_witness(any) +} + +struct MiddleToTargetWitnessResult { + target: AggregateChainTarget, +} + +impl ReductionResult for MiddleToTargetWitnessResult { + type Source = AggregateChainMiddle; + type Target = AggregateChainTarget; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) + } +} + +fn reduce_middle_to_target_witness( + any: &dyn Any, +) -> Box { + any.downcast_ref::() + .expect("expected AggregateChainMiddle"); + Box::new(MiddleToTargetWitnessResult { + target: AggregateChainTarget, + }) +} + fn reduce_natural_variant_witness( any: &dyn Any, ) -> Box { @@ -232,6 +321,205 @@ fn build_two_node_graph( } } +#[test] +fn execute_paths_executes_a_shared_prefix_once() { + SHARED_PREFIX_EXECUTIONS.store(0, Ordering::SeqCst); + let witness_edge = |reduce_fn| ReductionEdgeData { + size_contract: empty_size_contract(), + reduce_fn: Some(reduce_fn), + reduce_aggregate_fn: None, + turing: false, + }; + let graph = ReductionGraph::from_test_edges( + &[ + AggregateChainSource::NAME, + AggregateChainMiddle::NAME, + AggregateChainTarget::NAME, + ], + &[ + ( + AggregateChainSource::NAME, + AggregateChainMiddle::NAME, + witness_edge(reduce_counted_source_to_middle_witness), + ), + ( + AggregateChainMiddle::NAME, + AggregateChainTarget::NAME, + witness_edge(reduce_middle_to_target_witness), + ), + ], + ); + let paths = vec![ + named_path(&[AggregateChainSource::NAME, AggregateChainMiddle::NAME]), + named_path(&[ + AggregateChainSource::NAME, + AggregateChainMiddle::NAME, + AggregateChainTarget::NAME, + ]), + ]; + + let executed = graph + .execute_paths(&paths, &AggregateChainSource) + .expect("both paths are executable"); + + assert_eq!(executed.len(), 2); + assert_eq!(SHARED_PREFIX_EXECUTIONS.load(Ordering::SeqCst), 1); +} + +#[test] +fn path_size_contract_errors_are_typed_and_isolated() { + let single = named_path(&["A"]); + let empty = ReductionPath { steps: vec![] }; + let graph = ReductionGraph::from_test_edges(&["A", "B"], &[]); + assert!(graph.path_size_transforms(&single).unwrap().is_empty()); + assert!(graph + .compose_path_size_transform(&single) + .unwrap() + .is_none()); + assert!(matches!( + graph.compose_path_size_transform(&empty), + Err(PathSizeError::EmptyPath) + )); + + let unknown = named_path(&["A", "Unknown"]); + assert!(matches!( + graph.path_size_transforms(&unknown), + Err(PathSizeError::UnknownNode { .. }) + )); + + let disconnected = named_path(&["A", "B"]); + assert!(matches!( + graph.path_size_transforms(&disconnected), + Err(PathSizeError::MissingEdge { .. }) + )); + + let unavailable = build_two_node_graph( + "A", + BTreeMap::new(), + "B", + BTreeMap::new(), + ReductionEdgeData { + size_contract: empty_size_contract(), + reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), + reduce_aggregate_fn: None, + turing: false, + }, + ); + assert!(matches!( + unavailable.path_size_transforms(&disconnected), + Err(PathSizeError::Unavailable { .. }) + )); + + let invalid_contract = Err(SizeContractError::EmptyUnavailableReason { + edge: "A -> B".into(), + field: "x".into(), + }); + let invalid = build_two_node_graph( + "A", + BTreeMap::new(), + "B", + BTreeMap::new(), + ReductionEdgeData { + size_contract: invalid_contract, + reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), + reduce_aggregate_fn: None, + turing: false, + }, + ); + assert!(matches!( + invalid.path_size_transforms(&disconnected), + Err(PathSizeError::InvalidContract { .. }) + )); + + let turing = build_two_node_graph( + "A", + BTreeMap::new(), + "B", + BTreeMap::new(), + symbolic_size_edge(&[("x", "n")], true), + ); + assert!(matches!( + turing.path_size_transforms(&disconnected), + Err(PathSizeError::TuringEdge { .. }) + )); +} + +#[test] +fn path_size_composition_and_evaluation_propagate_step_errors() { + let missing_input = build_two_node_graph( + "A", + BTreeMap::new(), + "B", + BTreeMap::new(), + symbolic_size_edge(&[("x", "n")], false), + ); + let direct = named_path(&["A", "B"]); + assert!(matches!( + missing_input.evaluate_path_size(&direct, &ProblemSize::default()), + Err(PathSizeError::Step { .. }) + )); + + let invalid_composition = ReductionGraph::from_test_edges( + &["A", "B", "C"], + &[ + ("A", "B", symbolic_size_edge(&[("x", "n")], false)), + ("B", "C", symbolic_size_edge(&[("z", "y")], false)), + ], + ); + let chained = named_path(&["A", "B", "C"]); + assert!(matches!( + invalid_composition.compose_path_size_transform(&chained), + Err(PathSizeError::Step { .. }) + )); + + let valid = ReductionGraph::from_test_edges( + &["A", "B", "C"], + &[ + ("A", "B", symbolic_size_edge(&[("x", "n + 1")], false)), + ("B", "C", symbolic_size_edge(&[("z", "2 * x")], false)), + ], + ); + assert_eq!( + valid + .evaluate_path_size(&chained, &ProblemSize::new(vec![("n", 3)])) + .unwrap() + .values() + .get("z"), + Some(&num_bigint::BigUint::from(8u8)) + ); +} + +#[test] +fn symbolic_path_enumeration_retains_every_path_without_ranking() { + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "C", "T"], + &[ + ("S", "A", symbolic_size_edge(&[("x", "2")], false)), + ("S", "B", symbolic_size_edge(&[("x", "1")], false)), + ("S", "C", symbolic_size_edge(&[("x", "3")], false)), + ("A", "T", symbolic_size_edge(&[("y", "x")], false)), + ("B", "T", symbolic_size_edge(&[("y", "x")], false)), + ("C", "T", symbolic_size_edge(&[("y", "x")], false)), + ], + ); + let variant = BTreeMap::new(); + let paths = graph.find_all_paths_mode("S", &variant, "T", &variant, ReductionMode::Witness); + assert_eq!(paths.len(), 3); + let values: BTreeSet<_> = paths + .iter() + .map(|path| { + graph + .evaluate_path_size(path, &ProblemSize::default()) + .unwrap() + .values() + .get("y") + .unwrap() + .clone() + }) + .collect(); + assert_eq!(values, BTreeSet::from([1u8.into(), 2u8.into(), 3u8.into()])); +} + #[test] fn test_find_direct_path() { let graph = ReductionGraph::new(); @@ -278,20 +566,20 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { source_idx, middle_idx, ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); graph.add_edge( middle_idx, target_idx, ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_middle_to_target_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); @@ -343,35 +631,31 @@ fn witness_path_search_rejects_aggregate_only_edge() { AggregateChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); assert!(graph - .find_cheapest_path_mode( + .find_all_paths_mode( AggregateChainSource::NAME, &source_variant, AggregateChainMiddle::NAME, &target_variant, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Witness ) - .is_none()); - assert!(graph - .find_cheapest_path_mode( + .is_empty()); + assert!(!graph + .find_all_paths_mode( AggregateChainSource::NAME, &source_variant, AggregateChainMiddle::NAME, &target_variant, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Aggregate ) - .is_some()); + .is_empty()); } #[test] @@ -384,39 +668,35 @@ fn aggregate_path_search_rejects_witness_only_edge() { AggregateChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, }, ); assert!(graph - .find_cheapest_path_mode( + .find_all_paths_mode( AggregateChainSource::NAME, &source_variant, AggregateChainMiddle::NAME, &target_variant, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Aggregate ) - .is_none()); - assert!(graph - .find_cheapest_path_mode( + .is_empty()); + assert!(!graph + .find_all_paths_mode( AggregateChainSource::NAME, &source_variant, AggregateChainMiddle::NAME, &target_variant, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, + ReductionMode::Witness ) - .is_some()); + .is_empty()); } #[test] -fn natural_edge_supports_both_modes() { +fn witness_executor_does_not_imply_aggregate_capability() { let source_variant = BTreeMap::from([("graph".to_string(), "Source".to_string())]); let target_variant = BTreeMap::from([("graph".to_string(), "Target".to_string())]); let graph = build_two_node_graph( @@ -425,38 +705,31 @@ fn natural_edge_supports_both_modes() { NaturalVariantProblem::NAME, target_variant.clone(), ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: Some(reduce_natural_variant_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::both(), + turing: false, }, ); - let witness_path = graph.find_cheapest_path_mode( - NaturalVariantProblem::NAME, - &source_variant, - NaturalVariantProblem::NAME, - &target_variant, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - let aggregate_path = graph.find_cheapest_path_mode( - NaturalVariantProblem::NAME, - &source_variant, - NaturalVariantProblem::NAME, - &target_variant, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - - assert!(witness_path.is_some()); - let aggregate_path = aggregate_path.expect("expected aggregate path"); - let chain = graph - .reduce_aggregate_along_path(&aggregate_path, &NaturalVariantProblem as &dyn Any) - .expect("expected aggregate chain"); - assert_eq!(chain.extract_value_dyn(json!(7)), json!(7)); + assert!(!graph + .find_all_paths_mode( + NaturalVariantProblem::NAME, + &source_variant, + NaturalVariantProblem::NAME, + &target_variant, + ReductionMode::Witness + ) + .is_empty()); + assert!(graph + .find_all_paths_mode( + NaturalVariantProblem::NAME, + &source_variant, + NaturalVariantProblem::NAME, + &target_variant, + ReductionMode::Aggregate + ) + .is_empty()); } #[test] @@ -468,10 +741,10 @@ fn reduce_aggregate_along_path_rejects_single_step_path() { AggregateChainMiddle::NAME, BTreeMap::new(), ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); let single_step_path = ReductionPath { @@ -495,10 +768,10 @@ fn reduce_aggregate_returns_none_for_witness_only_edge() { AggregateChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { - overhead: crate::rules::registry::ReductionOverhead::default(), + size_contract: empty_size_contract(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, }, ); let path = ReductionPath { @@ -528,20 +801,15 @@ fn test_find_indirect_path() { } #[test] -fn test_find_shortest_path() { +fn test_find_direct_path_in_all_routes() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MaximumSetPacking", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(path.is_some()); - let path = path.unwrap(); + let path = graph + .find_all_paths("MaximumIndependentSet", &src, "MaximumSetPacking", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); assert_eq!(path.len(), 1); // Direct path exists } @@ -550,16 +818,11 @@ fn test_knapsack_to_ilp_path_exists() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&Knapsack::variant()); let dst = ReductionGraph::variant_to_map(&ILP::::variant()); - let path = graph.find_cheapest_path( - "Knapsack", - &src, - "ILP", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - - let path = path.expect("Knapsack should reduce to ILP"); + let path = graph + .find_all_paths("Knapsack", &src, "ILP", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("Knapsack should reduce directly to ILP"); assert_eq!( path.type_names(), vec!["Knapsack", "ILP"], @@ -580,16 +843,11 @@ fn test_is_to_qubo_path() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "QUBO", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(path.is_some()); - let path = path.unwrap(); + let path = graph + .find_all_paths("MaximumIndependentSet", &src, "QUBO", &dst) + .into_iter() + .find(|path| path.type_names() == ["MaximumIndependentSet", "MaximumSetPacking", "QUBO"]) + .expect("explicit QUBO route"); assert!( path.len() > 1, "MIS -> QUBO should now go through a composite path" @@ -625,7 +883,7 @@ fn test_variant_level_paths() { } #[test] -fn test_find_shortest_path_variants() { +fn test_find_direct_path_variants() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map( @@ -634,31 +892,19 @@ fn test_find_shortest_path_variants() { let dst = ReductionGraph::variant_to_map( &crate::models::graph::SpinGlass::::variant(), ); - let shortest = graph.find_cheapest_path( - "MaxCut", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(shortest.is_some()); - assert_eq!(shortest.unwrap().len(), 1); // Direct path + assert!(graph + .find_all_paths("MaxCut", &src, "SpinGlass", &dst) + .iter() + .any(|path| path.len() == 1)); let src = ReductionGraph::variant_to_map(&crate::models::misc::Factoring::variant()); let dst = ReductionGraph::variant_to_map( &crate::models::graph::SpinGlass::::variant(), ); - let shortest = graph.find_cheapest_path( - "Factoring", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!(shortest.is_some()); - assert_eq!(shortest.unwrap().len(), 2); // Factoring -> CircuitSAT -> SpinGlass + assert!(graph + .find_all_paths("Factoring", &src, "SpinGlass", &dst) + .iter() + .any(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"])); } #[test] @@ -685,15 +931,10 @@ fn test_reduction_path_methods() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let path = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); assert!(!path.is_empty()); assert!(path.source().unwrap().contains("MaximumIndependentSet")); @@ -708,8 +949,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); @@ -737,7 +984,8 @@ fn test_to_json_string() { assert!(json_string.contains("\"edges\"")); assert!(json_string.contains("MaximumIndependentSet")); assert!(json_string.contains("\"category\"")); - assert!(json_string.contains("\"overhead\"")); + assert!(json_string.contains("\"size_fields\"")); + assert!(!json_string.contains("\"overhead\"")); // The legacy "bidirectional" field must not be present assert!( @@ -746,39 +994,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!( @@ -835,17 +1050,9 @@ fn test_circuit_reductions() { let dst = ReductionGraph::variant_to_map(&SpinGlass::::variant()); let paths = graph.find_all_paths("Factoring", &src, "SpinGlass", &dst); assert!(!paths.is_empty()); - let shortest = graph - .find_cheapest_path( - "Factoring", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); - assert_eq!(shortest.len(), 2); // Factoring -> CircuitSAT -> SpinGlass + assert!(paths + .iter() + .any(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"])); } #[test] @@ -996,27 +1203,14 @@ fn test_unknown_name_returns_empty() { assert!(graph .find_all_paths("MaximumIndependentSet", &is_var, "UnknownProblem", &unknown) .is_empty()); - - // find_shortest_path with unknown name - assert!(graph - .find_cheapest_path( - "UnknownProblem", - &unknown, - "MaximumIndependentSet", - &is_var, - &ProblemSize::new(vec![]), - &MinimizeSteps - ) - .is_none()); } #[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] @@ -1058,18 +1252,10 @@ fn test_circuitsat_to_satisfiability_direct_edge() { assert!(graph.has_direct_reduction_by_name("CircuitSAT", "Satisfiability")); - let path = graph.find_cheapest_path( - "CircuitSAT", - &src, - "Satisfiability", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!( - path.is_some(), - "CircuitSAT -> Satisfiability path should exist" - ); + assert!(graph + .find_all_paths("CircuitSAT", &src, "Satisfiability", &dst) + .iter() + .any(|path| path.len() == 1)); } #[test] @@ -1097,8 +1283,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()); } } @@ -1206,155 +1390,16 @@ fn test_edges_have_doc_paths() { } } -#[test] -fn test_find_cheapest_path_minimize_steps() { - let graph = ReductionGraph::new(); - let cost_fn = MinimizeSteps; - let input_size = crate::types::ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); - let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &cost_fn, - ); - - assert!(path.is_some()); - let path = path.unwrap(); - assert_eq!(path.len(), 1); // Direct path -} - -#[test] -fn test_find_cheapest_path_multi_step() { - let graph = ReductionGraph::new(); - let cost_fn = MinimizeSteps; - let input_size = crate::types::ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); - let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MaximumSetPacking", - &dst, - &input_size, - &cost_fn, - ); - - assert!(path.is_some()); - let path = path.unwrap(); - assert_eq!(path.len(), 1); // Direct path: MaximumIndependentSet -> MaximumSetPacking -} - -#[test] -fn test_find_cheapest_path_is_to_qubo() { - let graph = ReductionGraph::new(); - let cost_fn = Minimize("num_vars"); - let input_size = crate::types::ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); - let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); - - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "QUBO", - &dst, - &input_size, - &cost_fn, - ); - - assert!(path.is_some()); - let path = path.unwrap(); - assert!( - path.len() > 1, - "MIS -> QUBO should now be discovered through a composite path" - ); - assert_eq!( - path.type_names(), - vec!["MaximumIndependentSet", "MaximumSetPacking", "QUBO"] - ); -} - -#[test] -fn test_find_cheapest_path_unknown_source() { - let graph = ReductionGraph::new(); - let cost_fn = MinimizeSteps; - let input_size = crate::types::ProblemSize::new(vec![]); - let unknown = BTreeMap::new(); - let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - - let path = graph.find_cheapest_path( - "UnknownProblem", - &unknown, - "MinimumVertexCover", - &dst, - &input_size, - &cost_fn, - ); - - assert!(path.is_none()); -} - -#[test] -fn test_find_cheapest_path_unknown_target() { - let graph = ReductionGraph::new(); - let cost_fn = MinimizeSteps; - let input_size = crate::types::ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); - let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let unknown = BTreeMap::new(); - - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "UnknownProblem", - &unknown, - &input_size, - &cost_fn, - ); - - assert!(path.is_none()); -} - -#[test] -fn test_classify_problem_category() { - assert_eq!( - classify_problem_category("problemreductions::models::graph::maximum_independent_set"), - "graph" - ); - assert_eq!( - classify_problem_category("problemreductions::models::formula::satisfiability"), - "formula" - ); - assert_eq!( - classify_problem_category("problemreductions::models::set::maximum_set_packing"), - "set" - ); - assert_eq!( - classify_problem_category("problemreductions::models::algebraic::qubo"), - "algebraic" - ); - assert_eq!(classify_problem_category("unknown::path"), "other"); -} - #[test] fn test_reduce_along_path_direct() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let rpath = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); // Just verify the path can produce a chain with a dummy source let source = MaximumIndependentSet::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), @@ -1373,15 +1418,10 @@ fn test_reduction_chain_direct() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let rpath = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); let problem = MaximumIndependentSet::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), @@ -1394,7 +1434,7 @@ fn test_reduction_chain_direct() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = problem.evaluate(&source_solution); assert!(metric.is_valid()); } @@ -1408,15 +1448,10 @@ fn test_reduction_chain_multi_step() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); let rpath = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "MaximumSetPacking", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .unwrap(); + .find_all_paths("MaximumIndependentSet", &src, "MaximumSetPacking", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); let problem = MaximumIndependentSet::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), @@ -1429,7 +1464,7 @@ fn test_reduction_chain_multi_step() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = problem.evaluate(&source_solution); assert!(metric.is_valid()); } @@ -1437,33 +1472,28 @@ fn test_reduction_chain_multi_step() { #[test] fn test_reduction_chain_with_variant_casts() { use crate::models::formula::{CNFClause, KSatisfiability}; - use crate::rules::MinimizeSteps; use crate::solvers::BruteForce; use crate::topology::UnitDiskGraph; use crate::traits::Problem; - use crate::types::ProblemSize; let graph = ReductionGraph::new(); // MIS -> MIS (variant cast) -> MVC - // Use find_cheapest_path for exact variant matching (not name-based) + // Resolve a route with exact source and target variants. let src_var = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst_var = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let rpath = graph.find_cheapest_path( - "MaximumIndependentSet", - &src_var, - "MinimumVertexCover", - &dst_var, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - assert!( - rpath.is_some(), - "Should find path from MIS to MVC via variant cast" - ); - let rpath = rpath.unwrap(); + let rpath = graph + .find_all_paths( + "MaximumIndependentSet", + &src_var, + "MinimumVertexCover", + &dst_var, + ) + .into_iter() + .find(|path| path.len() >= 2) + .expect("variant-cast route"); assert!( rpath.len() >= 2, "Path should cross variant cast boundary (at least 2 steps)" @@ -1480,30 +1510,30 @@ fn test_reduction_chain_with_variant_casts() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = mis.evaluate(&source_solution); assert!(metric.is_valid()); // Also test the KSat -> Sat -> MIS multi-step path - // Use find_cheapest_path for exact variant matching (not name-based - // and may pick a path through a different KSat variant) + // Resolve the explicit KSat -> SAT -> MIS route with exact variants. let ksat_src = ReductionGraph::variant_to_map(&KSatisfiability::::variant()); let ksat_dst = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let ksat_rpath = graph.find_cheapest_path( - "KSatisfiability", - &ksat_src, - "MaximumIndependentSet", - &ksat_dst, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ); - assert!( - ksat_rpath.is_some(), - "Should find path from KSat to MIS" - ); - let ksat_rpath = ksat_rpath.unwrap(); + let ksat_rpath = graph + .find_all_paths( + "KSatisfiability", + &ksat_src, + "MaximumIndependentSet", + &ksat_dst, + ) + .into_iter() + .find(|path| { + path.len() == 4 + && path.type_names() + == ["KSatisfiability", "Satisfiability", "MaximumIndependentSet"] + }) + .expect("explicit SAT route"); // Create a 3-SAT formula let ksat = KSatisfiability::::new( @@ -1522,7 +1552,7 @@ fn test_reduction_chain_with_variant_casts() { let target: &MaximumIndependentSet = ksat_chain.target_problem(); let target_solution = solver.find_witness(target).unwrap(); - let original_solution = ksat_chain.extract_solution(&target_solution); + let original_solution = ksat_chain.extract_solution(&target_solution).unwrap(); // Verify the extracted solution satisfies the original 3-SAT formula assert!(ksat.evaluate(&original_solution)); @@ -1536,18 +1566,18 @@ fn test_size_field_names_returns_own_fields() { // not the target's fields from any reduction. let mis_fields = graph.size_field_names("MaximumIndependentSet"); assert!( - mis_fields.contains(&"num_vertices"), + mis_fields.iter().any(|field| field == "num_vertices"), "MIS should have num_vertices, got: {:?}", mis_fields ); assert!( - mis_fields.contains(&"num_edges"), + mis_fields.iter().any(|field| field == "num_edges"), "MIS should have num_edges, got: {:?}", mis_fields ); // Should NOT contain target fields like num_vars or num_constraints assert!( - !mis_fields.contains(&"num_constraints"), + !mis_fields.iter().any(|field| field == "num_constraints"), "MIS should not report ILP's num_constraints, got: {:?}", mis_fields ); @@ -1555,7 +1585,7 @@ fn test_size_field_names_returns_own_fields() { // QUBO should report num_vars let qubo_fields = graph.size_field_names("QUBO"); assert!( - qubo_fields.contains(&"num_vars"), + qubo_fields.iter().any(|field| field == "num_vars"), "QUBO should have num_vars, got: {:?}", qubo_fields ); @@ -1566,28 +1596,29 @@ fn test_size_field_names_returns_own_fields() { } #[test] -fn test_overhead_variables_are_consistent() { - // For each reduction, the input variables of the overhead should be - // a subset of the source problem's size fields (as derived from all - // reductions where it appears). +fn size_contract_variables_are_registered_source_fields() { let graph = ReductionGraph::new(); for entry in inventory::iter:: { - let overhead = entry.overhead(); - let input_vars = overhead.input_variable_names(); + let declarations = (entry.size_declarations_fn)(); + let input_vars: std::collections::HashSet<_> = declarations + .fields + .iter() + .flat_map(|(_, expression)| expression.variables()) + .collect(); if input_vars.is_empty() { continue; } - let source_fields: std::collections::HashSet<&str> = graph + let source_fields: std::collections::HashSet = graph .size_field_names(entry.source_name) .into_iter() .collect(); for var in &input_vars { assert!( - source_fields.contains(var), - "Reduction {} -> {}: overhead references variable '{}' \ + source_fields.contains(*var), + "Reduction {} -> {}: size contract references variable '{}' \ which is not a known size field of {}. Known fields: {:?}", entry.source_name, entry.target_name, @@ -1640,93 +1671,99 @@ fn test_variant_complexity() { } #[test] -fn test_compute_source_size() { +fn test_compute_problem_size_uses_exact_variant_executor() { let problem = MaximumIndependentSet::::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), vec![1, 1, 1, 1], ); - let size = ReductionGraph::compute_source_size("MaximumIndependentSet", &problem); + let variant = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let size = ReductionGraph::compute_problem_size("MaximumIndependentSet", &variant, &problem); assert_eq!(size.get("num_vertices"), Some(4)); assert_eq!(size.get("num_edges"), Some(3)); } #[test] -fn test_compute_source_size_unknown_problem() { +fn test_outgoing_reductions_from_uses_exact_variant_and_mode() { + let graph = ReductionGraph::new(); + let unit = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let weighted = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + + let unit_targets = + graph.outgoing_reductions_from("MaximumIndependentSet", &unit, ReductionMode::Witness); + assert!(unit_targets + .iter() + .all(|edge| edge.source_variant == unit && edge.capabilities.witness)); + assert!(unit_targets.iter().any(|edge| { + edge.target_name == "MaximumSetPacking" + && edge.target_variant.get("weight").map(String::as_str) == Some("One") + })); + assert!(!unit_targets + .iter() + .any(|edge| edge.target_name == "IntegralFlowBundles")); + + let weighted_targets = + graph.outgoing_reductions_from("MaximumIndependentSet", &weighted, ReductionMode::Witness); + assert!(weighted_targets + .iter() + .all(|edge| edge.source_variant == weighted && edge.capabilities.witness)); + assert!(weighted_targets + .iter() + .any(|edge| edge.target_name == "IntegralFlowBundles")); + assert!(!weighted_targets.iter().any(|edge| { + edge.target_name == "MaximumIndependentSet" + && edge.target_variant.get("graph").map(String::as_str) == Some("KingsSubgraph") + })); +} + +#[test] +#[should_panic(expected = "registered problem variant not found")] +fn test_outgoing_reductions_from_rejects_unknown_exact_variant() { + let graph = ReductionGraph::new(); + graph.outgoing_reductions_from( + "MaximumIndependentSet", + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i64".to_string()), + ]), + ReductionMode::Witness, + ); +} + +#[test] +fn test_compute_problem_size_unknown_problem() { let problem = 42u32; - let size = ReductionGraph::compute_source_size("NonExistentProblem", &problem); + let size = + ReductionGraph::compute_problem_size("NonExistentProblem", &BTreeMap::new(), &problem); assert!(size.components.is_empty()); } #[test] -fn test_evaluate_path_overhead() { - use crate::rules::cost::MinimizeStepsThenOverhead; - +fn test_evaluate_path_size() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let input_size = ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); let path = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &MinimizeStepsThenOverhead, - ) - .expect("should find path"); + .find_all_paths("MaximumIndependentSet", &src, "MinimumVertexCover", &dst) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct route"); let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .expect("should evaluate overhead"); + .evaluate_path_size(&path, &input_size) + .expect("should evaluate size transform"); // MIS → MVC preserves num_vertices and num_edges - assert_eq!(final_size.get("num_vertices"), Some(10)); - assert_eq!(final_size.get("num_edges"), Some(20)); -} - -#[test] -fn test_evaluate_path_overhead_multistep() { - use crate::rules::cost::MinimizeStepsThenOverhead; - - // MIS → SetPacking → SetPacking → ILP (3 steps with size transformations) - let graph = ReductionGraph::new(); - let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let dst_variants = graph.variants_for("ILP"); - let dst = dst_variants - .iter() - .find(|v| v.get("variable") == Some(&"bool".to_string())) - .expect("ILP variant should exist"); - let input_size = ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 20)]); - - let path = graph - .find_cheapest_path_mode( - "MaximumIndependentSet", - &src, - "ILP", - dst, - ReductionMode::Witness, - &input_size, - &MinimizeStepsThenOverhead, - ) - .expect("should find path"); - - assert!( - path.len() >= 2, - "path should have at least 2 steps, got {}", - path.len() + assert_eq!( + final_size.values().get("num_vertices"), + Some(&num_bigint::BigUint::from(10u8)) + ); + assert_eq!( + final_size.values().get("num_edges"), + Some(&num_bigint::BigUint::from(20u8)) ); - - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .expect("should evaluate overhead"); - - // MIS(V=10,E=20) → SetPacking(sets=V=10, universe=E=20) → ... → ILP(vars=10, constraints=20) - // The final ILP dimensions should reflect the composed overhead, not the input. - assert_eq!(final_size.get("num_vars"), Some(10)); - assert_eq!(final_size.get("num_constraints"), Some(20)); - // Original MIS fields should NOT appear in the final output - assert_eq!(final_size.get("num_vertices"), None); - assert_eq!(final_size.get("num_edges"), None); } diff --git a/src/unit_tests/rules/graphpartitioning_ilp.rs b/src/unit_tests/rules/graphpartitioning_ilp.rs index bb0ec4e4e..a302ec545 100644 --- a/src/unit_tests/rules/graphpartitioning_ilp.rs +++ b/src/unit_tests/rules/graphpartitioning_ilp.rs @@ -87,7 +87,7 @@ fn test_graphpartitioning_to_ilp_closed_loop() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(3))); @@ -104,7 +104,10 @@ fn test_odd_vertices_reduce_to_infeasible_ilp() { assert_eq!(ilp.constraints[0].rhs, 1.5); let solver = ILPSolver::new(); - assert_eq!(solver.solve(ilp), None); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -113,7 +116,7 @@ fn test_solution_extraction() { let reduction: ReductionGraphPartitioningToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); @@ -125,7 +128,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert_eq!(problem.evaluate(&solution), Min(Some(3))); diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs index ca9acf458..2018cf4a6 100644 --- a/src/unit_tests/rules/graphpartitioning_maxcut.rs +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -53,7 +53,7 @@ fn test_graphpartitioning_to_maxcut_extract_solution_identity() { let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), target_solution ); } diff --git a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index b5ba18142..222e23aa7 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -63,7 +63,7 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_extract_solution() { // Select edges (0,1), (0,3), (1,2), (2,3) => config [1, 0, 1, 1, 0, 1] let target_config = vec![1, 0, 1, 1, 0, 1]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( diff --git a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index be81f53d8..0f2367358 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -73,7 +73,7 @@ fn test_hamiltoniancircuit_to_bottlenecktravelingsalesman_extract_solution_cycle .map(|(u, v)| usize::from(cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u)))) .collect(); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // Bottleneck should be 1 (all selected edges are original cycle edges) assert_eq!(target.evaluate(&target_solution), Min(Some(1))); diff --git a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs index e9b9ca02a..40a6faaf5 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -57,7 +57,7 @@ fn test_hamiltoniancircuit_to_hamiltonianpath_extract_solution() { // HP solution: s=5, 0, 1, 2, 3, v'=4, t=6 let hp_config = vec![5, 0, 1, 2, 3, 4, 6]; - let extracted = reduction.extract_solution(&hp_config); + let extracted = reduction.extract_solution(&hp_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( @@ -73,7 +73,7 @@ fn test_hamiltoniancircuit_to_hamiltonianpath_extract_reversed() { // HP solution reversed: t=6, v'=4, 3, 2, 1, 0, s=5 let hp_config = vec![6, 4, 3, 2, 1, 0, 5]; - let extracted = reduction.extract_solution(&hp_config); + let extracted = reduction.extract_solution(&hp_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( diff --git a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs index 821d9c4bb..ee45e6ee9 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs @@ -70,7 +70,7 @@ fn test_hamiltoniancircuit_to_longestcircuit_extract_solution() { // All edges selected forms a Hamiltonian circuit on the cycle graph let target_solution = vec![1, 1, 1, 1]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(target.evaluate(&target_solution), Max(Some(4))); assert_eq!(extracted.len(), 4); diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 97c0ddc52..83f695d3d 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -103,7 +103,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { // Permutation [0,1,2,3] visits 0->1->2->3->0 on cycle4 let target_config = vec![0, 1, 2, 3]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).0, @@ -111,7 +111,6 @@ fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { ); } -#[cfg(feature = "ilp-solver")] #[test] fn test_prism_graph_hc_via_qap_ilp_roundtrip() { use crate::models::algebraic::ILP; @@ -137,8 +136,8 @@ fn test_prism_graph_hc_via_qap_ilp_roundtrip() { let ilp_sol = ILPSolver::new() .solve(r2.target_problem()) .expect("ILP should be feasible"); - let qap_sol = r2.extract_solution(&ilp_sol); - let hc_sol = r1.extract_solution(&qap_sol); + let qap_sol = r2.extract_solution(&ilp_sol).unwrap(); + let hc_sol = r1.extract_solution(&qap_sol).unwrap(); assert!( hc.evaluate(&hc_sol).0, diff --git a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs index d65a02617..c3a9e0862 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs @@ -108,7 +108,7 @@ fn test_hamiltoniancircuit_to_ruralpostman_nonhamiltonian_cost_gap() { metric.is_valid(), "best RPP solution should be a valid circuit" ); - let two_n = 2 * n as i32; + let two_n = 2 * i64::try_from(n).unwrap(); assert!( metric.unwrap() > two_n, "non-Hamiltonian source should give RPP cost > 2n={two_n}, got {}", @@ -127,7 +127,7 @@ fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { .find_witness(target) .expect("should find a solution"); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert_eq!( extracted.len(), 3, diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs index 264cbbb3e..924ca6b46 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -92,7 +92,7 @@ fn test_hamiltoniancircuit_to_stackercrane_extract_solution() { // The identity permutation [0, 1, 2, 3] traverses arcs in order, // corresponding to vertex order 0, 1, 2, 3 in the original graph. let target_config = vec![0, 1, 2, 3]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index 77d41ac83..6bed4e9fc 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -86,7 +86,7 @@ fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_extract_solution() assert!(target.is_valid_solution(&target_config)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( source.evaluate(&extracted).is_valid(), diff --git a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs index de6300cbc..626d15531 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs @@ -65,7 +65,7 @@ fn test_hamiltoniancircuit_to_travelingsalesman_extract_solution_cycle() { .map(|(u, v)| usize::from(cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u)))) .collect(); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(target.evaluate(&target_solution), Min(Some(4))); assert_eq!(extracted.len(), 4); diff --git a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index 610a9b1d8..bb8527f4e 100644 --- a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -51,7 +51,7 @@ fn test_hamiltonianpath_to_degreeconstrainedspanningtree_extract_solution_recons &[(0, 1), (1, 2), (2, 3)], ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!(source.evaluate(&extracted)); diff --git a/src/unit_tests/rules/hamiltonianpath_ilp.rs b/src/unit_tests/rules/hamiltonianpath_ilp.rs index ce36c3422..44d4628a7 100644 --- a/src/unit_tests/rules/hamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/hamiltonianpath_ilp.rs @@ -33,7 +33,7 @@ fn test_hamiltonianpath_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -58,7 +58,7 @@ fn test_hamiltonianpath_to_ilp_cycle_graph() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -77,7 +77,7 @@ fn test_hamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Disconnected graph should have no Hamiltonian path" ); } @@ -90,6 +90,6 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs index 77ec0ab99..02381d6f1 100644 --- a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -83,7 +83,7 @@ fn test_hamiltonianpath_to_isomorphicspanningtree_complete_graph() { let target_solution = solve_satisfaction_problem(result.target_problem()) .expect("K4 should have an IST solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); // Extracted solution should be a valid Hamiltonian path assert!( source.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs index c81bb7a5b..e6e906dbc 100644 --- a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs +++ b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs @@ -76,7 +76,7 @@ fn test_highlyconnecteddeletion_to_ilp_extract_solution_decode() { target_solution[3] = 1; // singleton {3} target_solution[4] = 1; // triangle {0,1,2} - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // Edges in input order: (0,1), (0,2), (1,2) all inside the triangle (kept); // (2,3) crosses clusters and is deleted. @@ -85,6 +85,21 @@ fn test_highlyconnecteddeletion_to_ilp_extract_solution_decode() { assert!(source.is_valid_solution(&extracted)); } +#[test] +fn test_highlyconnecteddeletion_to_ilp_rejects_unassigned_vertex() { + let source = issue_instance(); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = vec![0; reduction.target_problem().num_vars]; + + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "vertex 0 has no selected cluster" + ); +} + #[test] fn test_highlyconnecteddeletion_to_ilp_disconnected_no_cluster() { // Two disjoint K3's stitched by a single bridge edge. The bridge is the diff --git a/src/unit_tests/rules/ilp_bool_ilp_i32.rs b/src/unit_tests/rules/ilp_bool_ilp_i32.rs index 1e824f1fa..e4ee38ebd 100644 --- a/src/unit_tests/rules/ilp_bool_ilp_i32.rs +++ b/src/unit_tests/rules/ilp_bool_ilp_i32.rs @@ -34,7 +34,7 @@ fn test_ilp_bool_to_ilp_i32_closed_loop() { assert_eq!(target.dims(), vec![(i32::MAX as usize) + 1; 3]); // Extract solution back to source and verify optimality - let source_solution = result.extract_solution(&source_best); + let source_solution = result.extract_solution(&source_best).unwrap(); assert_eq!(source.evaluate(&source_solution), source_obj); } diff --git a/src/unit_tests/rules/ilp_helpers.rs b/src/unit_tests/rules/ilp_helpers.rs index 40eda271b..7e157ba08 100644 --- a/src/unit_tests/rules/ilp_helpers.rs +++ b/src/unit_tests/rules/ilp_helpers.rs @@ -126,7 +126,7 @@ fn test_one_hot_decode_permutation() { solution[2] = 1; // item 0 -> slot 2 solution[3] = 1; // item 1 -> slot 0 solution[7] = 1; // item 2 -> slot 1 - let decoded = one_hot_decode(&solution, 3, 3, 0); + let decoded = one_hot_decode(&solution, 3, 3, 0).unwrap(); assert_eq!(decoded, vec![1, 2, 0]); // slot 0 gets item 1, slot 1 gets item 2, slot 2 gets item 0 } @@ -137,10 +137,27 @@ fn test_one_hot_decode_with_offset() { solution[7] = 1; // 5 + 2 solution[8] = 1; // 5 + 3 solution[12] = 1; // 5 + 7 - let decoded = one_hot_decode(&solution, 3, 3, 5); + let decoded = one_hot_decode(&solution, 3, 3, 5).unwrap(); assert_eq!(decoded, vec![1, 2, 0]); } +#[test] +fn test_one_hot_decode_rejects_missing_and_duplicate_items() { + assert!(one_hot_decode(&[0, 0, 0, 0], 2, 2, 0).is_err()); + assert!(one_hot_decode(&[1, 0, 1, 0], 2, 2, 0).is_err()); + assert!(one_hot_decode(&[1, 1, 0, 0], 2, 2, 0).is_err()); +} + +#[test] +fn test_one_hot_decode_rows_accepts_exactly_one_column_per_row() { + assert_eq!( + one_hot_decode_rows(&[0, 1, 0, 1, 0, 0], 2, 3, 0).unwrap(), + vec![1, 0] + ); + assert!(one_hot_decode_rows(&[0, 0, 0, 1, 0, 0], 2, 3, 0).is_err()); + assert!(one_hot_decode_rows(&[1, 1, 0, 1, 0, 0], 2, 3, 0).is_err()); +} + #[test] fn test_permutation_to_lehmer() { // Identity permutation [0,1,2] -> Lehmer [0,0,0] diff --git a/src/unit_tests/rules/ilp_i32_ilp_bool.rs b/src/unit_tests/rules/ilp_i32_ilp_bool.rs index d18367f03..7a8dbae93 100644 --- a/src/unit_tests/rules/ilp_i32_ilp_bool.rs +++ b/src/unit_tests/rules/ilp_i32_ilp_bool.rs @@ -10,7 +10,7 @@ fn solve_via_bool(source: &ILP) -> Option<(Vec, f64)> { let target = reduction.target_problem(); let solver = BruteForce::new(); let witness = solver.find_witness(target)?; - let source_config = reduction.extract_solution(&witness); + let source_config = reduction.extract_solution(&witness).unwrap(); let values: Vec = source_config.iter().map(|&c| c as i64).collect(); let obj = source.evaluate_objective(&values); Some((source_config, obj)) diff --git a/src/unit_tests/rules/ilp_qubo.rs b/src/unit_tests/rules/ilp_qubo.rs index 071d28743..314310727 100644 --- a/src/unit_tests/rules/ilp_qubo.rs +++ b/src/unit_tests/rules/ilp_qubo.rs @@ -24,13 +24,13 @@ fn test_ilp_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal should be [1, 0, 1] - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best, vec![1, 0, 1]); } @@ -52,12 +52,12 @@ fn test_ilp_to_qubo_minimize() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best, vec![1, 0, 0]); } @@ -85,7 +85,7 @@ fn test_ilp_to_qubo_equality() { assert_eq!(qubo_solutions.len(), 3); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); @@ -116,13 +116,13 @@ fn test_ilp_to_qubo_ge_with_slack() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal: exactly one variable = 1 - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best.iter().sum::(), 1); } @@ -150,13 +150,13 @@ fn test_ilp_to_qubo_le_with_slack() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal: exactly 2 of 3 variables = 1 (3 solutions) - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best.iter().sum::(), 2); } diff --git a/src/unit_tests/rules/integerknapsack_ilp.rs b/src/unit_tests/rules/integerknapsack_ilp.rs index 2fb5f1f35..e3f200b02 100644 --- a/src/unit_tests/rules/integerknapsack_ilp.rs +++ b/src/unit_tests/rules/integerknapsack_ilp.rs @@ -16,7 +16,7 @@ fn test_integerknapsack_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 2]); } @@ -58,7 +58,7 @@ fn test_integerknapsack_to_ilp_zero_capacity() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("zero-capacity ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); } diff --git a/src/unit_tests/rules/integralflowbundles_ilp.rs b/src/unit_tests/rules/integralflowbundles_ilp.rs index 6a3268ebf..007a50b16 100644 --- a/src/unit_tests/rules/integralflowbundles_ilp.rs +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -75,7 +75,7 @@ fn test_integral_flow_bundles_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -85,7 +85,7 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { let problem = yes_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); assert_eq!( - reduction.extract_solution(&satisfying_config()), + reduction.extract_solution(&satisfying_config()).unwrap(), satisfying_config() ); } @@ -94,7 +94,7 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { fn test_integral_flow_bundles_to_ilp_unsat_instance_is_infeasible() { let problem = no_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } #[test] diff --git a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs index 4ddd58699..dc7f6d088 100644 --- a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs +++ b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs @@ -26,7 +26,7 @@ fn test_integralflowhomologousarcs_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs index 35c1b0cb8..b1c677ea7 100644 --- a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs +++ b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs @@ -25,7 +25,7 @@ fn test_integralflowwithmultipliers_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs index 47f8a2293..4aa9f8552 100644 --- a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs +++ b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs @@ -52,7 +52,7 @@ fn test_isomorphicspanningtree_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -67,7 +67,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs index 074fd4007..b4ef61e6e 100644 --- a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -44,7 +44,7 @@ fn test_kclique_to_bcbs_complete_graph() { let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -91,7 +91,7 @@ fn test_kclique_to_bcbs_k_equals_2() { let witness = bf .find_witness(target) .expect("graph has edges, so 2-clique exists"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 2); } @@ -110,7 +110,7 @@ fn test_kclique_to_bcbs_k_equals_1() { let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("should find a 1-clique"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 1); } diff --git a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs index ddfcbb7a6..b4e84f82a 100644 --- a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs +++ b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs @@ -68,7 +68,7 @@ fn test_solution_extraction() { let cbq_witness = bf .find_witness(reduction.target_problem()) .expect("CBQ should be satisfiable"); - let extracted = reduction.extract_solution(&cbq_witness); + let extracted = reduction.extract_solution(&cbq_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // All 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -90,6 +90,6 @@ fn test_trivial_k1() { let witness = bf .find_witness(reduction.target_problem()) .expect("k=1 should be feasible"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/kclique_ilp.rs b/src/unit_tests/rules/kclique_ilp.rs index 6c8628c0a..9bd522a48 100644 --- a/src/unit_tests/rules/kclique_ilp.rs +++ b/src/unit_tests/rules/kclique_ilp.rs @@ -32,7 +32,7 @@ fn test_kclique_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -45,7 +45,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Should select at least k=3 vertices (ILP may return a larger valid clique) assert!(extracted.iter().sum::() >= 3); diff --git a/src/unit_tests/rules/kclique_subgraphisomorphism.rs b/src/unit_tests/rules/kclique_subgraphisomorphism.rs index d2abaf38c..083913efa 100644 --- a/src/unit_tests/rules/kclique_subgraphisomorphism.rs +++ b/src/unit_tests/rules/kclique_subgraphisomorphism.rs @@ -47,7 +47,7 @@ fn test_kclique_to_subgraphisomorphism_complete_graph() { // Solve the target and extract back to source let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -90,7 +90,7 @@ fn test_kclique_to_subgraphisomorphism_k_equals_1() { let witness = bf .find_witness(target) .expect("should find a single vertex"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 1); } @@ -110,7 +110,7 @@ fn test_kclique_to_subgraphisomorphism_k_equals_2() { let witness = bf .find_witness(target) .expect("graph has edges, so K2 exists"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 2); } diff --git a/src/unit_tests/rules/kcoloring_bicliquecover.rs b/src/unit_tests/rules/kcoloring_bicliquecover.rs index c39d9726d..96458930b 100644 --- a/src/unit_tests/rules/kcoloring_bicliquecover.rs +++ b/src/unit_tests/rules/kcoloring_bicliquecover.rs @@ -25,7 +25,7 @@ fn test_kcoloring_to_bicliquecover_closed_loop_trivial() { let witness = BruteForce::new() .find_witness(target) .expect("trivial target must be feasible"); - let coloring = reduction.extract_solution(&witness); + let coloring = reduction.extract_solution(&witness).unwrap(); assert_eq!(coloring.len(), 1); assert!(source.is_valid_solution(&coloring)); // The source brute force agrees. @@ -97,7 +97,7 @@ fn test_kcoloring_to_bicliquecover_forward_witness_path_q2() { // Witness covers all edges with rank <= n + q. assert!(target.is_valid_cover(&witness)); // Extraction recovers a proper coloring. - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); } @@ -115,7 +115,7 @@ fn test_kcoloring_to_bicliquecover_forward_witness_cycle_q2() { let witness = forward_witness(&source, &coloring); assert!(target.is_valid_cover(&witness)); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); } @@ -180,7 +180,7 @@ fn test_kcoloring_to_bicliquecover_extract_solution_on_forward_witness() { let witness = forward_witness(&source, &coloring); assert!(target.is_valid_cover(&witness)); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); // K_3 forces 3 distinct colors. let mut seen = std::collections::BTreeSet::new(); @@ -238,6 +238,6 @@ fn test_kcoloring_to_bicliquecover_extract_trivial_layout() { assert_eq!(cell(&witness, 0, 1, k), 1); assert_eq!(cell(&witness, 2, 1, k), 1); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted, vec![0]); } diff --git a/src/unit_tests/rules/kcoloring_clustering.rs b/src/unit_tests/rules/kcoloring_clustering.rs index 3c003c411..3a51814b3 100644 --- a/src/unit_tests/rules/kcoloring_clustering.rs +++ b/src/unit_tests/rules/kcoloring_clustering.rs @@ -42,7 +42,7 @@ fn test_kcoloring_to_clustering_extract_solution_identity() { let reduction = ReduceTo::::reduce_to(&source); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config), config); + assert_eq!(reduction.extract_solution(&config).unwrap(), config); } #[test] @@ -64,6 +64,9 @@ fn test_kcoloring_to_clustering_empty_graph() { assert_eq!(target.num_elements(), 1); assert_eq!(target.num_clusters(), 3); assert_eq!(target.diameter_bound(), 0); - assert_eq!(reduction.extract_solution(&[2]), Vec::::new()); + assert_eq!( + reduction.extract_solution(&[2]).unwrap(), + Vec::::new() + ); assert_satisfaction_round_trip_from_satisfaction_target(&source, &reduction, "empty graph"); } diff --git a/src/unit_tests/rules/kcoloring_partitionintocliques.rs b/src/unit_tests/rules/kcoloring_partitionintocliques.rs index c64337df2..046baba99 100644 --- a/src/unit_tests/rules/kcoloring_partitionintocliques.rs +++ b/src/unit_tests/rules/kcoloring_partitionintocliques.rs @@ -39,7 +39,7 @@ fn test_kcoloring_to_partitionintocliques_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&source); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config), config); + assert_eq!(reduction.extract_solution(&config).unwrap(), config); } #[test] diff --git a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs index 39012b081..0b7a4263a 100644 --- a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -100,7 +100,7 @@ fn test_kcoloring_to_tdcs_extract_solution_valid() { let target_solutions = solver.find_all_witnesses(reduction.target_problem()); for target_sol in &target_solutions { - let source_sol = reduction.extract_solution(target_sol); + let source_sol = reduction.extract_solution(target_sol).unwrap(); assert_eq!(source_sol.len(), 3); // Verify it is a valid coloring assert!( diff --git a/src/unit_tests/rules/knapsack_ilp.rs b/src/unit_tests/rules/knapsack_ilp.rs index 35aa277a7..a6a0295d4 100644 --- a/src/unit_tests/rules/knapsack_ilp.rs +++ b/src/unit_tests/rules/knapsack_ilp.rs @@ -18,7 +18,7 @@ fn test_knapsack_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 1, 0]); } @@ -33,7 +33,7 @@ fn test_knapsack_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = knapsack.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -68,7 +68,7 @@ fn test_knapsack_to_ilp_zero_capacity() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("zero-capacity ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); } @@ -88,7 +88,7 @@ fn test_knapsack_to_ilp_empty_instance() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("empty Knapsack ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, Vec::::new()); } diff --git a/src/unit_tests/rules/knapsack_qubo.rs b/src/unit_tests/rules/knapsack_qubo.rs index 568cdab1f..89eebb0f9 100644 --- a/src/unit_tests/rules/knapsack_qubo.rs +++ b/src/unit_tests/rules/knapsack_qubo.rs @@ -28,7 +28,7 @@ fn test_knapsack_to_qubo_single_item() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo); - let extracted = reduction.extract_solution(&best_target[0]); + let extracted = reduction.extract_solution(&best_target[0]).unwrap(); assert_eq!(extracted, vec![1]); } @@ -42,7 +42,7 @@ fn test_knapsack_to_qubo_infeasible_rejected() { let best_target = solver.find_all_witnesses(qubo); for sol in &best_target { - let source_sol = reduction.extract_solution(sol); + let source_sol = reduction.extract_solution(sol).unwrap(); let eval = knapsack.evaluate(&source_sol); assert!( eval.is_valid(), @@ -61,7 +61,7 @@ fn test_knapsack_to_qubo_empty() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo); - let extracted = reduction.extract_solution(&best_target[0]); + let extracted = reduction.extract_solution(&best_target[0]).unwrap(); assert_eq!(extracted, vec![0, 0]); } diff --git a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs index b2b98c15e..cfc642a1f 100644 --- a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs +++ b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs @@ -20,11 +20,22 @@ fn test_ksatisfiability_to_acyclicpartition_closed_loop() { assert!(!solutions.is_empty()); for solution in solutions { - let extracted = reduction.extract_solution(&solution); + let extracted = reduction.extract_solution(&solution).unwrap(); assert!(source.evaluate(&extracted).0); } } +#[test] +fn test_partition_to_acyclicpartition_rejects_malformed_target_configuration() { + let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); + let reduction = ReduceTo::>::reduce_to(&source); + + assert!(reduction + .partition_to_acyclic + .extract_solution(&[]) + .is_err()); +} + #[test] fn test_ksatisfiability_to_acyclicpartition_unsatisfiable() { let source = KSatisfiability::::new( diff --git a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs index ad3ea0504..ac94f55cc 100644 --- a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs +++ b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs @@ -137,7 +137,7 @@ fn test_ksatisfiability_to_bicliquecover_extract_solution_reads_b1() { set(&mut witness, 0, 0); // Leave h_1^u (vertex 1) unset → f_1 = false in B_1. - let assignment = reduction.extract_solution(&witness); + let assignment = reduction.extract_solution(&witness).unwrap(); assert_eq!(assignment.len(), 1); assert_eq!(assignment[0], 1, "expected source x_1 = true from B_1"); @@ -145,6 +145,22 @@ fn test_ksatisfiability_to_bicliquecover_extract_solution_reads_b1() { assert_eq!(n, 2); } +#[test] +fn test_ksatisfiability_to_bicliquecover_rejects_missing_b1() { + let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + let target_solution = vec![0; target.num_vertices() * target.k()]; + + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "target configuration has no important-edge biclique B_1" + ); +} + /// If `B_1` is shadowed by a free-edge biclique that touches `Y`, the /// extractor must skip it and proceed to the next candidate. We test /// this by setting up two bicliques that both contain `s_11^u` and @@ -182,7 +198,7 @@ fn test_ksatisfiability_to_bicliquecover_extract_skips_y_touching_bicliques() { // h_1^u is unified vertex 1. set(&mut witness, 1, 1); - let assignment = reduction.extract_solution(&witness); + let assignment = reduction.extract_solution(&witness).unwrap(); assert_eq!(assignment.len(), 1); assert_eq!( assignment[0], 0, @@ -211,7 +227,7 @@ fn test_ksatisfiability_to_bicliquecover_closed_loop_smallest() { "forward witness must be a valid biclique cover" ); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!( extracted[0], 1, diff --git a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs index 2d6509dd0..6894aa52c 100644 --- a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs +++ b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs @@ -141,7 +141,7 @@ fn test_ksatisfiability_to_cyclicordering_single_clause_reference_vector() { let target_solution = solve_cyclic_ordering(target).expect("single-clause gadget should be solvable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert!(source.evaluate(&extracted).0); } @@ -178,7 +178,10 @@ fn test_ksatisfiability_to_cyclicordering_extract_solution_from_reference_witnes let target_solution = vec![0, 11, 1, 9, 12, 10, 6, 13, 7, 2, 3, 4, 8, 5]; assert!(reduction.target_problem().evaluate(&target_solution).0); - assert_eq!(reduction.extract_solution(&target_solution), vec![1, 1, 1]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![1, 1, 1] + ); } #[test] @@ -251,7 +254,7 @@ fn test_ksatisfiability_to_cyclicordering_closed_loop() { "target solution must evaluate as satisfying" ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!( source.evaluate(&extracted).0, "extracted source config must satisfy the source" diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index 70a1e5b95..40d9c9858 100644 --- a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -75,5 +75,5 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_extract_solution() { reduction.target_problem().evaluate(&cover), crate::types::Or(true) ); - assert_eq!(reduction.extract_solution(&cover), vec![0, 0, 1]); + assert_eq!(reduction.extract_solution(&cover).unwrap(), vec![0, 0, 1]); } diff --git a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs index 6ed29abea..379edc441 100644 --- a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -1,13 +1,11 @@ #[cfg(feature = "example-db")] use super::canonical_rule_example_specs; use super::*; -#[cfg(feature = "ilp-solver")] use crate::models::algebraic::ILP; use crate::models::formula::CNFClause; #[cfg(feature = "example-db")] use crate::models::graph::DirectedTwoCommodityIntegralFlow; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; -#[cfg(feature = "ilp-solver")] use crate::solvers::ILPSolver; use crate::traits::Problem; use crate::variant::K3; @@ -42,13 +40,12 @@ fn all_assignments(num_vars: usize) -> Vec> { .collect() } -#[cfg(feature = "ilp-solver")] fn solve_target_via_ilp( problem: &crate::models::graph::DirectedTwoCommodityIntegralFlow, ) -> Option> { let reduction = ReduceTo::>::reduce_to(problem); - let ilp_solution = ILPSolver::new().solve(reduction.target_problem())?; - let extracted = reduction.extract_solution(&ilp_solution); + let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).ok()?; + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); problem.evaluate(&extracted).0.then_some(extracted) } @@ -95,10 +92,9 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_extract_solution_fro let assignment = vec![1, 1, 0]; let flow = reduction.encode_assignment(&assignment); assert!(reduction.target_problem().evaluate(&flow).0); - assert_eq!(reduction.extract_solution(&flow), assignment); + assert_eq!(reduction.extract_solution(&flow).unwrap(), assignment); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_directedtwocommodityintegralflow_closed_loop() { let source = issue_example(); @@ -110,11 +106,10 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_closed_loop() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_directedtwocommodityintegralflow_unsatisfiable() { let source = unsatisfiable_instance(); diff --git a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs index 457ef61de..c0ecdcb43 100644 --- a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs @@ -68,7 +68,7 @@ fn test_ksatisfiability_to_feasible_register_assignment_extract_solution() { let mut realization: Vec = (0..reduction.target_problem().num_vertices()).collect(); realization.swap(s_pos_idx(1), s_neg_idx(2, 1)); - let extracted = reduction.extract_solution(&realization); + let extracted = reduction.extract_solution(&realization).unwrap(); assert_eq!(extracted, vec![1, 0]); } @@ -82,10 +82,10 @@ fn test_ksatisfiability_to_feasible_register_assignment_closed_loop_via_ilp() { let ilp_solution = ILPSolver::new() .solve(fra_to_ilp.target_problem()) .expect("satisfiable FRA gadget should reduce to a feasible ILP"); - let fra_solution = fra_to_ilp.extract_solution(&ilp_solution); + let fra_solution = fra_to_ilp.extract_solution(&ilp_solution).unwrap(); assert_eq!(reduction.target_problem().evaluate(&fra_solution), Or(true)); - let extracted = reduction.extract_solution(&fra_solution); + let extracted = reduction.extract_solution(&fra_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -102,9 +102,7 @@ fn test_ksatisfiability_to_feasible_register_assignment_unsatisfiable_instance() let fra_to_ilp = ReduceTo::>::reduce_to(reduction.target_problem()); assert!( - ILPSolver::new() - .solve(fra_to_ilp.target_problem()) - .is_none(), + ILPSolver::new().solve(fra_to_ilp.target_problem()).is_err(), "an unsatisfiable source formula should yield an infeasible FRA instance" ); } diff --git a/src/unit_tests/rules/ksatisfiability_kclique.rs b/src/unit_tests/rules/ksatisfiability_kclique.rs index c9886f1c1..29452f7e2 100644 --- a/src/unit_tests/rules/ksatisfiability_kclique.rs +++ b/src/unit_tests/rules/ksatisfiability_kclique.rs @@ -29,7 +29,7 @@ fn test_ksatisfiability_to_kclique_closed_loop() { // Every KClique solution must map back to a satisfying 3-SAT assignment for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -79,7 +79,7 @@ fn test_ksatisfiability_to_kclique_single_clause() { // Each solution maps to a satisfying assignment let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -142,7 +142,7 @@ fn test_ksatisfiability_to_kclique_three_clauses() { // Verify all solutions map back correctly for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -170,7 +170,7 @@ fn test_ksatisfiability_to_kclique_extract_solution_example() { let specific_config = vec![0, 0, 1, 1, 0, 0]; assert!(target.evaluate(&specific_config)); - let extracted = reduction.extract_solution(&specific_config); + let extracted = reduction.extract_solution(&specific_config).unwrap(); // Vertex 2 = clause 0, pos 2 → literal 3 (x3) → x3=T → assignment[2]=1 // Vertex 3 = clause 1, pos 0 → literal -1 (¬x1) → x1=F → assignment[0]=0 // Unset variables default to 0. diff --git a/src/unit_tests/rules/ksatisfiability_kernel.rs b/src/unit_tests/rules/ksatisfiability_kernel.rs index 38776937e..89404a4e1 100644 --- a/src/unit_tests/rules/ksatisfiability_kernel.rs +++ b/src/unit_tests/rules/ksatisfiability_kernel.rs @@ -70,7 +70,7 @@ fn test_ksatisfiability_to_kernel_extract_solution_reads_variable_gadgets() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 0, 1, 0, 0, 0]), + reduction.extract_solution(&[1, 0, 0, 1, 0, 0, 0]).unwrap(), vec![1, 0] ); } diff --git a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs index b1687007b..6f3278fc9 100644 --- a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs @@ -105,7 +105,7 @@ fn test_ksatisfiability_to_minimumvertexcover_extract_solution() { // Verify this is a valid vertex cover assert!(reduction.target_problem().is_valid_solution(&vc_config)); - let extracted = reduction.extract_solution(&vc_config); + let extracted = reduction.extract_solution(&vc_config).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); // x1=F, x2=F, x3=T assert!(ksat.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs index 6dfdfc691..65ceaf273 100644 --- a/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs @@ -6,9 +6,7 @@ use crate::traits::Problem; use crate::variant::K3; use std::collections::BTreeSet; -#[cfg(feature = "ilp-solver")] use crate::models::algebraic::ILP; -#[cfg(feature = "ilp-solver")] use crate::solvers::ILPSolver; #[test] @@ -58,12 +56,11 @@ fn test_ksatisfiability_to_monochromatic_triangle_complement_extraction() { "the supplied target coloring must avoid monochromatic triangles" ); - let extracted = reduction.extract_solution(&target_coloring); + let extracted = reduction.extract_solution(&target_coloring).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert!(source.evaluate(&extracted)); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_monochromatic_triangle_closed_loop() { let source = KSatisfiability::::new( @@ -79,10 +76,10 @@ fn test_ksatisfiability_to_monochromatic_triangle_closed_loop() { let ilp_solution = ILPSolver::new() .solve(mono_to_ilp.target_problem()) .expect("reduced MonochromaticTriangle instance should be feasible"); - let mono_solution = mono_to_ilp.extract_solution(&ilp_solution); + let mono_solution = mono_to_ilp.extract_solution(&ilp_solution).unwrap(); assert!(reduction.target_problem().evaluate(&mono_solution)); - let extracted = reduction.extract_solution(&mono_solution); + let extracted = reduction.extract_solution(&mono_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs index e35d7b788..dc86b3ab1 100644 --- a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -97,7 +97,7 @@ fn test_ksatisfiability_to_oneinthreesatisfiability_extract_solution() { let target_solution = vec![0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0]; assert!(target.evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index 7f92fcbaa..2953cd439 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -2,7 +2,6 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::formula::CNFClause; use crate::models::misc::{PrecedenceConstrainedScheduling, PreemptiveScheduling}; -#[cfg(feature = "ilp-solver")] use crate::solvers::ILPSolver; use crate::traits::Problem; use crate::types::Min; @@ -22,7 +21,6 @@ fn no_single_variable_instance() -> KSatisfiability { ) } -#[cfg(feature = "ilp-solver")] fn solve_threshold_schedule_via_ilp( target: &PreemptiveScheduling, deadline: usize, @@ -34,8 +32,8 @@ fn solve_threshold_schedule_via_ilp( target.precedences().to_vec(), ); let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs); - let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem())?; - let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution); + let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem()).ok()?; + let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution).unwrap(); let mut config = vec![0usize; target.num_tasks() * target.d_max()]; for (task, &slot) in slot_assignment.iter().enumerate() { @@ -68,7 +66,7 @@ fn test_ksatisfiability_to_preemptivescheduling_extract_solution_from_constructe assert_eq!(reduction.target_problem().evaluate(&schedule), Min(Some(4))); - let extracted = reduction.extract_solution(&schedule); + let extracted = reduction.extract_solution(&schedule).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -87,12 +85,11 @@ fn test_ksatisfiability_to_preemptivescheduling_multi_variable_round_trip() { let schedule = construct_schedule_from_assignment(result.target_problem(), &[1, 1, 0], &source) .expect("satisfying assignment should yield a witness schedule"); - let extracted = result.extract_solution(&schedule); + let extracted = result.extract_solution(&schedule).unwrap(); assert_eq!(extracted, vec![1, 1, 0]); assert!(source.evaluate(&extracted).0); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_preemptivescheduling_closed_loop() { let source = yes_single_variable_instance(); @@ -107,12 +104,11 @@ fn test_ksatisfiability_to_preemptivescheduling_closed_loop() { Min(Some(reduction.threshold())) ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_preemptivescheduling_unsatisfiable_threshold_gap() { let source = no_single_variable_instance(); diff --git a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs index aa8cc986a..36088f494 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs @@ -56,7 +56,7 @@ fn test_ksatisfiability_to_quadraticcongruences_yes_vector_matches_reference() { .expect("reference witness must fit target encoding"); assert_eq!(target.evaluate(&target_config), crate::types::Or(true)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!(source.evaluate(&extracted), crate::types::Or(true)); } @@ -93,7 +93,7 @@ fn test_ksatisfiability_to_quadraticcongruences_extracts_assignment_from_constru let target_config = witness_config_for_assignment(&source, &[1, 0, 0, 0]) .expect("assignment should lift to a target witness"); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 0]); assert_eq!(source.evaluate(&extracted), crate::types::Or(true)); assert_eq!( @@ -102,6 +102,15 @@ fn test_ksatisfiability_to_quadraticcongruences_extracts_assignment_from_constru ); } +#[test] +fn test_ksatisfiability_to_quadraticcongruences_rejects_missing_variable_signs() { + let source = yes_source(); + let reduction = ReduceTo::::reduce_to(&source); + let target_config = vec![0; reduction.target_problem().dims().len()]; + + assert!(reduction.extract_solution(&target_config).is_err()); +} + #[test] fn test_ksatisfiability_to_quadraticcongruences_closed_loop() { let source = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, -3])]); @@ -128,7 +137,7 @@ fn test_ksatisfiability_to_quadraticcongruences_closed_loop() { ); // Verify round-trip: extracting the source solution recovers the original assignment. - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!( source.evaluate(&extracted), diff --git a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs index 70045195f..c9f3825f2 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -25,7 +25,7 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_closed_loop() { Or(true) ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -41,7 +41,7 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_yes_vector_matches_refe assert_eq!(target.evaluate(&target_config), Or(true)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 9b64eccee..0054fdc42 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -25,7 +25,7 @@ fn test_ksatisfiability_to_qubo_closed_loop() { // Verify all solutions satisfy all clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -41,7 +41,7 @@ fn test_ksatisfiability_to_qubo_simple() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -86,7 +86,7 @@ fn test_ksatisfiability_to_qubo_reversed_vars() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -130,7 +130,7 @@ fn test_k3satisfiability_to_qubo_closed_loop() { // Verify all extracted solutions maximize satisfied clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 5); let assignment: Vec = extracted.iter().map(|&v| v == 1).collect(); let satisfied = ksat.count_satisfied(&assignment); @@ -153,7 +153,7 @@ fn test_k3satisfiability_to_qubo_single_clause() { // All solutions should satisfy the single clause for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -172,7 +172,7 @@ fn test_k3satisfiability_to_qubo_all_negated() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } // 7 out of 8 assignments satisfy (¬x1 ∨ ¬x2 ∨ ¬x3) diff --git a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs index fdf330cfc..ce458c34e 100644 --- a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs +++ b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs @@ -88,8 +88,9 @@ fn test_ksatisfiability_to_register_sufficiency_extract_solution_uses_w_snapshot } } - let extracted = - reduction.extract_solution(&positions_from_order(&order, target.num_vertices())); + let extracted = reduction + .extract_solution(&positions_from_order(&order, target.num_vertices())) + .unwrap(); assert_eq!(extracted, vec![1]); } @@ -107,7 +108,7 @@ fn test_ksatisfiability_to_register_sufficiency_closed_loop_via_exact_solver() { Or(true) ); - let extracted = reduction.extract_solution(®ister_schedule); + let extracted = reduction.extract_solution(®ister_schedule).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted, vec![1]); } diff --git a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs index c2613cdc6..f7608624a 100644 --- a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs @@ -23,7 +23,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { let target_solution = solver .find_witness(target) .expect("target should be satisfiable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted)); } @@ -76,7 +76,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_tautological_clause_is_red let target_solution = solver .find_witness(reduction.target_problem()) .expect("target should remain satisfiable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_subsetsum.rs b/src/unit_tests/rules/ksatisfiability_subsetsum.rs index 30ffca725..aef7f4d48 100644 --- a/src/unit_tests/rules/ksatisfiability_subsetsum.rs +++ b/src/unit_tests/rules/ksatisfiability_subsetsum.rs @@ -30,7 +30,7 @@ fn test_ksatisfiability_to_subsetsum_closed_loop() { // Every SubsetSum solution must map back to a satisfying 3-SAT assignment for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -73,7 +73,7 @@ fn test_ksatisfiability_to_subsetsum_single_clause() { // Each SubsetSum solution maps to a satisfying assignment let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -122,7 +122,7 @@ fn test_ksatisfiability_to_subsetsum_all_negated() { let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -156,7 +156,7 @@ fn test_ksatisfiability_to_subsetsum_extract_solution_example() { ]; assert!(target.evaluate(&specific_config)); - let extracted = reduction.extract_solution(&specific_config); + let extracted = reduction.extract_solution(&specific_config).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); // x1=T, x2=T, x3=T assert!(ksat.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs index 76363ba44..d4d27c0b5 100644 --- a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs +++ b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs @@ -1,7 +1,6 @@ use super::*; use crate::models::formula::CNFClause; use crate::models::misc::TimetableDesign; -#[cfg(feature = "ilp-solver")] use crate::solvers::ILPSolver; use crate::traits::Problem; use crate::variant::K3; @@ -53,7 +52,7 @@ fn test_ksatisfiability_to_timetabledesign_extract_solution_from_constructed_tim assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -66,28 +65,26 @@ fn test_ksatisfiability_to_timetabledesign_multi_variable_round_trip() { construct_timetable_from_assignment(reduction.target_problem(), &[1, 1, 0], &source) .expect("a satisfying 3SAT assignment should lift to a timetable witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 0]); assert!(source.evaluate(&extracted).0); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_timetabledesign_closed_loop() { let source = satisfiable_instance(); let reduction = ReduceTo::::reduce_to(&source); let target_solution = ILPSolver::new() - .solve_reduced(reduction.target_problem()) + .solve_reduced::(reduction.target_problem()) .expect("satisfiable source instance should produce a feasible timetable"); assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } -#[cfg(feature = "ilp-solver")] #[test] fn test_ksatisfiability_to_timetabledesign_unsatisfiable() { let source = unsatisfiable_instance(); @@ -95,8 +92,8 @@ fn test_ksatisfiability_to_timetabledesign_unsatisfiable() { assert!( ILPSolver::new() - .solve_reduced(reduction.target_problem()) - .is_none(), + .solve_reduced::(reduction.target_problem()) + .is_err(), "unsatisfiable 3SAT instance should produce an infeasible timetable" ); } diff --git a/src/unit_tests/rules/longestcircuit_ilp.rs b/src/unit_tests/rules/longestcircuit_ilp.rs index b0c20b4ad..1ac9602d5 100644 --- a/src/unit_tests/rules/longestcircuit_ilp.rs +++ b/src/unit_tests/rules/longestcircuit_ilp.rs @@ -52,7 +52,7 @@ fn test_longestcircuit_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0.is_some(), "ILP solution should be a valid circuit" @@ -86,7 +86,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs index 62ecfcc7a..814703c29 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs @@ -16,7 +16,7 @@ fn test_lcs_to_ilp_yes_instance() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted); @@ -33,7 +33,7 @@ fn test_lcs_to_ilp_closed_loop_three_strings() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(matches!(ilp_value, Max(Some(_)))); @@ -53,7 +53,7 @@ fn test_lcs_to_ilp_extracts_valid_witness() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted); @@ -69,7 +69,7 @@ fn test_lcs_to_ilp_matches_brute_force() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); let brute_force = BruteForce::new(); @@ -88,7 +88,7 @@ fn test_lcs_to_ilp_single_position_all_padding() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Max(Some(0))); diff --git a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs index 16d3ddb91..4ac0305cc 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs @@ -116,7 +116,7 @@ fn test_lcs_to_mis_extract_solution() { let witness = solver .find_witness(reduction.target_problem()) .expect("should have a solution"); - let source_sol = reduction.extract_solution(&witness); + let source_sol = reduction.extract_solution(&witness).unwrap(); // The extracted solution should be valid for the source let value = lcs.evaluate(&source_sol); diff --git a/src/unit_tests/rules/longestpath_ilp.rs b/src/unit_tests/rules/longestpath_ilp.rs index 288d5d172..bd7f64c86 100644 --- a/src/unit_tests/rules/longestpath_ilp.rs +++ b/src/unit_tests/rules/longestpath_ilp.rs @@ -69,7 +69,7 @@ fn test_longestpath_to_ilp_closed_loop_on_issue_example() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_solution(&extracted)); assert_eq!(problem.evaluate(&extracted), best_value); @@ -82,7 +82,7 @@ fn test_solution_extraction_from_handcrafted_ilp_assignment() { // x_{0->1}, x_{1->0}, x_{1->2}, x_{2->1}, o_0, o_1, o_2 let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert_eq!(problem.evaluate(&extracted), Max(Some(5))); @@ -101,7 +101,7 @@ fn test_source_equals_target_uses_empty_path() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should solve the trivial empty-path case"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0]); assert_eq!(problem.evaluate(&extracted), Max(Some(0))); diff --git a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs index 0e2f3c6ac..37c6ac72e 100644 --- a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs @@ -117,7 +117,7 @@ fn test_maxcut_to_minimumcutintoboundedsets_extract_solution_size() { // Target has 8 vertices, extract should return 3 let dummy_target_sol = vec![0, 1, 0, 1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&dummy_target_sol); + let extracted = reduction.extract_solution(&dummy_target_sol).unwrap(); assert_eq!(extracted.len(), 3); } diff --git a/src/unit_tests/rules/maxcut_minimummatrixcover.rs b/src/unit_tests/rules/maxcut_minimummatrixcover.rs index fb722d1a5..169b6ff41 100644 --- a/src/unit_tests/rules/maxcut_minimummatrixcover.rs +++ b/src/unit_tests/rules/maxcut_minimummatrixcover.rs @@ -33,7 +33,7 @@ fn verify_identity(source: &MaxCut) { let Max(Some(cut)) = source.evaluate(&config) else { panic!("MaxCut must yield a finite cut for every config"); }; - let cut64 = cut as i64; + let cut64 = cut; assert_eq!( qf, @@ -182,7 +182,7 @@ fn test_extract_solution_is_identity() { MaxCut::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1, 1]); let reduction = ReduceTo::::reduce_to(&source); let target_sol = vec![1, 0, 1]; - assert_eq!(reduction.extract_solution(&target_sol), target_sol); + assert_eq!(reduction.extract_solution(&target_sol).unwrap(), target_sol); } #[test] @@ -202,7 +202,7 @@ fn test_empty_graph() { #[test] fn test_overhead_num_rows_equals_num_vertices() { - // Spot-check the size overhead: target.num_rows == source.num_vertices. + // Spot-check the exact size relation: target.num_rows == source.num_vertices. for n in [1usize, 2, 5, 8] { let edges: Vec<(usize, usize)> = (0..n.saturating_sub(1)).map(|i| (i, i + 1)).collect(); let weights: Vec = vec![1; edges.len()]; diff --git a/src/unit_tests/rules/maximalis_ilp.rs b/src/unit_tests/rules/maximalis_ilp.rs index 4a977ab58..740b4c75c 100644 --- a/src/unit_tests/rules/maximalis_ilp.rs +++ b/src/unit_tests/rules/maximalis_ilp.rs @@ -30,7 +30,7 @@ fn test_maximalis_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -45,7 +45,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/maximum2satisfiability_ilp.rs b/src/unit_tests/rules/maximum2satisfiability_ilp.rs index 52bdf45ac..ea7c4edb4 100644 --- a/src/unit_tests/rules/maximum2satisfiability_ilp.rs +++ b/src/unit_tests/rules/maximum2satisfiability_ilp.rs @@ -34,7 +34,7 @@ fn test_maximum2satisfiability_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Optimal: 6 satisfied clauses let value = problem.evaluate(&extracted); assert_eq!(value, crate::types::Max(Some(6))); @@ -51,7 +51,7 @@ fn test_maximum2satisfiability_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -106,7 +106,7 @@ fn test_maximum2satisfiability_to_ilp_all_satisfiable() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // Both clauses should be satisfiable assert_eq!(value, crate::types::Max(Some(2))); diff --git a/src/unit_tests/rules/maximum2satisfiability_maxcut.rs b/src/unit_tests/rules/maximum2satisfiability_maxcut.rs index 16863af58..55ab86955 100644 --- a/src/unit_tests/rules/maximum2satisfiability_maxcut.rs +++ b/src/unit_tests/rules/maximum2satisfiability_maxcut.rs @@ -64,8 +64,8 @@ fn test_maximum2satisfiability_to_maxcut_issue_affine_relation_on_all_partitions let target_solution: Vec = (0..target.num_vertices()) .map(|bit| (mask >> bit) & 1) .collect(); - let source_solution = reduction.extract_solution(&target_solution); - let satisfied = source.evaluate(&source_solution).unwrap() as i32; + let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let satisfied = i64::try_from(source.evaluate(&source_solution).unwrap()).unwrap(); let cut_weight = target.evaluate(&target_solution).unwrap(); assert_eq!( @@ -81,10 +81,16 @@ fn test_maximum2satisfiability_to_maxcut_extract_solution_uses_reference_vertex( let source = make_issue_instance(); let reduction = ReduceTo::>::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[0, 1, 0, 0]), vec![0, 1, 1]); - assert_eq!(reduction.extract_solution(&[1, 0, 1, 1]), vec![0, 1, 1]); assert_eq!( - source.evaluate(&reduction.extract_solution(&[1, 0, 1, 1])), + reduction.extract_solution(&[0, 1, 0, 0]).unwrap(), + vec![0, 1, 1] + ); + assert_eq!( + reduction.extract_solution(&[1, 0, 1, 1]).unwrap(), + vec![0, 1, 1] + ); + assert_eq!( + source.evaluate(&reduction.extract_solution(&[1, 0, 1, 1]).unwrap()), Max(Some(5)) ); } diff --git a/src/unit_tests/rules/maximumclique_ilp.rs b/src/unit_tests/rules/maximumclique_ilp.rs index 21d24c1ae..753c3f36d 100644 --- a/src/unit_tests/rules/maximumclique_ilp.rs +++ b/src/unit_tests/rules/maximumclique_ilp.rs @@ -120,7 +120,7 @@ fn test_maximumclique_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 3 (all vertices form a clique) let ilp_size = clique_size(&problem, &extracted); @@ -151,7 +151,7 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = clique_size(&problem, &extracted); assert_eq!(bf_size, 2); @@ -177,7 +177,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = brute_force_max_clique(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = clique_size(&problem, &extracted); assert_eq!(bf_obj, 101); @@ -195,7 +195,7 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 0]); // Verify this is a valid clique (0 and 1 are adjacent) @@ -229,7 +229,7 @@ fn test_empty_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Only one vertex should be selected assert_eq!(extracted.iter().sum::(), 1); @@ -253,7 +253,7 @@ fn test_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All vertices should be selected assert_eq!(extracted, vec![1, 1, 1, 1]); @@ -275,7 +275,7 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(is_valid_clique(&problem, &extracted)); assert_eq!(clique_size(&problem, &extracted), 2); @@ -301,7 +301,7 @@ fn test_star_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(is_valid_clique(&problem, &extracted)); assert_eq!(clique_size(&problem, &extracted), 2); diff --git a/src/unit_tests/rules/maximumclique_maximumindependentset.rs b/src/unit_tests/rules/maximumclique_maximumindependentset.rs index 39ede01a4..454dc70ea 100644 --- a/src/unit_tests/rules/maximumclique_maximumindependentset.rs +++ b/src/unit_tests/rules/maximumclique_maximumindependentset.rs @@ -52,7 +52,7 @@ fn test_maximumclique_to_maximumindependentset_triangle() { .any(|s| s.iter().sum::() == 3)); // Extract solution: should be the full clique {0,1,2} - let source_sol = reduction.extract_solution(&target_solutions[0]); + let source_sol = reduction.extract_solution(&target_solutions[0]).unwrap(); assert_eq!(source.evaluate(&source_sol).unwrap(), 3); } @@ -108,7 +108,7 @@ fn test_maximumclique_to_maximumindependentset_one_weights_closed_loop() { #[test] fn test_maximumclique_to_maximumindependentset_overhead() { - // Verify overhead formula: complement edges = n*(n-1)/2 - m + // Verify exact size formula: complement edges = n*(n-1)/2 - m let source = MaximumClique::new( SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), vec![1i32; 5], diff --git a/src/unit_tests/rules/maximumcokplex_ilp.rs b/src/unit_tests/rules/maximumcokplex_ilp.rs index 18e0c8dc9..c5dcb8180 100644 --- a/src/unit_tests/rules/maximumcokplex_ilp.rs +++ b/src/unit_tests/rules/maximumcokplex_ilp.rs @@ -72,7 +72,7 @@ fn test_maximumcokplex_to_ilp_k_equals_1_regression() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("k=1 instance should be ILP-solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Max(Some(2))); assert_eq!(extracted.iter().sum::(), 2); @@ -84,7 +84,7 @@ fn test_maximumcokplex_to_ilp_extract_solution_identity() { let source = issue_instance(); let reduction: ReductionCoKPlexToILP = ReduceTo::>::reduce_to(&source); let target_solution = vec![1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Max(Some(12))); diff --git a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs index c606ef50c..f52769275 100644 --- a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs @@ -62,7 +62,7 @@ fn test_maximumcommonedgesubgraph_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("matched paths ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(2))); @@ -91,7 +91,7 @@ fn test_maximumcommonedgesubgraph_to_ilp_truncated_target() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("truncated ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); @@ -115,7 +115,7 @@ fn test_maximumcommonedgesubgraph_to_ilp_empty_graphs() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("empty-arc ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(0))); } @@ -132,7 +132,7 @@ fn test_maximumcommonedgesubgraph_to_ilp_self_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("self-loop ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); diff --git a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs index 0636b736e..ba34e2044 100644 --- a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs @@ -47,7 +47,7 @@ fn test_maximumcontactmapoverlap_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("canonical CMO ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // The optimal alignment preserves both contacts of G_1. assert!(source.is_valid_solution(&extracted)); @@ -71,7 +71,7 @@ fn test_maximumcontactmapoverlap_to_ilp_trivial_no_contacts() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("empty-contact ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(0))); } @@ -97,7 +97,7 @@ fn test_maximumcontactmapoverlap_to_ilp_order_preserving_forbidden() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); @@ -123,7 +123,7 @@ fn test_maximumcontactmapoverlap_to_ilp_extract_solution_partial() { let mut target_sol = vec![0usize; reduction.target_problem().num_vars]; target_sol[1] = 1; target_sol[n2 + 2] = 1; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); // Encoding: vertex j of G_2 is represented as j+1. assert_eq!(extracted, vec![2, 3]); assert!(source.is_valid_solution(&extracted)); diff --git a/src/unit_tests/rules/maximumdomaticnumber_ilp.rs b/src/unit_tests/rules/maximumdomaticnumber_ilp.rs index 1c8cb7e4f..a72d354f7 100644 --- a/src/unit_tests/rules/maximumdomaticnumber_ilp.rs +++ b/src/unit_tests/rules/maximumdomaticnumber_ilp.rs @@ -20,7 +20,7 @@ fn test_maximumdomaticnumber_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should find domatic number = 2 @@ -72,7 +72,7 @@ fn test_maximumdomaticnumber_to_ilp_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Max(Some(3))); @@ -87,7 +87,7 @@ fn test_maximumdomaticnumber_to_ilp_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Max(Some(1))); @@ -105,7 +105,7 @@ fn test_maximumdomaticnumber_to_ilp_solution_extraction() { // x_{2,0}=1, x_{2,1}=0, x_{2,2}=0, // y_0=1, y_1=1, y_2=0 let ilp_solution = vec![1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); // Verify this is a valid partition with 2 dominating sets diff --git a/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs b/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs index b6dccc002..84fb42bcf 100644 --- a/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs @@ -48,7 +48,7 @@ fn test_maximumedgeweightedkclique_to_ilp_extract_solution_identity() { let source = issue_instance(); let reduction = ReduceTo::>::reduce_to(&source); let target_solution = vec![1, 1, 1, 0, 1, 1, 1, 0, 0]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 0]); assert_eq!(source.evaluate(&extracted), Max(Some(8))); } diff --git a/src/unit_tests/rules/maximumindependentset_gridgraph.rs b/src/unit_tests/rules/maximumindependentset_gridgraph.rs index 1c19183d8..6e088d282 100644 --- a/src/unit_tests/rules/maximumindependentset_gridgraph.rs +++ b/src/unit_tests/rules/maximumindependentset_gridgraph.rs @@ -87,7 +87,7 @@ fn test_mis_simple_one_to_kings_one_closed_loop() { let grid_solutions = solver.find_all_witnesses(target); assert!(!grid_solutions.is_empty()); - let original_solution = result.extract_solution(&grid_solutions[0]); + let original_solution = result.extract_solution(&grid_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 5); let size: usize = original_solution.iter().sum(); assert_eq!(size, 3, "Max IS in path of 5 should be 3"); diff --git a/src/unit_tests/rules/maximumindependentset_ilp.rs b/src/unit_tests/rules/maximumindependentset_ilp.rs index ce3c165c5..302a05339 100644 --- a/src/unit_tests/rules/maximumindependentset_ilp.rs +++ b/src/unit_tests/rules/maximumindependentset_ilp.rs @@ -1,10 +1,10 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumIndependentSet; -use crate::rules::{MinimizeSteps, ReductionChain, ReductionGraph, ReductionPath}; +use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::{BruteForce, ILPSolver, Solver}; use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{Max, ProblemSize}; +use crate::types::Max; fn reduce_mis_to_ilp( problem: &MaximumIndependentSet, @@ -13,15 +13,10 @@ fn reduce_mis_to_ilp( let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&ILP::::variant()); let path = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "ILP", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .expect("Should find path MaximumIndependentSet -> ILP"); + .find_all_paths("MaximumIndependentSet", &src, "ILP", &dst) + .into_iter() + .find(|path| path.type_names() == ["MaximumIndependentSet", "MaximumSetPacking", "ILP"]) + .expect("expected explicit MaximumSetPacking route"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) .expect("Should reduce MaximumIndependentSet to ILP along path"); @@ -64,7 +59,7 @@ fn test_maximumindependentset_to_ilp_via_path_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); let ilp_size: usize = extracted.iter().sum(); assert_eq!(ilp_size, 2); @@ -80,7 +75,7 @@ fn test_maximumindependentset_to_ilp_via_path_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(100))); assert_eq!(extracted, vec![0, 1, 0]); @@ -96,6 +91,6 @@ fn test_maximumindependentset_to_ilp_bf_vs_ilp() { let ilp: &ILP = chain.target_problem(); let bf_value = BruteForce::new().solve(&problem); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } diff --git a/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs index 7ee27a1ac..62aec0bff 100644 --- a/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs +++ b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs @@ -23,7 +23,7 @@ fn test_maximumindependentset_to_integralflowbundles_closed_loop() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid(), "Extracted config should be a valid IS"); } @@ -48,7 +48,7 @@ fn test_maximumindependentset_to_integralflowbundles_triangle() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -73,7 +73,7 @@ fn test_maximumindependentset_to_integralflowbundles_cycle5() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -95,7 +95,7 @@ fn test_maximumindependentset_to_integralflowbundles_empty_graph() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -117,7 +117,7 @@ fn test_maximumindependentset_to_integralflowbundles_single_vertex() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); assert_eq!(value.unwrap(), 1); diff --git a/src/unit_tests/rules/maximumindependentset_maximumclique.rs b/src/unit_tests/rules/maximumindependentset_maximumclique.rs index 57e94117c..fd3e0a85e 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumclique.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumclique.rs @@ -44,7 +44,7 @@ fn test_maximumindependentset_to_maximumclique_weighted() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(target); for sol in &best { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); assert!(metric.is_valid()); } diff --git a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs index 880265a7d..548517931 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs @@ -180,7 +180,7 @@ fn test_maximumindependentset_one_to_maximumsetpacking_closed_loop() { let sp_solutions = solver.find_all_witnesses(sp_problem); assert!(!sp_solutions.is_empty()); - let original_solution = reduction.extract_solution(&sp_solutions[0]); + let original_solution = reduction.extract_solution(&sp_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 3); let size: usize = original_solution.iter().sum(); assert_eq!(size, 2, "Max IS in path of 3 should be 2"); @@ -200,7 +200,7 @@ fn test_maximumsetpacking_one_to_maximumindependentset_closed_loop() { let is_solutions = solver.find_all_witnesses(is_problem); assert!(!is_solutions.is_empty()); - let original_solution = reduction.extract_solution(&is_solutions[0]); + let original_solution = reduction.extract_solution(&is_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 3); let size: usize = original_solution.iter().sum(); assert_eq!( diff --git a/src/unit_tests/rules/maximumindependentset_qubo.rs b/src/unit_tests/rules/maximumindependentset_qubo.rs index 1e297ba80..6cac5ccb1 100644 --- a/src/unit_tests/rules/maximumindependentset_qubo.rs +++ b/src/unit_tests/rules/maximumindependentset_qubo.rs @@ -1,10 +1,10 @@ use crate::models::algebraic::QUBO; use crate::models::graph::MaximumIndependentSet; -use crate::rules::{Minimize, ReductionChain, ReductionGraph, ReductionPath}; +use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::BruteForce; -use crate::topology::{Graph, SimpleGraph}; +use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{Max, ProblemSize}; +use crate::types::Max; fn reduce_mis_to_qubo( problem: &MaximumIndependentSet, @@ -13,18 +13,10 @@ fn reduce_mis_to_qubo( let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); let path = graph - .find_cheapest_path( - "MaximumIndependentSet", - &src, - "QUBO", - &dst, - &ProblemSize::new(vec![ - ("num_vertices", problem.graph().num_vertices()), - ("num_edges", problem.graph().num_edges()), - ]), - &Minimize("num_vars"), - ) - .expect("Should find path MaximumIndependentSet -> QUBO"); + .find_all_paths("MaximumIndependentSet", &src, "QUBO", &dst) + .into_iter() + .find(|path| path.type_names() == ["MaximumIndependentSet", "MaximumSetPacking", "QUBO"]) + .expect("expected explicit MaximumSetPacking route"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) .expect("Should reduce MaximumIndependentSet to QUBO along path"); @@ -53,7 +45,7 @@ fn test_maximumindependentset_to_qubo_via_path_closed_loop() { let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol); + let extracted = chain.extract_solution(sol).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -70,7 +62,7 @@ fn test_maximumindependentset_to_qubo_via_path_weighted() { let qubo_solution = solver .find_witness(qubo) .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(100))); assert_eq!(extracted, vec![0, 1, 0]); @@ -86,7 +78,7 @@ fn test_maximumindependentset_to_qubo_via_path_empty_graph() { let solver = BruteForce::new(); let qubo_solution = solver.find_witness(qubo).expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Max(Some(3))); diff --git a/src/unit_tests/rules/maximumindependentset_triangular.rs b/src/unit_tests/rules/maximumindependentset_triangular.rs index 428e02ef5..2dcd51533 100644 --- a/src/unit_tests/rules/maximumindependentset_triangular.rs +++ b/src/unit_tests/rules/maximumindependentset_triangular.rs @@ -54,7 +54,7 @@ fn test_mis_simple_one_to_triangular_closed_loop() { // Map a trivial zero solution back to verify dimensions let zero_config = vec![0; target.graph().num_vertices()]; - let original_solution = result.extract_solution(&zero_config); + let original_solution = result.extract_solution(&zero_config).unwrap(); assert_eq!(original_solution.len(), 3); } diff --git a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs index 159ce297e..5f740bddf 100644 --- a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs +++ b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs @@ -56,7 +56,7 @@ fn test_maximumleafspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All brute-force optimal solutions have the same value let bf_value = problem.evaluate(&best_source[0]); @@ -76,7 +76,7 @@ fn test_maximumleafspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Max(Some(4))); assert_eq!(problem.evaluate(&extracted), Max(Some(4))); @@ -96,7 +96,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[2] = 1; // edge (2,3) assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 1, 0] ); } @@ -109,7 +109,7 @@ fn test_reduce_and_solve_via_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(4))); assert!(problem.is_valid_solution(&extracted)); } @@ -131,7 +131,7 @@ fn test_maximumleafspanningtree_to_ilp_path_graph() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); } @@ -144,7 +144,7 @@ fn test_maximumleafspanningtree_to_ilp_star_graph() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(3))); assert!(problem.is_valid_solution(&extracted)); } @@ -164,7 +164,7 @@ fn test_maximumleafspanningtree_to_ilp_complete_graph() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); assert_eq!(bf_value, Max(Some(3))); diff --git a/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs b/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs index f88feec2b..94b9c9bd7 100644 --- a/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs +++ b/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs @@ -49,7 +49,7 @@ fn test_maximumlikelihoodranking_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -66,7 +66,7 @@ fn test_maximumlikelihoodranking_to_ilp_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify the extracted config is a valid permutation let n = problem.num_items(); @@ -92,7 +92,7 @@ fn test_maximumlikelihoodranking_to_ilp_two_items() { assert_eq!(ilp.num_constraints(), 0); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); @@ -114,7 +114,7 @@ fn test_maximumlikelihoodranking_to_ilp_single_item() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("single-item ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); } diff --git a/src/unit_tests/rules/maximummatching_ilp.rs b/src/unit_tests/rules/maximummatching_ilp.rs index 02c9c6061..8c93230a1 100644 --- a/src/unit_tests/rules/maximummatching_ilp.rs +++ b/src/unit_tests/rules/maximummatching_ilp.rs @@ -59,7 +59,7 @@ fn test_maximummatching_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 1 (one edge) let bf_size = problem.evaluate(&bf_solutions[0]); @@ -91,7 +91,7 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Max(Some(2))); @@ -118,7 +118,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Max(Some(100))); @@ -136,7 +136,7 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // Verify this is a valid matching (edges 0-1 and 2-3 are disjoint) @@ -188,7 +188,7 @@ fn test_k4_perfect_matching() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); // Perfect matching has 2 edges @@ -209,7 +209,7 @@ fn test_star_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(1))); @@ -228,7 +228,7 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); @@ -242,7 +242,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs index b72d0ee3e..3bc0fd31b 100644 --- a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs @@ -56,7 +56,7 @@ fn test_matching_to_setpacking_solution_extraction() { // Test solution extraction is 1:1 let sp_solution = vec![1, 0, 1]; - let matching_solution = reduction.extract_solution(&sp_solution); + let matching_solution = reduction.extract_solution(&sp_solution).unwrap(); assert_eq!(matching_solution, vec![1, 0, 1]); // Verify the extracted solution is valid for original MaximumMatching diff --git a/src/unit_tests/rules/maximumsetpacking_casts.rs b/src/unit_tests/rules/maximumsetpacking_casts.rs index 7932ba4d1..6cf2f2a62 100644 --- a/src/unit_tests/rules/maximumsetpacking_casts.rs +++ b/src/unit_tests/rules/maximumsetpacking_casts.rs @@ -15,7 +15,7 @@ fn test_maximumsetpacking_one_to_i32_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.find_witness(sp_i32).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); let metric = sp_one.evaluate(&source_solution); assert!(metric.is_valid()); @@ -32,7 +32,7 @@ fn test_maximumsetpacking_i32_to_f64_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.find_witness(sp_f64).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); let metric = sp_i32.evaluate(&source_solution); assert!(metric.is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 54daaed04..2deafc02a 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -49,7 +49,7 @@ fn test_maximumsetpacking_to_ilp_closed_loop() { let bf_solutions = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let bf_size: usize = bf_solutions[0].iter().sum(); let ilp_size: usize = extracted.iter().sum(); @@ -78,7 +78,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Max(Some(6))); @@ -93,7 +93,7 @@ fn test_solution_extraction() { let reduction: ReductionSPToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } @@ -108,7 +108,7 @@ fn test_disjoint_sets() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 1]); assert!(problem.evaluate(&extracted).is_valid()); @@ -121,7 +121,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_qubo.rs b/src/unit_tests/rules/maximumsetpacking_qubo.rs index dfad90aa2..fca0b1fc3 100644 --- a/src/unit_tests/rules/maximumsetpacking_qubo.rs +++ b/src/unit_tests/rules/maximumsetpacking_qubo.rs @@ -15,7 +15,7 @@ fn test_setpacking_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -32,7 +32,7 @@ fn test_setpacking_to_qubo_disjoint() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); // All 3 sets should be selected assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 3); @@ -50,7 +50,7 @@ fn test_setpacking_to_qubo_all_overlap() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 1); } diff --git a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs index 2b03ca508..4d5179e97 100644 --- a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs @@ -64,7 +64,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let bf_value = problem.evaluate(&best_source[0]); let ilp_value = problem.evaluate(&extracted); @@ -83,7 +83,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Min(Some(5))); assert_eq!(problem.evaluate(&extracted), Min(Some(5))); @@ -103,7 +103,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[3] = 1; // edge (1,3) assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 0, 1, 0] ); } @@ -131,7 +131,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_star_tree() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); assert!(problem.is_valid_solution(&extracted)); } @@ -151,7 +151,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_path_graph() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(6))); assert!(problem.is_valid_solution(&extracted)); } diff --git a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 716580b80..2e42978ec 100644 --- a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -79,7 +79,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_bottleneck() { // value 1 and cost 1 (the cheaper 1->3 path). let solver = BruteForce::new(); let target_witness = solver.find_witness(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); assert_eq!(source.total_cost(&extracted), 1); } @@ -113,7 +113,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_parallel_arcs() { // parallel arc has cost 1, so optimal source cost = 1. let solver = BruteForce::new(); let target_witness = solver.find_witness(target).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); assert_eq!(source.total_cost(&extracted), 1); } @@ -161,7 +161,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_zero_capacity_arc() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); // Zero-capacity arc must be 0 in the extracted flow. assert_eq!(extracted[2], 0); @@ -187,7 +187,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_value_priority_over_cos let solver = BruteForce::new(); let target_witness = solver.find_witness(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 2); assert_eq!(source.total_cost(&extracted), 20); @@ -208,7 +208,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_extract_solution_length for (i, v) in padded.iter_mut().enumerate().take(m) { *v = i % 2; } - let extracted = reduction.extract_solution(&padded); + let extracted = reduction.extract_solution(&padded).unwrap(); assert_eq!(extracted.len(), m); assert_eq!(extracted, padded[..m].to_vec()); } diff --git a/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs b/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs index d0b65d386..0665f29a8 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs @@ -31,7 +31,7 @@ fn test_minimumcoveringbycliques_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Min(Some(2))); assert_eq!(source.evaluate(&extracted), bf_value); @@ -46,7 +46,10 @@ fn test_minimumcoveringbycliques_to_ilp_empty_graph() { assert_eq!(ilp.num_vars, 0); assert_eq!(ilp.constraints.len(), 0); - assert_eq!(reduction.extract_solution(&[]), Vec::::new()); + assert_eq!( + reduction.extract_solution(&[]).unwrap(), + Vec::::new() + ); assert_eq!(source.evaluate(&[]), Min(Some(0))); } diff --git a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 03f6e8987..8c88f827c 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -39,7 +39,7 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_issue_example_ assert_eq!(target.evaluate(&target_solution), Min(Some(2))); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1]); assert_eq!(source.evaluate(&extracted), Min(Some(2))); @@ -54,9 +54,13 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_invalid_target assert_eq!(target.evaluate(&invalid_target_solution), Min(None)); - let extracted = reduction.extract_solution(&invalid_target_solution); - - assert_eq!(source.evaluate(&extracted), Min(None)); + let error = reduction + .extract_solution(&invalid_target_solution) + .unwrap_err(); + assert_eq!( + error.to_string(), + "target configuration is not a valid intersection graph basis" + ); } #[test] @@ -66,6 +70,9 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_empty_graph() let target = reduction.target_problem(); assert_eq!(target.evaluate(&[]), Min(Some(0))); - assert_eq!(reduction.extract_solution(&[]), Vec::::new()); + assert_eq!( + reduction.extract_solution(&[]).unwrap(), + Vec::::new() + ); assert_eq!(source.evaluate(&[]), Min(Some(0))); } diff --git a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs index 1dbe73c45..97f99a7a3 100644 --- a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs @@ -42,7 +42,7 @@ fn test_extract_solution() { let source = small_instance(); let reduction: ReductionMinCutBSToILP = ReduceTo::>::reduce_to(&source); let target_sol = vec![0, 0, 1, 1, 0, 1, 0]; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); assert_eq!(extracted, vec![0, 0, 1, 1]); assert!(source.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs index 711a805bf..20510142a 100644 --- a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -43,7 +43,10 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_single_link() { assert_eq!(reduction.target_problem().num_vars(), 3); assert_eq!(qubo_solutions.len(), 1); - assert_eq!(reduction.extract_solution(&qubo_solutions[0]), vec![1]); + assert_eq!( + reduction.extract_solution(&qubo_solutions[0]).unwrap(), + vec![1] + ); assert!(matches!(source.evaluate(&[1]), Min(Some(v)) if v.abs() < EPS)); } @@ -62,7 +65,7 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_single_sample_per_link() assert_eq!(reduction.target_problem().num_vars(), 3); assert_eq!(qubo_solutions, vec![vec![1, 1, 1]]); assert_eq!( - reduction.extract_solution(&qubo_solutions[0]), + reduction.extract_solution(&qubo_solutions[0]).unwrap(), vec![0, 0, 0] ); assert!(matches!(source.evaluate(&[0, 0, 0]), Min(Some(v)) if v.abs() < EPS)); @@ -83,7 +86,7 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_empty_allowed_pairs() { assert_eq!(solver.solve(&source), Min(None)); assert!(!qubo_solutions.is_empty(), "QUBO solver found no solutions"); for target_solution in qubo_solutions { - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Min(None)); } } diff --git a/src/unit_tests/rules/minimumdominatingset_ilp.rs b/src/unit_tests/rules/minimumdominatingset_ilp.rs index 42c4f4031..63a0e34a9 100644 --- a/src/unit_tests/rules/minimumdominatingset_ilp.rs +++ b/src/unit_tests/rules/minimumdominatingset_ilp.rs @@ -65,7 +65,7 @@ fn test_minimumdominatingset_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 1 (just the center) @@ -98,7 +98,7 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(2))); @@ -126,7 +126,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(3))); @@ -144,7 +144,7 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 0]); // Verify this is a valid DS (0 dominates 0,1 and 2 dominates 2,3) @@ -173,7 +173,7 @@ fn test_isolated_vertices() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Vertex 2 must be selected (isolated) assert_eq!(extracted[2], 1); @@ -193,7 +193,7 @@ fn test_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -208,7 +208,7 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1]); @@ -234,7 +234,7 @@ fn test_cycle_graph() { let bf_size = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, ilp_size); diff --git a/src/unit_tests/rules/minimumedgecostflow_ilp.rs b/src/unit_tests/rules/minimumedgecostflow_ilp.rs index 080748317..d6363ca3e 100644 --- a/src/unit_tests/rules/minimumedgecostflow_ilp.rs +++ b/src/unit_tests/rules/minimumedgecostflow_ilp.rs @@ -75,7 +75,7 @@ fn test_minimumedgecostflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -95,7 +95,7 @@ fn test_minimumedgecostflow_to_ilp_small_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } @@ -104,7 +104,7 @@ fn test_minimumedgecostflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMECFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -133,7 +133,7 @@ fn test_minimumedgecostflow_to_ilp_extract_solution() { target_solution[10] = 1; // y on arc (2,4) target_solution[11] = 1; // y on arc (3,4) - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 6); assert_eq!(extracted, vec![0, 1, 2, 0, 1, 2]); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); diff --git a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs index 48720b069..d8abdd6d7 100644 --- a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs @@ -14,7 +14,7 @@ fn test_emdc_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(2))); @@ -33,7 +33,7 @@ fn test_emdc_to_ilp_compression_wins() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(12))); @@ -78,7 +78,7 @@ fn test_emdc_to_ilp_empty() { assert!(ilp.constraints.is_empty()); // For empty ILP, the solution is empty - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(0))); } @@ -102,7 +102,7 @@ fn test_emdc_to_ilp_single_char() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value, Min(Some(1))); @@ -121,7 +121,7 @@ fn test_emdc_to_ilp_repeated_string() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value, Min(Some(3))); diff --git a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs index a831575ce..89d79951a 100644 --- a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs @@ -59,7 +59,7 @@ fn test_minimumfaultdetectiontestset_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1]); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); @@ -80,7 +80,7 @@ fn test_reduction_is_infeasible_when_an_internal_vertex_has_no_covering_pair() { assert_eq!(problem.evaluate(&[0]), Min(None)); assert_eq!(problem.evaluate(&[1]), Min(None)); - assert!(ILPSolver::new().solve(ilp).is_none()); + assert!(ILPSolver::new().solve(ilp).is_err()); } #[test] @@ -95,7 +95,7 @@ fn test_reduction_handles_instances_without_internal_vertices() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("ILP should be feasible when there are no internal vertices"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs index 4c2d060aa..d08b9b96b 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs @@ -38,7 +38,7 @@ fn test_minimumfeedbackarcset_to_ilp_bf_vs_ilp() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should find optimal value = 1 @@ -55,7 +55,7 @@ fn test_solution_extraction() { // Simulate ILP solution: y_0=0, y_1=0, y_2=1, o_0=0, o_1=1, o_2=2 let ilp_solution = vec![0, 0, 1, 0, 1, 2]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); // Verify this is a valid FAS (removing arc 2->0 breaks the 3-cycle) @@ -79,7 +79,7 @@ fn test_minimumfeedbackarcset_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(0)), "DAG needs no arc removal"); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index c021a9dda..4a95eeebd 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -105,7 +105,7 @@ fn test_solution_extraction_marks_backward_arcs() { let source = issue_example_source(); let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - let source_config = reduction.extract_solution(&[0, 1, 2, 3, 4]); + let source_config = reduction.extract_solution(&[0, 1, 2, 3, 4]).unwrap(); assert_eq!(source_config, vec![0, 0, 1, 0, 0, 1, 0]); } diff --git a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs index 74f12365d..8b63f0a65 100644 --- a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs @@ -37,7 +37,7 @@ fn test_minimumfeedbackvertexset_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 1 @@ -86,7 +86,7 @@ fn test_cycle_of_triangles() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let size = problem.evaluate(&extracted); assert_eq!(size, Min(Some(3)), "FVS should be 3"); @@ -102,7 +102,7 @@ fn test_dag_no_removal() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let size = problem.evaluate(&extracted); assert_eq!(size, Min(Some(0)), "DAG needs no removal"); @@ -123,7 +123,7 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); @@ -149,7 +149,7 @@ fn test_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should remove vertex 1 (cheapest) assert_eq!(extracted[1], 1, "Should remove vertex 1 (cheapest)"); @@ -171,7 +171,7 @@ fn test_two_disjoint_cycles() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(2))); @@ -187,7 +187,7 @@ fn test_solution_extraction() { // Simulate ILP solution: x_0=1, x_1=0, x_2=0, o_0=0, o_1=0, o_2=1 let ilp_solution = vec![1, 0, 0, 0, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); // Verify this is a valid FVS (removing vertex 0 breaks the 3-cycle) diff --git a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs index 577295a8c..fc5223560 100644 --- a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs +++ b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs @@ -32,7 +32,7 @@ fn test_minimumgraphbandwidth_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( ilp_value.0.is_some(), @@ -57,7 +57,7 @@ fn test_minimumgraphbandwidth_to_ilp_path() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!( value, diff --git a/src/unit_tests/rules/minimumhittingset_ilp.rs b/src/unit_tests/rules/minimumhittingset_ilp.rs index fff92587b..cbf912452 100644 --- a/src/unit_tests/rules/minimumhittingset_ilp.rs +++ b/src/unit_tests/rules/minimumhittingset_ilp.rs @@ -22,7 +22,7 @@ fn test_minimumhittingset_to_ilp_bf_vs_ilp() { let bf_solutions = bf.find_all_witnesses(&problem); let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); assert!(ilp_value.is_valid()); @@ -33,7 +33,7 @@ fn test_solution_extraction() { let problem = MinimumHittingSet::new(3, vec![vec![0, 1], vec![1, 2]]); let reduction: ReductionHSToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs index 39a642450..7619aad65 100644 --- a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs @@ -15,7 +15,7 @@ fn test_imdc_to_ilp_closed_loop_simple() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 2); @@ -31,7 +31,7 @@ fn test_imdc_to_ilp_closed_loop_repeated() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 4); @@ -48,7 +48,7 @@ fn test_imdc_to_ilp_closed_loop_low_pointer_cost() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); // Verify against brute force @@ -62,7 +62,7 @@ fn test_imdc_to_ilp_empty_string() { let reduction = ReduceTo::>::reduce_to(&source); let target = reduction.target_problem(); assert_eq!(target.num_variables(), 0); - let source_config = reduction.extract_solution(&[]); + let source_config = reduction.extract_solution(&[]).unwrap(); assert_eq!(source.evaluate(&source_config), Min(Some(0))); } @@ -76,7 +76,7 @@ fn test_imdc_to_ilp_single_char() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.evaluate(&source_config), Min(Some(1))); } @@ -108,7 +108,7 @@ fn test_imdc_to_ilp_vs_brute_force() { let target_witness = BruteForce::new() .find_witness(target) .expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let ilp_val = source.evaluate(&source_config); assert_eq!( diff --git a/src/unit_tests/rules/minimummatrixcover_ilp.rs b/src/unit_tests/rules/minimummatrixcover_ilp.rs index 420f2102e..3eb81b9eb 100644 --- a/src/unit_tests/rules/minimummatrixcover_ilp.rs +++ b/src/unit_tests/rules/minimummatrixcover_ilp.rs @@ -25,7 +25,7 @@ fn test_minimum_matrix_cover_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(-20))); } @@ -66,7 +66,7 @@ fn test_minimum_matrix_cover_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -80,7 +80,7 @@ fn test_minimum_matrix_cover_to_ilp_2x2() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // Optimal: different signs → value = -(3+2) = -5 assert_eq!(value, Min(Some(-5))); @@ -102,7 +102,7 @@ fn test_minimum_matrix_cover_to_ilp_1x1() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("1x1 ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(5))); } @@ -116,7 +116,7 @@ fn test_minimum_matrix_cover_to_ilp_diagonal_matrix() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("diagonal ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All configs give value 2+3+1 = 6 assert_eq!(problem.evaluate(&extracted), Min(Some(6))); } @@ -132,7 +132,7 @@ fn test_minimum_matrix_cover_to_ilp_asymmetric() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); diff --git a/src/unit_tests/rules/minimummaximalmatching_ilp.rs b/src/unit_tests/rules/minimummaximalmatching_ilp.rs index 278244d39..711eb491a 100644 --- a/src/unit_tests/rules/minimummaximalmatching_ilp.rs +++ b/src/unit_tests/rules/minimummaximalmatching_ilp.rs @@ -34,7 +34,7 @@ fn test_minimummaximalmatching_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, Min(Some(1))); @@ -55,7 +55,7 @@ fn test_minimummaximalmatching_to_ilp_path_p6() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); } @@ -70,7 +70,7 @@ fn test_minimummaximalmatching_to_ilp_triangle() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert!(problem.evaluate(&extracted).is_valid()); diff --git a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs index baf7ca2c1..5e8d3f231 100644 --- a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -46,7 +46,7 @@ fn test_minimummaximalmatching_to_maximumachromaticnumber_closed_loop() { "complement(T-tree) must admit an achromatic 4-coloring" ); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!( source.evaluate(&extracted), Min(Some(1)), @@ -81,7 +81,7 @@ fn test_extract_solution_known_coloring() { // The single size-2 class {v2, v1} is the G-edge (v1, v2) = // unified edge (1, 3), source-edge index 1 in the edges list. let coloring = vec![1, 0, 3, 0, 2]; - let extracted = reduction.extract_solution(&coloring); + let extracted = reduction.extract_solution(&coloring).unwrap(); assert_eq!(extracted, vec![0, 1, 0, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(1))); } @@ -101,14 +101,14 @@ fn test_extract_solution_recovers_suboptimal_matchings() { // Source edges in unified order: (0,3), (1,3), (1,4), (2,3). // Edge 0 = (v0, v1) selected; edge 2 = (v2, v3) selected. let coloring_a = vec![0, 1, 2, 0, 1]; - let extracted_a = reduction.extract_solution(&coloring_a); + let extracted_a = reduction.extract_solution(&coloring_a).unwrap(); assert_eq!(extracted_a, vec![1, 0, 1, 0]); assert_eq!(source.evaluate(&extracted_a), Min(Some(2))); // Suboptimal matching {(v1, v4), (v2, v3)} -> pair v1 with v4 and v2 // with v3; v0 takes a singleton color. Edge 2 = (v2, v3); edge 3 = (v1, v4). let coloring_b = vec![2, 0, 1, 1, 0]; - let extracted_b = reduction.extract_solution(&coloring_b); + let extracted_b = reduction.extract_solution(&coloring_b).unwrap(); assert_eq!(extracted_b, vec![0, 0, 1, 1]); assert_eq!(source.evaluate(&extracted_b), Min(Some(2))); } diff --git a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs index ccc6598e9..a6d9fdf6d 100644 --- a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -50,7 +50,7 @@ fn test_minimummaximalmatching_to_minimummatrixdomination_closed_loop() { "matrix domination has at least one optimum" ); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!( source.evaluate(&extracted), Min(Some(2)), @@ -100,7 +100,7 @@ fn test_extract_solution_returns_maximal_matching() { let target_witness = solver .find_witness(target) .expect("matrix domination has an optimum"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); // The result must be a valid maximal matching of the source graph and // realize mm(B) = 2. @@ -163,7 +163,7 @@ fn test_extract_solution_yg_transform_on_non_matching_eds() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&target_witness), Min(Some(2))); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); // The extracted configuration must be a valid maximal matching of B of // size 2 (= mm(B)). Crucially it cannot be {(l0, r1), (l0, r2)} because diff --git a/src/unit_tests/rules/minimummetricdimension_ilp.rs b/src/unit_tests/rules/minimummetricdimension_ilp.rs index c19068eae..45b4c1326 100644 --- a/src/unit_tests/rules/minimummetricdimension_ilp.rs +++ b/src/unit_tests/rules/minimummetricdimension_ilp.rs @@ -22,7 +22,7 @@ fn test_minimummetricdimension_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 2 @@ -80,7 +80,7 @@ fn test_minimummetricdimension_to_ilp_path_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -103,7 +103,7 @@ fn test_minimummetricdimension_to_ilp_complete_graph() { let bf_size = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(3))); @@ -117,7 +117,7 @@ fn test_minimummetricdimension_to_ilp_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); // Verify this is a valid resolving set @@ -136,7 +136,7 @@ fn test_minimummetricdimension_to_ilp_cycle() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); diff --git a/src/unit_tests/rules/minimummultiwaycut_ilp.rs b/src/unit_tests/rules/minimummultiwaycut_ilp.rs index 99260a2ab..7530a3c0f 100644 --- a/src/unit_tests/rules/minimummultiwaycut_ilp.rs +++ b/src/unit_tests/rules/minimummultiwaycut_ilp.rs @@ -42,7 +42,7 @@ fn test_minimummultiwaycut_to_ilp_closed_loop() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); // Optimal cut cost is 8 @@ -63,7 +63,7 @@ fn test_triangle_with_3_terminals() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let obj = problem.evaluate(&extracted); assert_eq!(obj, Min(Some(6))); @@ -81,7 +81,7 @@ fn test_two_terminals() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let obj = problem.evaluate(&extracted); assert_eq!(obj, Min(Some(1))); @@ -118,7 +118,7 @@ fn test_solution_extraction() { ilp_solution[15 + 3] = 1; // edge (3,4) cut ilp_solution[15 + 4] = 1; // edge (0,4) cut - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1, 1, 0]); let obj = problem.evaluate(&extracted); @@ -131,7 +131,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimummultiwaycut_qubo.rs b/src/unit_tests/rules/minimummultiwaycut_qubo.rs index 4b42b97c7..0f130208e 100644 --- a/src/unit_tests/rules/minimummultiwaycut_qubo.rs +++ b/src/unit_tests/rules/minimummultiwaycut_qubo.rs @@ -19,7 +19,7 @@ fn test_minimummultiwaycut_to_qubo_closed_loop() { // All QUBO optimal solutions should extract to valid source solutions with cost 8 for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); assert_eq!(metric, Min(Some(8))); } @@ -41,7 +41,7 @@ fn test_minimummultiwaycut_to_qubo_small() { // All solutions should extract to valid cuts for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); // With 2 terminals and path 0-1-2, minimum cut is 1 (cut either edge) assert_eq!(metric, Min(Some(1))); diff --git a/src/unit_tests/rules/minimumsetcovering_ilp.rs b/src/unit_tests/rules/minimumsetcovering_ilp.rs index cd16428a2..aad3616a0 100644 --- a/src/unit_tests/rules/minimumsetcovering_ilp.rs +++ b/src/unit_tests/rules/minimumsetcovering_ilp.rs @@ -56,7 +56,7 @@ fn test_minimumsetcovering_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 2 let bf_size: usize = bf_solutions[0].iter().sum(); @@ -92,7 +92,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(6))); @@ -109,7 +109,7 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // Verify this is a valid set cover @@ -137,7 +137,7 @@ fn test_single_set_covers_all() { let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // First set alone covers everything with weight 1 assert_eq!(extracted, vec![1, 0, 0, 0]); @@ -156,7 +156,7 @@ fn test_overlapping_sets() { let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Need both sets to cover all elements assert_eq!(extracted, vec![1, 1]); @@ -184,7 +184,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumsummulticenter_ilp.rs b/src/unit_tests/rules/minimumsummulticenter_ilp.rs index 2f9047f6f..f95493000 100644 --- a/src/unit_tests/rules/minimumsummulticenter_ilp.rs +++ b/src/unit_tests/rules/minimumsummulticenter_ilp.rs @@ -48,7 +48,7 @@ fn test_minimumsummulticenter_to_ilp_bf_vs_ilp() { let bf_cost = problem.evaluate(&bf_witness).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -84,7 +84,7 @@ fn test_minimumsummulticenter_to_ilp_respects_weighted_shortest_paths() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted, bf_witness, @@ -112,7 +112,7 @@ fn test_solution_extraction() { 0, 1, 0, // y_{1,0}, y_{1,1}, y_{1,2} 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} ]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted).unwrap(), 2); } @@ -130,7 +130,7 @@ fn test_minimumsummulticenter_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(extracted, vec![1]); assert_eq!(problem.evaluate(&extracted).unwrap(), 0); diff --git a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs index ba211afee..d40fdedb0 100644 --- a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs +++ b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs @@ -31,7 +31,7 @@ fn test_minimumtardinesssequencing_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -46,7 +46,7 @@ fn test_minimumtardinesssequencing_to_ilp_no_precedences() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } @@ -58,7 +58,7 @@ fn test_minimumtardinesssequencing_to_ilp_all_tight() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value.0, Some(2)); @@ -95,7 +95,7 @@ fn test_minimumtardinesssequencing_weighted_to_ilp_vs_brute_force() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); diff --git a/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs b/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs index ecf5c4322..cba17eb04 100644 --- a/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs @@ -10,7 +10,7 @@ use crate::traits::Problem; fn decision_mvc( num_vertices: usize, edges: &[(usize, usize)], - k: i32, + k: i64, ) -> Decision> { Decision::new( MinimumVertexCover::new( @@ -89,7 +89,7 @@ fn test_minimumvertexcover_to_comparativecontainment_extracts_cover() { let witness = BruteForce::new() .find_witness(reduction.target_problem()) .expect("triangle with K=2 should be satisfiable"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -110,7 +110,7 @@ fn test_minimumvertexcover_to_comparativecontainment_trivial_yes_k_equals_n() { assert!(target.evaluate(&[]).0); // Extracted source configuration must be a valid cover with size <= K. - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -123,7 +123,7 @@ fn test_minimumvertexcover_to_comparativecontainment_trivial_yes_k_greater_than_ let target = reduction.target_problem(); assert_eq!(target.universe_size(), 0); - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs index afb8d3af6..38bb414c0 100644 --- a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs @@ -40,7 +40,7 @@ fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { // Every extracted solution must be a valid vertex cover let witnesses = solver.find_all_witnesses(target); for witness in &witnesses { - let source_config = reduction.extract_solution(witness); + let source_config = reduction.extract_solution(witness).unwrap(); assert_eq!(source_config.len(), 2); assert!( is_valid_cover(&graph, &source_config), @@ -100,7 +100,7 @@ fn test_extract_solution_correctness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config), Min(Some(2))); - let cover = reduction.extract_solution(&config); + let cover = reduction.extract_solution(&config).unwrap(); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); } @@ -117,7 +117,7 @@ fn test_extract_from_non_normalized_witness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config), Min(Some(2))); - let cover = reduction.extract_solution(&config); + let cover = reduction.extract_solution(&config).unwrap(); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); } diff --git a/src/unit_tests/rules/minimumvertexcover_ilp.rs b/src/unit_tests/rules/minimumvertexcover_ilp.rs index 736072a62..0d101a55a 100644 --- a/src/unit_tests/rules/minimumvertexcover_ilp.rs +++ b/src/unit_tests/rules/minimumvertexcover_ilp.rs @@ -1,10 +1,10 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinimumVertexCover; -use crate::rules::{MinimizeSteps, ReductionChain, ReductionGraph, ReductionPath}; +use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{Min, ProblemSize}; +use crate::types::Min; fn reduce_vc_to_ilp( problem: &MinimumVertexCover, @@ -13,15 +13,10 @@ fn reduce_vc_to_ilp( let src = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let dst = ReductionGraph::variant_to_map(&ILP::::variant()); let path = graph - .find_cheapest_path( - "MinimumVertexCover", - &src, - "ILP", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .expect("Should find path MinimumVertexCover -> ILP"); + .find_all_paths("MinimumVertexCover", &src, "ILP", &dst) + .into_iter() + .find(|path| path.type_names() == ["MinimumVertexCover", "MinimumSetCovering", "ILP"]) + .expect("expected explicit MinimumSetCovering route"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) .expect("Should reduce MinimumVertexCover to ILP along path"); @@ -61,7 +56,7 @@ fn test_minimumvertexcover_to_ilp_via_path_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); let ilp_size: usize = extracted.iter().sum(); assert_eq!(ilp_size, 2); @@ -77,7 +72,7 @@ fn test_minimumvertexcover_to_ilp_via_path_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted, vec![0, 1, 0]); @@ -94,6 +89,6 @@ fn test_minimumvertexcover_to_ilp_bf_vs_ilp() { let bf_solutions = BruteForce::new().find_all_witnesses(&problem); let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs index 5b6673a24..a397399cc 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -108,7 +108,7 @@ fn test_solution_extraction() { // Target has 9 arcs; first 3 are internal. Extract should take first 3. let target_config = vec![1, 1, 0, 0, 0, 0, 0, 0, 0]; - let source_config = reduction.extract_solution(&target_config); + let source_config = reduction.extract_solution(&target_config).unwrap(); assert_eq!(source_config, vec![1, 1, 0]); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs index 4ae5fd263..c9155c35a 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -77,7 +77,7 @@ fn test_identity_solution_extraction() { ReduceTo::>::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1]), + reduction.extract_solution(&[1, 0, 1, 0, 1]).unwrap(), vec![1, 0, 1, 0, 1] ); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs index 9e7a7b6a3..99c749b7a 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs @@ -123,6 +123,6 @@ fn test_vc_to_hs_solution_extraction() { let reduction = ReduceTo::::reduce_to(&vc_problem); let target_solution = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs index 641468712..1cf25a481 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -81,7 +81,10 @@ fn test_weighted_vertices_are_charged_on_sink_arcs() { assert_eq!(source.evaluate(&[0, 1, 0]), Min(Some(1))); assert_eq!(target.evaluate(&target_solution), Min(Some(5))); assert_eq!(target.arc_weights(), &[1, 1, 1, 1, 1, 1, 4, 1, 3]); - assert_eq!(reduction.extract_solution(&target_solution), vec![0, 1, 0]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![0, 1, 0] + ); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/minimumvertexcover_qubo.rs b/src/unit_tests/rules/minimumvertexcover_qubo.rs index 8b4dd1711..785b2e4ae 100644 --- a/src/unit_tests/rules/minimumvertexcover_qubo.rs +++ b/src/unit_tests/rules/minimumvertexcover_qubo.rs @@ -1,10 +1,10 @@ use crate::models::algebraic::QUBO; use crate::models::graph::MinimumVertexCover; -use crate::rules::{Minimize, ReductionChain, ReductionGraph, ReductionPath}; +use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::BruteForce; -use crate::topology::{Graph, SimpleGraph}; +use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{Min, ProblemSize}; +use crate::types::Min; fn reduce_vc_to_qubo( problem: &MinimumVertexCover, @@ -13,18 +13,18 @@ fn reduce_vc_to_qubo( let src = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); let path = graph - .find_cheapest_path( - "MinimumVertexCover", - &src, - "QUBO", - &dst, - &ProblemSize::new(vec![ - ("num_vertices", problem.graph().num_vertices()), - ("num_edges", problem.graph().num_edges()), - ]), - &Minimize("num_vars"), - ) - .expect("Should find path MinimumVertexCover -> QUBO"); + .find_all_paths("MinimumVertexCover", &src, "QUBO", &dst) + .into_iter() + .find(|path| { + path.type_names() + == [ + "MinimumVertexCover", + "MaximumIndependentSet", + "MaximumSetPacking", + "QUBO", + ] + }) + .expect("expected explicit MaximumIndependentSet route"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) .expect("Should reduce MinimumVertexCover to QUBO along path"); @@ -58,7 +58,7 @@ fn test_minimumvertexcover_to_qubo_via_path_closed_loop() { let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol); + let extracted = chain.extract_solution(sol).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -75,7 +75,7 @@ fn test_minimumvertexcover_to_qubo_via_path_weighted() { let qubo_solution = solver .find_witness(qubo) .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted, vec![0, 1, 0]); @@ -94,7 +94,7 @@ fn test_minimumvertexcover_to_qubo_via_path_star_graph() { let solver = BruteForce::new(); let qubo_solution = solver.find_witness(qubo).expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 1); diff --git a/src/unit_tests/rules/minimumweightdecoding_ilp.rs b/src/unit_tests/rules/minimumweightdecoding_ilp.rs index 5f589bf46..fd3702ea4 100644 --- a/src/unit_tests/rules/minimumweightdecoding_ilp.rs +++ b/src/unit_tests/rules/minimumweightdecoding_ilp.rs @@ -62,7 +62,7 @@ fn test_minimumweightdecoding_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -82,7 +82,7 @@ fn test_minimumweightdecoding_to_ilp_small_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } @@ -91,7 +91,7 @@ fn test_minimumweightdecoding_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMinimumWeightDecodingToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -114,7 +114,7 @@ fn test_minimumweightdecoding_to_ilp_extract_solution() { // Row 1: H[1][2]=1 → sum=1, s=1 → 1-1=0 → k_1=0 ✓ // Row 2: H[2][2]=0 → sum=0, s=0 → 0-0=0 → k_2=0 ✓ let target_solution = vec![0, 0, 1, 0, 0, 0, 0]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 4); assert_eq!(extracted, vec![0, 0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); diff --git a/src/unit_tests/rules/minmaxmulticenter_ilp.rs b/src/unit_tests/rules/minmaxmulticenter_ilp.rs index 0bf3b8787..506c1cb2d 100644 --- a/src/unit_tests/rules/minmaxmulticenter_ilp.rs +++ b/src/unit_tests/rules/minmaxmulticenter_ilp.rs @@ -51,7 +51,7 @@ fn test_minmaxmulticenter_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Min(Some(1))); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -80,7 +80,7 @@ fn test_solution_extraction() { 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} 1, // z ]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); } @@ -104,7 +104,7 @@ fn test_minmaxmulticenter_to_ilp_weighted() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(100))); } @@ -119,7 +119,7 @@ fn test_minmaxmulticenter_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); } diff --git a/src/unit_tests/rules/mixedchinesepostman_ilp.rs b/src/unit_tests/rules/mixedchinesepostman_ilp.rs index cda1e1b40..d9307dd8e 100644 --- a/src/unit_tests/rules/mixedchinesepostman_ilp.rs +++ b/src/unit_tests/rules/mixedchinesepostman_ilp.rs @@ -22,7 +22,7 @@ fn test_mixedchinesepostman_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted).0.is_some()); } @@ -42,7 +42,7 @@ fn test_mixedchinesepostman_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert_eq!( @@ -66,7 +66,7 @@ fn test_mixedchinesepostman_to_ilp_weighted() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert_eq!( diff --git a/src/unit_tests/rules/monochromatictriangle_ilp.rs b/src/unit_tests/rules/monochromatictriangle_ilp.rs index f55c96ee4..e078f8621 100644 --- a/src/unit_tests/rules/monochromatictriangle_ilp.rs +++ b/src/unit_tests/rules/monochromatictriangle_ilp.rs @@ -46,7 +46,7 @@ fn test_monochromatic_triangle_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("K4 should admit a monochromatic-triangle-free 2-edge-coloring"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, ilp_solution); assert!(problem.evaluate(&extracted)); @@ -64,7 +64,7 @@ fn test_monochromatic_triangle_to_ilp_infeasible_k6() { let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "K6 should be infeasible by R(3,3)=6" ); } @@ -75,7 +75,7 @@ fn test_monochromatic_triangle_to_ilp_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&problem); let coloring = vec![0, 0, 1, 1, 0, 1]; - let extracted = reduction.extract_solution(&coloring); + let extracted = reduction.extract_solution(&coloring).unwrap(); assert_eq!(extracted, coloring); assert!(problem.evaluate(&extracted)); diff --git a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs index 7c4cbfcd1..2223b08ea 100644 --- a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs +++ b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs @@ -46,7 +46,7 @@ fn test_multiplecopyfileallocation_to_ilp_bf_vs_ilp() { assert!(problem.evaluate(&bf_witness).0.is_some()); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -73,7 +73,7 @@ fn test_solution_extraction() { 0, 1, 0, // y_{1,0}, y_{1,1}, y_{1,2} 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} ]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(7))); } @@ -91,7 +91,7 @@ fn test_multiplecopyfileallocation_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); } diff --git a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs index 311d7dedf..58c9f7a5f 100644 --- a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs +++ b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs @@ -45,7 +45,7 @@ fn test_multiprocessorscheduling_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -62,7 +62,7 @@ fn test_solution_extraction() { // Manually set: task 0 → proc 0, task 1 → proc 1, task 2 → proc 0 // Variables: x_{0,0}=1, x_{0,1}=0, x_{1,0}=0, x_{1,1}=1, x_{2,0}=1, x_{2,1}=0 let ilp_solution = vec![1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); // loads: proc 0 = 1+3=4 ≤ 5, proc 1 = 2 ≤ 5 assert_eq!(problem.evaluate(&extracted), Or(true)); @@ -82,6 +82,6 @@ fn test_multiprocessorscheduling_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/naesatisfiability_ilp.rs b/src/unit_tests/rules/naesatisfiability_ilp.rs index bd2efef04..1cf6da83c 100644 --- a/src/unit_tests/rules/naesatisfiability_ilp.rs +++ b/src/unit_tests/rules/naesatisfiability_ilp.rs @@ -44,7 +44,7 @@ fn test_naesatisfiability_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -74,7 +74,7 @@ fn test_naesatisfiability_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); // The ILP should be infeasible: x1 ≥ 1 (at least one true) AND x1 ≤ 0 (at least one false) assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsatisfiable NAE-SAT" ); } @@ -98,7 +98,7 @@ fn test_naesatisfiability_to_ilp_negative_literals() { let ilp_solution = ilp_solver .solve(ilp) .expect("NAE-SAT with (¬x1 ∨ x2) is feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 265df4574..4e833f639 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -106,7 +106,7 @@ fn test_naesatisfiability_to_maxcut_extract_solution() { // x2=F -> vertex 2 in set 0, vertex 3 in set 1 // x3=T -> vertex 4 in set 1, vertex 5 in set 0 let target_config = vec![1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); // x1=T, x2=F, x3=T // Verify this is a valid NAE-SAT solution diff --git a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs index 412748a98..8a2b26709 100644 --- a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -246,7 +246,7 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_constructed_witness_r assert!(source.evaluate(&source_solution)); assert!(reduction.target_problem().evaluate(&target_solution)); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), source_solution ); } @@ -264,7 +264,7 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_two_literal_clause_no assert_eq!(target.num_matchings(), 2); assert!(target.evaluate(&target_solution)); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), source_solution ); } diff --git a/src/unit_tests/rules/naesatisfiability_setsplitting.rs b/src/unit_tests/rules/naesatisfiability_setsplitting.rs index a52ea2206..0e3d91895 100644 --- a/src/unit_tests/rules/naesatisfiability_setsplitting.rs +++ b/src/unit_tests/rules/naesatisfiability_setsplitting.rs @@ -53,7 +53,7 @@ fn test_naesatisfiability_to_setsplitting_extract_solution_uses_positive_literal let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1, 0]), + reduction.extract_solution(&[1, 0, 1, 0, 1, 0]).unwrap(), vec![1, 0, 1] ); } @@ -65,7 +65,7 @@ fn test_naesatisfiability_to_setsplitting_target_witness_extracts_to_satisfying_ let solver = BruteForce::new(); let target_solution = solver.find_witness(reduction.target_problem()).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&source_solution)); } diff --git a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 898e22396..3e76c851d 100644 --- a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -41,7 +41,7 @@ fn test_n3dm_to_nmts_extracts_target_witness_into_source_witness() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![2, 0, 1, 0, 2, 1]); assert!(source.evaluate(&extracted).0); } @@ -54,7 +54,7 @@ fn test_n3dm_to_nmts_handles_repeated_targets() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs index b6a1dd3ee..2d1318f6f 100644 --- a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs @@ -20,7 +20,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -60,7 +60,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_unsatisfiable() { let reduction = ReduceTo::>::reduce_to(&problem); let result = ILPSolver::new().solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Unsatisfiable instance should have no ILP solution" ); } @@ -78,7 +78,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_single_pair() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("single-pair ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -98,7 +98,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_compatible_triples_only() { assert_eq!(ilp.num_vars(), 2); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/openshopscheduling_ilp.rs b/src/unit_tests/rules/openshopscheduling_ilp.rs index cd5528432..d9016c62a 100644 --- a/src/unit_tests/rules/openshopscheduling_ilp.rs +++ b/src/unit_tests/rules/openshopscheduling_ilp.rs @@ -60,7 +60,7 @@ fn test_openshopscheduling_to_ilp_closed_loop_small() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -78,7 +78,7 @@ fn test_openshopscheduling_to_ilp_closed_loop_medium() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -103,7 +103,7 @@ fn test_openshopscheduling_to_ilp_extract_solution_respects_start_times() { // => M1: job 1 starts at 0, job 0 starts at 1 → order [1, 0] // => M2: job 0 starts at 0, job 1 starts at 2 → order [0, 1] let target_solution = vec![0, 1, 1, 0, 0, 2, 0, 1, 3]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // M1: J1 at t=0, J0 at t=1 → order [1, 0] // M2: J0 at t=0, J1 at t=2 → order [0, 1] assert_eq!(extracted[0..2], [1, 0], "M1 order should be [1, 0]"); @@ -122,7 +122,7 @@ fn test_openshopscheduling_to_ilp_single_job() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!(value.0.is_some()); assert_eq!(value, Min(Some(7))); @@ -136,7 +136,7 @@ fn test_openshopscheduling_to_ilp_single_machine() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!(value.0.is_some()); assert_eq!(value, Min(Some(6))); diff --git a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 168d6c825..a110ce603 100644 --- a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -57,7 +57,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_closed_loo assert_eq!(target.evaluate(&target_witness), Or(true)); // Reconstructed source arrangement must be a valid arrangement of length <= k. - let arrangement = reduction.extract_solution(&target_witness); + let arrangement = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.evaluate(&arrangement), Or(true)); } @@ -95,9 +95,10 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_edgeless_s assert_eq!(target.evaluate(&witness), Or(true)); // Reconstructed source arrangement covers all 3 vertices and is YES. - let arrangement = reduction.extract_solution(&witness); + let arrangement = reduction.extract_solution(&witness).unwrap(); assert_eq!(arrangement.len(), 3); assert_eq!(source.evaluate(&arrangement), Or(true)); + assert!(reduction.extract_solution(&[]).is_err()); } #[test] @@ -128,22 +129,26 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_negative_b BruteForce::new().find_witness(&source).is_none(), "P_6 has no arrangement of length <= 4" ); + assert!(reduction.extract_solution(&[]).is_err()); } #[test] fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_extract_invalid() { - // A non-permutation target solution falls back to the identity arrangement. let source = decision_ola(example_graph(), 11); let reduction = ReduceTo::::reduce_to(&source); - // Wrong length. assert_eq!( - reduction.extract_solution(&[0, 1, 2]), - vec![0, 1, 2, 3, 4, 5] + reduction + .extract_solution(&[0, 1, 2]) + .unwrap_err() + .to_string(), + "expected 6 target values, got 3" ); - // Repeated column. assert_eq!( - reduction.extract_solution(&[0, 0, 1, 2, 3, 4]), - vec![0, 1, 2, 3, 4, 5] + reduction + .extract_solution(&[0, 0, 1, 2, 3, 4]) + .unwrap_err() + .to_string(), + "target column order is not a permutation" ); } diff --git a/src/unit_tests/rules/optimallineararrangement_ilp.rs b/src/unit_tests/rules/optimallineararrangement_ilp.rs index 661b50569..50cb2a547 100644 --- a/src/unit_tests/rules/optimallineararrangement_ilp.rs +++ b/src/unit_tests/rules/optimallineararrangement_ilp.rs @@ -31,7 +31,7 @@ fn test_optimallineararrangement_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0.is_some(), "ILP solution should produce a valid arrangement" @@ -59,7 +59,7 @@ fn test_optimallineararrangement_to_ilp_with_chords() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -71,7 +71,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index f26fdcd03..9e83885fd 100644 --- a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -89,7 +89,7 @@ fn test_optimallineararrangement_to_sequencingtominimizeweightedcompletiontime_e let (source, reduction) = reduce_path(4); let schedule = vec![3, 2, 6, 1, 5, 0, 4]; let target_solution = permutation_to_lehmer(&schedule); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![3, 2, 1, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(3))); diff --git a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs index b0faca24e..561e4b4a8 100644 --- a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs +++ b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs @@ -80,7 +80,7 @@ fn test_ocst_to_ilp_bf_vs_ilp_k3() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -99,7 +99,7 @@ fn test_ocst_to_ilp_bf_vs_ilp_k4() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -115,7 +115,7 @@ fn test_ocst_to_ilp_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should be a valid config with m=3 entries assert_eq!(extracted.len(), 3); diff --git a/src/unit_tests/rules/paintshop_ilp.rs b/src/unit_tests/rules/paintshop_ilp.rs index b728e0d61..34bfbfc7e 100644 --- a/src/unit_tests/rules/paintshop_ilp.rs +++ b/src/unit_tests/rules/paintshop_ilp.rs @@ -41,7 +41,7 @@ fn test_paintshop_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -56,7 +56,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); // Either 0 or 1 is valid; coloring is [x, 1-x], switches = 1 assert!(problem.evaluate(&extracted).is_valid()); diff --git a/src/unit_tests/rules/paintshop_qubo.rs b/src/unit_tests/rules/paintshop_qubo.rs index 385d60dad..39c51d6b7 100644 --- a/src/unit_tests/rules/paintshop_qubo.rs +++ b/src/unit_tests/rules/paintshop_qubo.rs @@ -47,7 +47,7 @@ fn test_paintshop_to_qubo_optimal_value() { // Extract solutions and verify they are optimal for the source for sol in &best_target { - let source_sol = reduction.extract_solution(sol); + let source_sol = reduction.extract_solution(sol).unwrap(); let switches = source.count_switches(&source_sol); // Optimal is 2 switches assert_eq!(switches, 2, "Expected 2 switches for optimal solution"); diff --git a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs index 5553c3831..4a61de474 100644 --- a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs +++ b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs @@ -26,7 +26,7 @@ fn test_partiallyorderedknapsack_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -41,7 +41,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/partition_binpacking.rs b/src/unit_tests/rules/partition_binpacking.rs index c70410722..482e330eb 100644 --- a/src/unit_tests/rules/partition_binpacking.rs +++ b/src/unit_tests/rules/partition_binpacking.rs @@ -45,7 +45,7 @@ fn test_partition_to_binpacking_odd_total_is_not_satisfying() { let value = target.evaluate(&best); assert_eq!(value, Min(Some(3))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/partition_cosineproductintegration.rs b/src/unit_tests/rules/partition_cosineproductintegration.rs index 410a1d9c8..739ac5916 100644 --- a/src/unit_tests/rules/partition_cosineproductintegration.rs +++ b/src/unit_tests/rules/partition_cosineproductintegration.rs @@ -69,7 +69,7 @@ fn test_partition_to_cosineproductintegration_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol); let source_valid = source.evaluate(&extracted); diff --git a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs index 6149a2d9e..65cd581f3 100644 --- a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs +++ b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs @@ -71,7 +71,10 @@ fn test_partition_to_integralflowwithmultipliers_odd_total_is_fixed_no_instance( assert_eq!(target.capacities(), &[1, 1]); assert_eq!(target.requirement(), 1); assert!(BruteForce::new().find_witness(target).is_none()); - assert_eq!(reduction.extract_solution(&[]), vec![0, 0]); + assert_eq!( + reduction.extract_solution(&[]).unwrap_err().to_string(), + "the fixed infeasible target instance has no extractable witness" + ); } #[test] @@ -80,7 +83,9 @@ fn test_partition_to_integralflowwithmultipliers_extract_solution() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12]), + reduction + .extract_solution(&[1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12]) + .unwrap(), vec![1, 0, 1, 0, 1, 0] ); } diff --git a/src/unit_tests/rules/partition_knapsack.rs b/src/unit_tests/rules/partition_knapsack.rs index e308d172c..edddea5f3 100644 --- a/src/unit_tests/rules/partition_knapsack.rs +++ b/src/unit_tests/rules/partition_knapsack.rs @@ -40,7 +40,7 @@ fn test_partition_to_knapsack_odd_total_is_not_satisfying() { assert_eq!(target.evaluate(&best), Max(Some(5))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/partition_multiprocessorscheduling.rs b/src/unit_tests/rules/partition_multiprocessorscheduling.rs index b72884a81..0404c2087 100644 --- a/src/unit_tests/rules/partition_multiprocessorscheduling.rs +++ b/src/unit_tests/rules/partition_multiprocessorscheduling.rs @@ -83,7 +83,7 @@ fn test_partition_to_multiprocessorscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); // Solution length should match number of elements assert_eq!(extracted.len(), source.num_elements()); // Extracted solution should satisfy source if target is satisfied diff --git a/src/unit_tests/rules/partition_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs index ff3b42f81..e02aed5cd 100644 --- a/src/unit_tests/rules/partition_openshopscheduling.rs +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -41,7 +41,7 @@ fn test_partition_to_open_shop_scheduling_extract_solution() { let target_solution = BruteForce::new() .find_witness(target) .expect("target should have an optimal solution"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // The extracted solution should be a valid partition decision assert_eq!(extracted.len(), 3); @@ -60,5 +60,5 @@ fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { .expect("open-shop target should always have an optimal solution"); assert_eq!(target.evaluate(&best), Min(Some(16))); - assert!(!source.evaluate(&reduction.extract_solution(&best))); + assert!(!source.evaluate(&reduction.extract_solution(&best).unwrap())); } diff --git a/src/unit_tests/rules/partition_productionplanning.rs b/src/unit_tests/rules/partition_productionplanning.rs index ceca9105c..26d7f98a7 100644 --- a/src/unit_tests/rules/partition_productionplanning.rs +++ b/src/unit_tests/rules/partition_productionplanning.rs @@ -48,7 +48,7 @@ fn test_partition_to_productionplanning_extract_solution() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[0, 0, 0, 4, 6, 0]), + reduction.extract_solution(&[0, 0, 0, 4, 6, 0]).unwrap(), vec![0, 0, 0, 1, 1] ); } diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs index 75749bc3b..bd8b82888 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -38,7 +38,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 2, 4, 5, 0, 3]), + reduction.extract_solution(&[1, 2, 4, 5, 0, 3]).unwrap(), vec![1, 0, 0, 1, 0, 0] ); } @@ -53,7 +53,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsat .expect("target should always have an optimal schedule"); assert_eq!(target.evaluate(&best), Min(Some(6))); - assert!(!source.evaluate(&reduction.extract_solution(&best))); + assert!(!source.evaluate(&reduction.extract_solution(&best).unwrap())); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs index 9395e8571..b980b6a2f 100644 --- a/src/unit_tests/rules/partition_subsetsum.rs +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -1,7 +1,6 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; -use crate::traits::Problem; #[test] fn test_partition_to_subsetsum_closed_loop() { @@ -48,11 +47,11 @@ fn test_partition_to_subsetsum_odd_total() { let witness = BruteForce::new().find_witness(target); assert!(witness.is_none()); - // extract_solution should return all-zeros for the source - let extracted = reduction.extract_solution(&[]); - assert_eq!(extracted, vec![0, 0, 0]); - // The extracted solution should not satisfy the source - assert!(!source.evaluate(&extracted)); + let error = reduction.extract_solution(&[]).unwrap_err(); + assert_eq!( + error.to_string(), + "expected 3 subset-selection values, got 0" + ); } #[test] @@ -67,3 +66,11 @@ fn test_partition_to_subsetsum_equal_elements() { "Partition -> SubsetSum equal elements", ); } + +#[test] +fn test_partition_to_subsetsum_rejects_wrong_solution_length() { + let source = Partition::new(vec![1, 1, 2, 2]); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(reduction.extract_solution(&[0, 1, 0]).is_err()); +} diff --git a/src/unit_tests/rules/partition_sumofsquarespartition.rs b/src/unit_tests/rules/partition_sumofsquarespartition.rs index c1801c296..d124a0e72 100644 --- a/src/unit_tests/rules/partition_sumofsquarespartition.rs +++ b/src/unit_tests/rules/partition_sumofsquarespartition.rs @@ -30,7 +30,7 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { let target_witnesses = solver.find_all_witnesses(target_no_even); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction_no_even.extract_solution(witness); + let extracted = reduction_no_even.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source_no_even.num_elements()); assert!( !source_no_even.evaluate(&extracted).0, @@ -47,7 +47,7 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { let target_witnesses_odd = solver.find_all_witnesses(target_no_odd); assert!(!target_witnesses_odd.is_empty()); for witness in &target_witnesses_odd { - let extracted = reduction_no_odd.extract_solution(witness); + let extracted = reduction_no_odd.extract_solution(witness).unwrap(); assert!( !source_no_odd.evaluate(&extracted).0, "odd-sum NO Partition: extracted witness {extracted:?} should not satisfy source" @@ -104,9 +104,9 @@ fn test_partition_to_sumofsquarespartition_singleton_sentinel() { assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source.num_elements()); - assert_eq!(extracted, vec![0]); + assert_eq!(extracted, witness[..source.num_elements()]); assert!( !source.evaluate(&extracted).0, "singleton Partition: extracted witness must yield Or(false)" @@ -130,11 +130,13 @@ fn test_partition_to_sumofsquarespartition_solution_extraction_identity() { solver.find_all_witnesses(&source).into_iter().collect(); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted, *witness); assert!( source_witnesses.contains(&extracted), "extracted witness {extracted:?} must be a valid Partition solution" ); } + + assert!(reduction.extract_solution(&[0]).is_err()); } diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index b1b5d9836..492b51cf4 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -2,7 +2,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::topology::Graph; use crate::traits::Problem; -use crate::types::{Min, Or}; +use crate::types::Min; #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { @@ -68,7 +68,10 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure ], ); assert_eq!(target.evaluate(&target_solution), Min(Some(6))); - assert_eq!(reduction.extract_solution(&target_solution), vec![0, 0, 1]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![0, 0, 1] + ); } #[test] @@ -97,7 +100,11 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_unsat_extracts_invalid_ ); assert_eq!(target.evaluate(&target_solution), Min(Some(4))); - let extracted = reduction.extract_solution(&target_solution); - - assert_eq!(source.evaluate(&extracted), Or(false)); + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "target cover uses 2 cliques, exceeding source bound 1" + ); } diff --git a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 66482bc1e..83a9a0898 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -88,7 +88,7 @@ fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_extract_so let result = ReduceTo::>::reduce_to(&source); let target_config = vec![0, 0, 0, 1, 1, 1]; - let extracted = result.extract_solution(&target_config); + let extracted = result.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); // Verify the extracted solution is valid in the source diff --git a/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs b/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs index a76085cd5..365f11f6f 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs @@ -41,7 +41,7 @@ fn test_partitionintopathsoflength2_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -65,7 +65,7 @@ fn test_solution_extraction() { 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, // x vars 1, 0, 1, 0, 0, 1, 0, 1, // y vars ]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -80,7 +80,7 @@ fn test_partitionintopathsoflength2_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), diff --git a/src/unit_tests/rules/partitionintotriangles_ilp.rs b/src/unit_tests/rules/partitionintotriangles_ilp.rs index e66443b53..982df3ada 100644 --- a/src/unit_tests/rules/partitionintotriangles_ilp.rs +++ b/src/unit_tests/rules/partitionintotriangles_ilp.rs @@ -41,7 +41,7 @@ fn test_partitionintotriangles_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -59,7 +59,7 @@ fn test_solution_extraction() { // x_{v,g}: v0g0=1,v0g1=0, v1g0=1,v1g1=0, v2g0=1,v2g1=0, // v3g0=0,v3g1=1, v4g0=0,v4g1=1, v5g0=0,v5g1=1 let ilp_solution = vec![1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -74,6 +74,6 @@ fn test_partitionintotriangles_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs index a6f971326..b3a9b4476 100644 --- a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs @@ -25,7 +25,7 @@ fn test_pathconstrainednetworkflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs index 4f4e3a363..4c2bca369 100644 --- a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs @@ -44,7 +44,7 @@ fn test_precedenceconstrainedscheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for feasible instance"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -57,7 +57,7 @@ fn test_precedenceconstrainedscheduling_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionPCSToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible scheduling instance should produce infeasible ILP" ); } @@ -70,7 +70,7 @@ fn test_precedenceconstrainedscheduling_to_ilp_extract_solution() { // Manually: task 0 at slot 0, task 1 at slot 0, task 2 at slot 1 // x_{0,0}=1, x_{0,1}=0, x_{1,0}=1, x_{1,1}=0, x_{2,0}=0, x_{2,1}=1 let ilp_solution = vec![1, 0, 1, 0, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs index 95a0258a5..2ef0c0448 100644 --- a/src/unit_tests/rules/preemptivescheduling_ilp.rs +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -47,7 +47,7 @@ fn test_preemptivescheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -55,6 +55,16 @@ fn test_preemptivescheduling_to_ilp_closed_loop() { ); } +#[test] +fn test_solve_reduced_supports_direct_ilp_i32_reductions() { + let problem = small_instance(); + let solution = ILPSolver::new() + .solve_reduced::(&problem) + .expect("direct ILP reduction should be solvable"); + + assert!(problem.evaluate(&solution).0.is_some()); +} + #[test] fn test_preemptivescheduling_to_ilp_medium_closed_loop() { let p = medium_instance(); @@ -62,7 +72,7 @@ fn test_preemptivescheduling_to_ilp_medium_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -87,7 +97,7 @@ fn test_preemptivescheduling_to_ilp_infeasible() { let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); let sol = ILPSolver::new().solve(reduction.target_problem()); // 1 processor, t0 at slot 0, t1 at slot 1 → always feasible - assert!(sol.is_some(), "should be feasible"); + assert!(sol.is_ok(), "should be feasible"); } // ─── extract_solution ────────────────────────────────────────────────────── @@ -99,7 +109,7 @@ fn test_preemptivescheduling_to_ilp_extract_solution() { let p = small_instance(); let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); let ilp_solution = vec![1, 0, 0, 1, 2]; // last element is M - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1]); assert_eq!(p.evaluate(&extracted), Min(Some(2))); } diff --git a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs index 57001da27..d6b446c80 100644 --- a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs @@ -45,7 +45,7 @@ fn test_prizecollectingsteinerforest_to_steinertree_canonical_target_structure() let reduction = ReduceTo::>::reduce_to(&source); let target = reduction.target_problem(); - // Issue overhead: V_H = n + k + 1, E_H = m + n + 2k, T_H = k + 1. + // Exact size relation: V_H = n + k + 1, E_H = m + n + 2k, T_H = k + 1. // n = 3, m = 2, k = 3 -> V_H = 7, E_H = 11, T_H = 4. assert_eq!(target.num_vertices(), 7); assert_eq!(target.num_edges(), 11); @@ -65,7 +65,7 @@ fn test_prizecollectingsteinerforest_to_steinertree_extract_witness_canonical() let target_witness = BruteForce::new() .find_witness(target) .expect("target SteinerTree must be feasible"); - let source_witness = reduction.extract_solution(&target_witness); + let source_witness = reduction.extract_solution(&target_witness).unwrap(); // Source layout is `n` vertex-bits then `m` edge-bits. assert_eq!(source_witness.len(), source.num_variables()); @@ -123,7 +123,7 @@ fn test_prizecollectingsteinerforest_to_steinertree_all_prizes() { /// No vertex carries a positive prize, so no gadget terminals are added. /// Only the artificial root remains as a terminal, but SteinerTree requires -/// at least two terminals — so this corner case is delegated to overhead +/// at least two terminals — so this corner case is covered by size-contract /// inspection plus a degenerate single-vertex source case that still has /// the construction proceed when `omega = 0`. We skip the SteinerTree /// instantiation when `k = 0` (which would produce a single-terminal diff --git a/src/unit_tests/rules/quadraticassignment_ilp.rs b/src/unit_tests/rules/quadraticassignment_ilp.rs index d7a648d0e..7a008132f 100644 --- a/src/unit_tests/rules/quadraticassignment_ilp.rs +++ b/src/unit_tests/rules/quadraticassignment_ilp.rs @@ -33,7 +33,7 @@ fn test_quadraticassignment_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -61,7 +61,7 @@ fn test_quadraticassignment_to_ilp_2x2() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); @@ -76,7 +76,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); } @@ -99,7 +99,7 @@ fn test_quadraticassignment_to_ilp_rectangular() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); diff --git a/src/unit_tests/rules/qubo_ilp.rs b/src/unit_tests/rules/qubo_ilp.rs index 5895e0a4f..b3606c20a 100644 --- a/src/unit_tests/rules/qubo_ilp.rs +++ b/src/unit_tests/rules/qubo_ilp.rs @@ -29,7 +29,7 @@ fn test_qubo_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = qubo.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -49,7 +49,7 @@ fn test_qubo_to_ilp_diagonal_only() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(ilp); - let extracted = reduction.extract_solution(&best[0]); + let extracted = reduction.extract_solution(&best[0]).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -72,6 +72,6 @@ fn test_qubo_to_ilp_3var() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(ilp); - let extracted = reduction.extract_solution(&best[0]); + let extracted = reduction.extract_solution(&best[0]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } diff --git a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs index 9ad9e01be..b07d1b386 100644 --- a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs +++ b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs @@ -26,7 +26,7 @@ fn test_rectilinearpicturecompression_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -38,7 +38,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs index 9a7721594..9cae038df 100644 --- a/src/unit_tests/rules/reduction_path_parity.rs +++ b/src/unit_tests/rules/reduction_path_parity.rs @@ -1,16 +1,15 @@ //! Reduction path parity tests — mirrors Julia's test/reduction_path.jl. -//! Verifies that chained reductions via `find_cheapest_path` + `reduce_along_path` +//! Verifies that explicit chained reductions via `reduce_along_path` //! produce correct solutions matching direct source solves. use crate::models::algebraic::QUBO; use crate::models::graph::{MaxCut, SpinGlass}; use crate::models::misc::Factoring; use crate::rules::test_helpers::assert_optimization_round_trip_chain; -use crate::rules::{MinimizeSteps, MinimizeStepsThenOverhead, ReductionGraph}; +use crate::rules::ReductionGraph; use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::ProblemSize; /// Julia: paths = reduction_paths(MaxCut, SpinGlass) /// Julia: res = reduceto(paths[1], MaxCut(smallgraph(:petersen))) @@ -20,15 +19,10 @@ fn test_jl_parity_maxcut_to_spinglass_path() { let src_var = ReductionGraph::variant_to_map(&MaxCut::::variant()); let dst_var = ReductionGraph::variant_to_map(&SpinGlass::::variant()); let rpath = graph - .find_cheapest_path( - "MaxCut", - &src_var, - "SpinGlass", - &dst_var, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .expect("Should find path MaxCut -> SpinGlass"); + .find_all_paths("MaxCut", &src_var, "SpinGlass", &dst_var) + .into_iter() + .find(|path| path.type_names() == ["MaxCut", "SpinGlass"]) + .expect("direct route"); // Petersen graph: 10 vertices, 15 edges let petersen_edges = vec![ @@ -59,7 +53,7 @@ fn test_jl_parity_maxcut_to_spinglass_path() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); // Source solution should be valid let metric = source.evaluate(&source_solution); @@ -73,17 +67,11 @@ fn test_jl_parity_maxcut_to_qubo_path() { let graph = ReductionGraph::new(); let src_var = ReductionGraph::variant_to_map(&MaxCut::::variant()); let dst_var = ReductionGraph::variant_to_map(&QUBO::::variant()); - // Use Petersen graph size to pick the path with smallest output let rpath = graph - .find_cheapest_path( - "MaxCut", - &src_var, - "QUBO", - &dst_var, - &ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 15)]), - &MinimizeStepsThenOverhead, - ) - .expect("Should find path MaxCut -> QUBO"); + .find_all_paths("MaxCut", &src_var, "QUBO", &dst_var) + .into_iter() + .find(|path| path.type_names() == ["MaxCut", "SpinGlass", "QUBO"]) + .expect("explicit SpinGlass route"); // Use a small graph for brute-force feasibility let petersen_edges = vec![ @@ -117,7 +105,6 @@ fn test_jl_parity_maxcut_to_qubo_path() { /// Julia: factoring = Factoring(2, 1, 3) /// Julia: paths = reduction_paths(Factoring, SpinGlass) /// Julia: all(solution_size.(Ref(factoring), extract_solution.(Ref(res), sol)) .== Ref(valid objective 0)) -#[cfg(feature = "ilp-solver")] #[test] fn test_jl_parity_factoring_to_spinglass_path() { use crate::solvers::ILPSolver; @@ -126,15 +113,10 @@ fn test_jl_parity_factoring_to_spinglass_path() { let src_var = ReductionGraph::variant_to_map(&Factoring::variant()); let dst_var = ReductionGraph::variant_to_map(&SpinGlass::::variant()); let rpath = graph - .find_cheapest_path( - "Factoring", - &src_var, - "SpinGlass", - &dst_var, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ) - .expect("Should find path Factoring -> SpinGlass"); + .find_all_paths("Factoring", &src_var, "SpinGlass", &dst_var) + .into_iter() + .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .expect("explicit CircuitSAT route"); // Julia: Factoring(2, 1, 3) — factor 3 with 2-bit x 1-bit let factoring = Factoring::new(2, 1, 3); @@ -158,7 +140,7 @@ fn test_jl_parity_factoring_to_spinglass_path() { let ilp_solution = ilp_solver .solve(ilp) .expect("ILP solver should find factoring solution"); - let factoring_solution = reduction.extract_solution(&ilp_solution); + let factoring_solution = reduction.extract_solution(&ilp_solution).unwrap(); let metric = factoring.evaluate(&factoring_solution); assert_eq!( metric.unwrap(), @@ -166,47 +148,3 @@ fn test_jl_parity_factoring_to_spinglass_path() { "Factoring->ILP: ILP solution should yield distance 0" ); } - -/// Test that `find_cheapest_path` works with a concrete `ProblemSize` input, -/// rather than an empty `ProblemSize::new(vec![])`. -#[test] -fn test_find_cheapest_path_with_problem_size() { - let graph = ReductionGraph::new(); - let petersen = SimpleGraph::new( - 10, - vec![ - (0, 1), - (0, 4), - (0, 5), - (1, 2), - (1, 6), - (2, 3), - (2, 7), - (3, 4), - (3, 8), - (4, 9), - (5, 7), - (5, 8), - (6, 8), - (6, 9), - (7, 9), - ], - ); - let _source = MaxCut::::unweighted(petersen); - let src_var = ReductionGraph::variant_to_map(&MaxCut::::variant()); - let dst_var = ReductionGraph::variant_to_map(&SpinGlass::::variant()); - - let input_size = ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 15)]); - let rpath = graph - .find_cheapest_path( - "MaxCut", - &src_var, - "SpinGlass", - &dst_var, - &input_size, - &MinimizeSteps, - ) - .expect("Should find path MaxCut -> SpinGlass"); - - assert!(!rpath.type_names().is_empty()); -} diff --git a/src/unit_tests/rules/registersufficiency_ilp.rs b/src/unit_tests/rules/registersufficiency_ilp.rs index 8b5e9ec77..6d4f6b094 100644 --- a/src/unit_tests/rules/registersufficiency_ilp.rs +++ b/src/unit_tests/rules/registersufficiency_ilp.rs @@ -50,7 +50,7 @@ fn test_register_sufficiency_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("feasible register-sufficiency instance should yield a feasible ILP"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); let mut sorted = extracted.clone(); @@ -64,7 +64,7 @@ fn test_register_sufficiency_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-sufficiency instance with bound one should be infeasible" ); } @@ -105,7 +105,7 @@ fn test_register_sufficiency_to_ilp_canonical_example_spec() { let solution = &example.solutions[0]; assert_eq!(source.evaluate(&solution.source_config), Or(true)); assert_eq!( - reduction.extract_solution(&solution.target_config), + reduction.extract_solution(&solution.target_config).unwrap(), solution.source_config ); } diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index eeabcf017..0948e1c09 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -1,494 +1,129 @@ use super::*; use crate::expr::Expr; -use crate::rules::registry::EdgeCapabilities; -use std::path::Path; -/// Dummy reduce_fn for unit tests that don't exercise runtime reduction. -fn dummy_reduce_fn(_: &dyn std::any::Any) -> Box { - unimplemented!("dummy reduce_fn for testing") -} - -fn dummy_reduce_aggregate_fn( - _: &dyn std::any::Any, -) -> Box { - unimplemented!("dummy reduce_aggregate_fn for testing") -} - -fn dummy_overhead_eval_fn(_: &dyn std::any::Any) -> ProblemSize { - ProblemSize::new(vec![]) -} - -fn dummy_source_size_fn(_: &dyn std::any::Any) -> ProblemSize { - ProblemSize::new(vec![]) -} - -#[test] -fn test_reduction_overhead_evaluate() { - let overhead = ReductionOverhead::new(vec![ - ("n", Expr::Const(3.0) * Expr::Var("m")), - ("m", Expr::pow(Expr::Var("m"), Expr::Const(2.0))), - ]); - - let input = ProblemSize::new(vec![("m", 4)]); - let output = overhead.evaluate_output_size(&input); - - assert_eq!(output.get("n"), Some(12)); // 3 * 4 - assert_eq!(output.get("m"), Some(16)); // 4^2 -} - -#[test] -fn test_reduction_overhead_default() { - let overhead = ReductionOverhead::default(); - assert!(overhead.output_size.is_empty()); -} - -#[test] -fn test_reduction_entry_overhead() { - let entry = ReductionEntry { - source_name: "TestSource", - target_name: "TestTarget", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - overhead_fn: || ReductionOverhead::new(vec![("n", Expr::Const(2.0) * Expr::Var("n"))]), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - - let overhead = entry.overhead(); - let input = ProblemSize::new(vec![("n", 5)]); - let output = overhead.evaluate_output_size(&input); - assert_eq!(output.get("n"), Some(10)); -} - -#[test] -fn test_reduction_entry_debug() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - - let debug_str = format!("{:?}", entry); - assert!(debug_str.contains("A")); - assert!(debug_str.contains("B")); -} - -#[test] -fn test_is_base_reduction_unweighted() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - assert!(entry.is_base_reduction()); -} - -#[test] -fn test_is_base_reduction_source_weighted() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "i32")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - assert!(!entry.is_base_reduction()); -} - -#[test] -fn test_is_base_reduction_target_weighted() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "f64")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - assert!(!entry.is_base_reduction()); -} - -#[test] -fn test_is_base_reduction_both_weighted() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "i32")], - target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "f64")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - assert!(!entry.is_base_reduction()); -} - -#[test] -fn test_is_base_reduction_no_weight_key() { - // If no weight key is present, assume unweighted (base) - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph")], - target_variant_fn: || vec![("graph", "SimpleGraph")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", - reduce_fn: Some(dummy_reduce_fn), - reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - assert!(entry.is_base_reduction()); -} - -#[test] -fn test_reduction_entry_can_store_aggregate_executor() { - let entry = ReductionEntry { - source_name: "A", - target_name: "B", - source_variant_fn: || vec![("graph", "SimpleGraph")], - target_variant_fn: || vec![("graph", "SimpleGraph")], - overhead_fn: || ReductionOverhead::default(), - module_path: "test::module", +fn entry_with(declarations: fn() -> ReductionSizeDeclarations) -> ReductionEntry { + ReductionEntry { + source_name: "Source", + target_name: "Target", + source_variant_fn: Vec::new, + target_variant_fn: Vec::new, + size_declarations_fn: declarations, + module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: Some(dummy_reduce_aggregate_fn), - capabilities: EdgeCapabilities::aggregate_only(), - overhead_eval_fn: dummy_overhead_eval_fn, - source_size_fn: dummy_source_size_fn, - }; - - assert!(entry.reduce_fn.is_none()); - assert!(entry.reduce_aggregate_fn.is_some()); -} - -#[test] -fn test_reduction_entries_registered() { - let entries: Vec<_> = inventory::iter::().collect(); - - // Should have at least some registered reductions - assert!(entries.len() >= 10); - - // Check specific reductions exist - assert!( - entries - .iter() - .any(|e| e.source_name == "MaximumIndependentSet" - && e.target_name == "MinimumVertexCover") - ); -} - -/// Build a ProblemSize from an overhead's input variables by calling the eval fn -/// on the source problem instance and collecting field values via the overhead. -/// -/// This cross-checks compiled eval (calls getters directly) against symbolic eval -/// (looks up variables in a ProblemSize hashmap). -fn cross_check_overhead(entry: &ReductionEntry, src: &dyn std::any::Any, input: &ProblemSize) { - let compiled = (entry.overhead_eval_fn)(src); - let symbolic = entry.overhead().evaluate_output_size(input); - - for (field, _) in &entry.overhead().output_size { - assert_eq!( - compiled.get(field), - symbolic.get(field), - "overhead field '{}' mismatch for {}→{}: compiled={:?}, symbolic={:?}", - field, - entry.source_name, - entry.target_name, - compiled.get(field), - symbolic.get(field), - ); + reduce_aggregate_fn: None, + turing: false, + source_size_measure_fn: |_| crate::types::ProblemSize::new(vec![]), + target_size_measure_fn: |_| crate::types::ProblemSize::new(vec![]), } } -/// Cross-check complexity_eval_fn against symbolic Expr evaluation. -fn cross_check_complexity( - entry: &crate::registry::VariantEntry, - src: &dyn std::any::Any, - input: &ProblemSize, -) { - let compiled = (entry.complexity_eval_fn)(src); - let parsed = crate::expr::Expr::parse(entry.complexity); - let symbolic = parsed.eval(input); - - let diff = (compiled - symbolic).abs(); - let tol = 1e-6 * symbolic.abs().max(1.0); - assert!( - diff < tol, - "complexity mismatch for {} ({}): compiled={compiled}, symbolic={symbolic}, expr=\"{}\"", - entry.name, - entry - .variant() - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", "), - entry.complexity, - ); -} - -#[test] -fn test_overhead_eval_fn_cross_check_mis_to_mvc() { - use crate::models::graph::MaximumIndependentSet; - use crate::topology::SimpleGraph; - - let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5)]); - let problem = MaximumIndependentSet::new(graph, vec![1i32; 6]); - - let entry = inventory::iter::() - .find(|e| e.source_name == "MaximumIndependentSet" && e.target_name == "MinimumVertexCover") - .unwrap(); - - let input = ProblemSize::new(vec![ - ("num_vertices", problem.num_vertices()), - ("num_edges", problem.num_edges()), - ]); - cross_check_overhead(entry, &problem as &dyn std::any::Any, &input); -} - -#[test] -fn test_overhead_eval_fn_cross_check_factoring_to_ilp() { - use crate::models::misc::Factoring; - - let problem = Factoring::new(3, 4, 42); - - let entry = inventory::iter::() - .find(|e| e.source_name == "Factoring" && e.target_name == "ILP") - .unwrap(); - - let input = ProblemSize::new(vec![ - ("num_bits_first", problem.num_bits_first()), - ("num_bits_second", problem.num_bits_second()), - ]); - cross_check_overhead(entry, &problem as &dyn std::any::Any, &input); -} - #[test] -fn test_complexity_eval_fn_cross_check_mis() { - use crate::models::graph::MaximumIndependentSet; - use crate::registry::VariantEntry; - use crate::topology::SimpleGraph; - - let graph = SimpleGraph::new(10, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i32; 10]); - - let entry = inventory::iter::() - .find(|e| { - e.name == "MaximumIndependentSet" - && e.variant() - .iter() - .any(|(k, v)| *k == "graph" && *v == "SimpleGraph") - && e.variant() - .iter() - .any(|(k, v)| *k == "weight" && *v == "i32") - }) - .unwrap(); - - let input = ProblemSize::new(vec![("num_vertices", problem.num_vertices())]); - cross_check_complexity(entry, &problem as &dyn std::any::Any, &input); -} - -#[test] -fn test_complexity_eval_fn_cross_check_factoring() { - use crate::models::misc::Factoring; - use crate::registry::VariantEntry; - - let problem = Factoring::new(8, 8, 100); - - let entry = inventory::iter::() - .find(|e| e.name == "Factoring") - .unwrap(); - - let input = ProblemSize::new(vec![("m", problem.m()), ("n", problem.n())]); - cross_check_complexity(entry, &problem as &dyn std::any::Any, &input); -} - -type EndpointKey = (String, Vec<(String, String)>, String, Vec<(String, String)>); - -fn exact_endpoint_key(entry: &ReductionEntry) -> EndpointKey { - let source_variant = entry - .source_variant() - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let target_variant = entry - .target_variant() - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - ( - entry.source_name.to_string(), - source_variant, - entry.target_name.to_string(), - target_variant, +fn one_relation_applies_to_the_whole_transform() { + let entry = entry_with(|| ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![("n", Expr::variable("n"))], + unavailable: vec![], + }); + let contract = entry.size_contract().unwrap(); + let transform = contract.transform().unwrap(); + assert_eq!(transform.relation(), crate::size::SizeRelation::Exact); + assert!(transform.get("n").is_some()); +} + +#[test] +fn unavailable_field_cannot_overlap_a_formula() { + let entry = entry_with(|| ReductionSizeDeclarations { + relation: Some(crate::size::SizeRelation::Exact), + fields: vec![("n", Expr::variable("n"))], + unavailable: vec![UnavailableSizeField { + field: "n", + reason: "the construction does not expose this statistic", + }], + }); + assert!(matches!( + entry.size_contract(), + Err(SizeContractError::DuplicateClassification { field, .. }) if field.as_ref() == "n" + )); +} + +#[test] +fn unavailable_field_requires_a_reason() { + let entry = entry_with(|| ReductionSizeDeclarations { + relation: None, + fields: vec![], + unavailable: vec![UnavailableSizeField { + field: "n", + reason: " ", + }], + }); + assert!(matches!( + entry.size_contract(), + Err(SizeContractError::EmptyUnavailableReason { field, .. }) if field.as_ref() == "n" + )); +} + +#[test] +fn size_contract_errors_and_entry_debug_are_transparent() { + let transform_error = crate::size::SizeTransform::new( + "bad exact", + crate::size::SizeRelation::Exact, + [("x", Expr::variable("n")), ("x", Expr::variable("m"))], ) -} - -fn walk_rust_files(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - if path.is_dir() { - walk_rust_files(&path, files); - } else if path.extension().is_some_and(|ext| ext == "rs") { - files.push(path); - } + .unwrap_err(); + assert!(SizeContractError::from(transform_error) + .to_string() + .starts_with("invalid size transform:")); + assert!(SizeContractError::DuplicateClassification { + edge: "A -> B".into(), + field: "x".into(), } -} - -fn reduction_attribute_has_extra_top_level_field(path: &Path) -> bool { - let contents = std::fs::read_to_string(path).unwrap(); - let mut in_reduction_attr = false; - let mut attr_text = String::new(); - - for line in contents.lines() { - if !in_reduction_attr - && (line.contains("#[reduction(") || line.contains("#[$crate::reduction(")) - { - in_reduction_attr = true; - attr_text.clear(); - } - if in_reduction_attr { - attr_text.push_str(line.trim()); - attr_text.push(' '); - } - if in_reduction_attr && line.contains(")]") { - let normalized = attr_text.split_whitespace().collect::>().join(" "); - let body = normalized - .strip_prefix("#[reduction(") - .or_else(|| normalized.strip_prefix("#[$crate::reduction(")) - .unwrap_or(&normalized); - let body = body.strip_suffix(")]").unwrap_or(body).trim(); - if !body.starts_with("overhead =") { - return true; - } - in_reduction_attr = false; - } + .to_string() + .contains("classifies target field `x` more than once")); + assert!(SizeContractError::EmptyUnavailableReason { + edge: "A -> B".into(), + field: "x".into(), } + .to_string() + .contains("unavailable without a reason")); - false + let entry = entry_with(ReductionSizeDeclarations::default); + let debug = format!("{entry:?}"); + assert!(debug.contains("size_contract")); + assert!(debug.contains("capabilities")); } #[test] -fn every_registered_reduction_has_unique_exact_endpoints() { - let entries = reduction_entries(); - let mut seen = std::collections::HashMap::new(); - for entry in &entries { - let key = exact_endpoint_key(entry); - if let Some(prev) = seen.insert(key.clone(), entry) { +fn every_registered_contract_validates() { + for entry in reduction_entries() { + entry.size_contract().unwrap_or_else(|error| { panic!( - "Duplicate exact reduction endpoint {:?}: {} {:?} -> {} {:?} vs {} {:?} -> {} {:?}", - key, - prev.source_name, - prev.source_variant(), - prev.target_name, - prev.target_variant(), - entry.source_name, - entry.source_variant(), - entry.target_name, - entry.target_variant(), - ); - } + "{} -> {} has an invalid size contract: {error}", + entry.source_name, entry.target_name + ) + }); } } #[test] -fn every_registered_reduction_has_non_empty_names() { +fn every_registered_target_schema_field_is_classified() { + let mut mismatches = Vec::new(); for entry in reduction_entries() { - assert!( - !entry.source_name.is_empty(), - "Empty source_name for reduction targeting {}", - entry.target_name, - ); - assert!( - !entry.target_name.is_empty(), - "Empty target_name for reduction sourced from {}", - entry.source_name, - ); + let declared: std::collections::HashSet<_> = + crate::registry::declared_size_fields(entry.target_name) + .into_iter() + .collect(); + let contract = entry.size_contract().unwrap(); + let mut classified = std::collections::HashSet::new(); + if let Some(transform) = contract.transform() { + classified.extend(transform.expressions().map(|(field, _)| field)); + } + classified.extend(contract.unavailable().iter().map(|field| field.field)); + if !declared.is_empty() && classified != declared { + mismatches.push(format!( + "{} -> {}: classified={classified:?}, declared={declared:?}", + entry.source_name, entry.target_name + )); + } } -} - -#[test] -fn repo_reductions_use_overhead_only_attribute() { - let mut rust_files = Vec::new(); - walk_rust_files(Path::new("src/rules"), &mut rust_files); - - let offenders: Vec<_> = rust_files - .into_iter() - .filter(|path| reduction_attribute_has_extra_top_level_field(path)) - .collect(); - - assert!( - offenders.is_empty(), - "extra top-level reduction attribute still present in: {:?}", - offenders, - ); -} - -#[test] -fn test_edge_capabilities_constructors() { - let wo = EdgeCapabilities::witness_only(); - assert!(wo.witness); - assert!(!wo.aggregate); - - let ao = EdgeCapabilities::aggregate_only(); - assert!(!ao.witness); - assert!(ao.aggregate); - - let both = EdgeCapabilities::both(); - assert!(both.witness); - assert!(both.aggregate); - - let none = EdgeCapabilities::none(); - assert!(!none.witness); - assert!(!none.aggregate); - assert!(!none.turing); -} - -#[test] -fn test_edge_capabilities_default_is_witness_only() { - let default = EdgeCapabilities::default(); - assert_eq!(default, EdgeCapabilities::witness_only()); -} - -#[test] -fn test_edge_capabilities_serde_roundtrip() { - let caps = EdgeCapabilities::both(); - let json = serde_json::to_string(&caps).unwrap(); - let back: EdgeCapabilities = serde_json::from_str(&json).unwrap(); - assert_eq!(caps, back); + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); } diff --git a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs index f29497710..fcd12b8eb 100644 --- a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs @@ -40,7 +40,7 @@ fn test_resourceconstrainedscheduling_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -52,7 +52,7 @@ fn test_resourceconstrainedscheduling_to_ilp_infeasible() { ResourceConstrainedScheduling::new(1, vec![5], vec![vec![6], vec![6], vec![6]], 1); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible RCS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs index ec413aeb6..3fdaa50ab 100644 --- a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -83,7 +83,7 @@ fn test_rootedtreearrangement_to_rootedtreestorageassignment_solution_extraction // Target solution: parent array [0, 0] means tree rooted at 0 with 1->0 let target_config = vec![0, 0]; - let source_config = reduction.extract_solution(&target_config); + let source_config = reduction.extract_solution(&target_config).unwrap(); // Source config should be [parent_array | identity_mapping] = [0, 0, 0, 1] assert_eq!(source_config, vec![0, 0, 0, 1]); diff --git a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs index d21d180aa..a97f8d7b2 100644 --- a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs +++ b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs @@ -34,13 +34,13 @@ fn test_rootedtreestorageassignment_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution); + Ok(ilp_solution) => { + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.0, "ILP solution should be feasible"); assert!(bf_value.0, "BF should also find feasible solution"); } - None => { + Err(_) => { assert!(!bf_value.0, "both should agree on infeasibility"); } } @@ -63,10 +63,7 @@ fn test_rootedtreestorageassignment_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); let ilp_result = ilp_solver.solve(reduction.target_problem()); assert!(bf_witness.is_none(), "source should be infeasible"); - assert!( - ilp_result.is_none(), - "reduced ILP should also be infeasible" - ); + assert!(ilp_result.is_err(), "reduced ILP should also be infeasible"); } #[test] @@ -77,7 +74,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/ruralpostman_ilp.rs b/src/unit_tests/rules/ruralpostman_ilp.rs index 8798cd6ea..69ee2f30b 100644 --- a/src/unit_tests/rules/ruralpostman_ilp.rs +++ b/src/unit_tests/rules/ruralpostman_ilp.rs @@ -22,7 +22,7 @@ fn test_ruralpostman_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted).0.is_some()); } @@ -47,7 +47,7 @@ fn test_ruralpostman_to_ilp_optimization() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert!(ilp_value.0.is_some(), "ILP solution must be valid"); diff --git a/src/unit_tests/rules/sat_circuitsat.rs b/src/unit_tests/rules/sat_circuitsat.rs index 77b27e20a..903f0c37c 100644 --- a/src/unit_tests/rules/sat_circuitsat.rs +++ b/src/unit_tests/rules/sat_circuitsat.rs @@ -62,7 +62,7 @@ fn test_sat_to_circuitsat_single_literal_clause() { let target_solution = solve_satisfaction_problem(result.target_problem()) .expect("CircuitSAT should have a satisfying solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); } diff --git a/src/unit_tests/rules/sat_coloring.rs b/src/unit_tests/rules/sat_coloring.rs index 929bb7651..da70226a2 100644 --- a/src/unit_tests/rules/sat_coloring.rs +++ b/src/unit_tests/rules/sat_coloring.rs @@ -81,7 +81,7 @@ fn test_unsatisfiable_formula() { // OR no valid coloring exists that extracts to a satisfying SAT assignment let mut found_satisfying = false; for sol in &solutions { - let sat_sol = reduction.extract_solution(sol); + let sat_sol = reduction.extract_solution(sol).unwrap(); let assignment: Vec = sat_sol.iter().map(|&v| v == 1).collect(); if sat.is_satisfying(&assignment) { found_satisfying = true; @@ -194,7 +194,7 @@ fn test_single_literal_clauses() { let mut found_correct = false; for sol in &solutions { - let sat_sol = reduction.extract_solution(sol); + let sat_sol = reduction.extract_solution(sol).unwrap(); if sat_sol == vec![1, 1] { found_correct = true; break; @@ -272,7 +272,7 @@ fn test_manual_coloring_extraction() { let valid_coloring = vec![0, 1, 2, 0, 1]; assert_eq!(coloring.graph().num_vertices(), 5); - let extracted = reduction.extract_solution(&valid_coloring); + let extracted = reduction.extract_solution(&valid_coloring).unwrap(); // x1 should be true (1) because vertex 3 has color 0 which equals TRUE vertex's color assert_eq!(extracted, vec![1]); } @@ -287,14 +287,14 @@ fn test_extraction_with_different_color_assignment() { // Different valid coloring: TRUE=2, FALSE=0, AUX=1 // x1 must have color 2 (TRUE), NOT_x1 must have color 0 (FALSE) let coloring_permuted = vec![2, 0, 1, 2, 0]; - let extracted = reduction.extract_solution(&coloring_permuted); + let extracted = reduction.extract_solution(&coloring_permuted).unwrap(); // x1 should still be true because its color equals TRUE vertex's color assert_eq!(extracted, vec![1]); // Another permutation: TRUE=1, FALSE=2, AUX=0 // x1 has color 1 (TRUE), NOT_x1 has color 2 (FALSE) let coloring_permuted2 = vec![1, 2, 0, 1, 2]; - let extracted2 = reduction.extract_solution(&coloring_permuted2); + let extracted2 = reduction.extract_solution(&coloring_permuted2).unwrap(); assert_eq!(extracted2, vec![1]); } @@ -321,9 +321,9 @@ fn test_jl_parity_sat_to_coloring() { let ilp_solver = crate::solvers::ILPSolver::new(); let target = result.target_problem(); let target_sol = ilp_solver - .solve_reduced(target) + .solve_reduced::(target) .expect("ILP should find a coloring"); - let extracted = result.extract_solution(&target_sol); + let extracted = result.extract_solution(&target_sol).unwrap(); let best_source: HashSet> = BruteForce::new() .find_all_witnesses(&source) .into_iter() diff --git a/src/unit_tests/rules/sat_helpers.rs b/src/unit_tests/rules/sat_helpers.rs new file mode 100644 index 000000000..45f8b610d --- /dev/null +++ b/src/unit_tests/rules/sat_helpers.rs @@ -0,0 +1,28 @@ +use super::*; + +#[test] +fn test_sat_variable_allocator_numeric_boundaries() { + let mut allocator = SatVariableAllocator::new("test reduction", i32::MAX as usize - 1) + .expect("largest valid starting count"); + assert_eq!(allocator.allocate().unwrap(), i32::MAX); + assert_eq!(allocator.num_vars(), i32::MAX as usize); + + let error = allocator.allocate().unwrap_err(); + assert!(error.contains("test reduction")); + assert!(error.contains("limited to 2147483647")); +} + +#[test] +fn test_sat_variable_allocator_batch_numeric_boundaries() { + let mut exact = SatVariableAllocator::new("exact batch", i32::MAX as usize - 2).unwrap(); + assert_eq!( + exact.allocate_many(2).unwrap(), + vec![i32::MAX - 1, i32::MAX] + ); + assert_eq!(exact.num_vars(), i32::MAX as usize); + + let mut overflow = SatVariableAllocator::new("overflow batch", i32::MAX as usize - 1).unwrap(); + let error = overflow.allocate_many(2).unwrap_err(); + assert!(error.contains("cannot allocate 2 auxiliary variables")); + assert_eq!(overflow.num_vars(), i32::MAX as usize - 1); +} diff --git a/src/unit_tests/rules/sat_ksat.rs b/src/unit_tests/rules/sat_ksat.rs index 3c20a3c05..85841f0eb 100644 --- a/src/unit_tests/rules/sat_ksat.rs +++ b/src/unit_tests/rules/sat_ksat.rs @@ -152,7 +152,7 @@ fn test_sat_to_3sat_solution_extraction() { // Extract and verify solutions for ksat_sol in &ksat_solutions { - let sat_sol = reduction.extract_solution(ksat_sol); + let sat_sol = reduction.extract_solution(ksat_sol).unwrap(); // Should only have original 2 variables assert_eq!(sat_sol.len(), 2); // Should satisfy original problem @@ -188,7 +188,7 @@ fn test_3sat_to_sat_solution_extraction() { let reduction = ReduceTo::::reduce_to(&ksat); let sol = vec![1, 0, 1]; - let extracted = reduction.extract_solution(&sol); + let extracted = reduction.extract_solution(&sol).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } @@ -244,14 +244,14 @@ fn test_sat_to_3sat_mixed_clause_types() { #[test] fn test_ksat_structure() { - let sat = Satisfiability::new(3, vec![CNFClause::new(vec![1, 2, 3, 4])]); + let sat = Satisfiability::new(4, vec![CNFClause::new(vec![1, 2, 3, 4])]); let reduction = ReduceTo::>::reduce_to(&sat); let ksat = reduction.target_problem(); // K-SAT should preserve original variables plus auxiliary vars // A 4-literal clause requires 1 auxiliary variable for Tseitin - assert_eq!(ksat.num_vars(), 3 + 1); // Original vars + 1 auxiliary for Tseitin + assert_eq!(ksat.num_vars(), 4 + 1); // Original vars + 1 auxiliary for Tseitin } #[test] diff --git a/src/unit_tests/rules/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 9c2bd8f12..d55f2ab58 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -86,12 +86,12 @@ fn test_extract_solution_basic() { // Select vertex 0 (literal x1) let is_sol = vec![1, 0]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![1, 0]); // x1=true, x2=false // Select vertex 1 (literal x2) let is_sol = vec![0, 1]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![0, 1]); // x1=false, x2=true } @@ -102,7 +102,7 @@ fn test_extract_solution_with_negation() { let reduction = ReduceTo::>::reduce_to(&sat); let is_sol = vec![1]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![0]); // x1=false (so NOT x1 is true) } @@ -217,7 +217,7 @@ fn test_jl_parity_sat_to_independentset() { if sat_solutions.is_empty() { let target_solution = solve_optimization_problem(result.target_problem()) .expect("SAT->IS: target should have an optimal solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); assert!( !source.evaluate(&extracted), "SAT->IS [{label}]: unsatisfiable but extracted satisfies" diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index a421375b0..0dc10fd3e 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -5,7 +5,6 @@ use crate::rules::test_helpers::{ }; use crate::solvers::BruteForce; use crate::topology::Graph; -use crate::traits::Problem; include!("../jl_helpers.rs"); #[test] @@ -50,7 +49,7 @@ fn test_extract_solution_positive_literal() { // Solution: select vertex 0 (positive literal x1) // This dominates vertices 1, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![1, 0, 0, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![1]); // x1 = true } @@ -63,7 +62,7 @@ fn test_extract_solution_negative_literal() { // Solution: select vertex 1 (negative literal NOT x1) // This dominates vertices 0, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![0, 1, 0, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![0]); // x1 = false } @@ -77,7 +76,7 @@ fn test_extract_solution_dummy() { // Vertex 0 dominates: itself, 1, 2, and clause 6 // Vertex 5 dominates: 3, 4, and itself let ds_sol = vec![1, 0, 0, 0, 0, 1, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![1, 0]); // x1 = true, x2 = false (from dummy) } @@ -134,15 +133,42 @@ fn test_accessors() { #[test] fn test_extract_solution_too_many_selected() { - // Test that extract_solution handles invalid (non-minimal) dominating sets let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); let reduction = ReduceTo::>::reduce_to(&sat); - // Select all 4 vertices (more than num_literals=1) - let ds_sol = vec![1, 1, 1, 1]; - let sat_sol = reduction.extract_solution(&ds_sol); - // Should return default (all false) - assert_eq!(sat_sol, vec![0]); + let ds_sol = vec![1, 1, 0, 0]; + assert_eq!( + reduction.extract_solution(&ds_sol).unwrap_err().to_string(), + "variable 0 gadget must select exactly one vertex, got 2" + ); +} + +#[test] +fn test_extract_solution_rejects_unselected_variable_gadget() { + let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let reduction = ReduceTo::>::reduce_to(&sat); + + assert_eq!( + reduction + .extract_solution(&[0, 0, 0, 0]) + .unwrap_err() + .to_string(), + "variable 0 gadget must select exactly one vertex, got 0" + ); +} + +#[test] +fn test_extract_solution_rejects_selected_clause_vertex() { + let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let reduction = ReduceTo::>::reduce_to(&sat); + + assert_eq!( + reduction + .extract_solution(&[1, 0, 0, 1]) + .unwrap_err() + .to_string(), + "clause vertex 0 is selected" + ); } #[test] @@ -205,11 +231,7 @@ fn test_jl_parity_sat_to_dominatingset() { if sat_solutions.is_empty() { let target_solution = solve_optimization_problem(result.target_problem()) .expect("SAT->DS: target should have an optimal solution"); - let extracted = result.extract_solution(&target_solution); - assert!( - !source.evaluate(&extracted), - "SAT->DS [{label}]: unsatisfiable but extracted satisfies" - ); + assert!(result.extract_solution(&target_solution).is_err()); } else { assert_satisfaction_round_trip_from_optimization_target( &source, diff --git a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs index ccbbe362c..496adc0ae 100644 --- a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs @@ -66,7 +66,7 @@ fn test_satisfiability_to_integralflowhomologousarcs_issue_example_assignment_en let satisfying_flow = reduction.encode_assignment(&satisfying_assignment); assert!(target.evaluate(&satisfying_flow).0); assert_eq!( - reduction.extract_solution(&satisfying_flow), + reduction.extract_solution(&satisfying_flow).unwrap(), satisfying_assignment ); diff --git a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 502874a6a..287ad85aa 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -55,7 +55,7 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { let target_solution = solve_optimization_problem(target).expect("MAX-2-SAT target should always have a witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(!source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/satisfiability_naesatisfiability.rs b/src/unit_tests/rules/satisfiability_naesatisfiability.rs index fbe452648..6d0964346 100644 --- a/src/unit_tests/rules/satisfiability_naesatisfiability.rs +++ b/src/unit_tests/rules/satisfiability_naesatisfiability.rs @@ -64,10 +64,23 @@ fn test_solution_extraction_sentinel_false() { let reduction = ReduceTo::::reduce_to(&sat); // target_solution: [1, 0, 1, 0] means x1=true, x2=false, x3=true, sentinel=false - let extracted = reduction.extract_solution(&[1, 0, 1, 0]); + let extracted = reduction.extract_solution(&[1, 0, 1, 0]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } +#[test] +fn test_solution_extraction_distinguishes_zero_assignment_from_malformed_input() { + let sat = Satisfiability::new(2, vec![CNFClause::new(vec![-1, -2])]); + let reduction = ReduceTo::::reduce_to(&sat); + + assert_eq!(reduction.extract_solution(&[0, 0, 0]).unwrap(), vec![0, 0]); + + let error = reduction.extract_solution(&[0, 0]).unwrap_err(); + assert_eq!(error.to_string(), "expected 3 target values, got 2"); + assert!(reduction.extract_solution(&[0, 0, 0, 0]).is_err()); + assert!(reduction.extract_solution(&[0, 2, 0]).is_err()); +} + #[test] fn test_solution_extraction_sentinel_true() { // When sentinel is true, return complement of original variables @@ -77,7 +90,7 @@ fn test_solution_extraction_sentinel_true() { // target_solution: [0, 1, 0, 1] means x1=false, x2=true, x3=false, sentinel=true // Complement: x1=true, x2=false, x3=true - let extracted = reduction.extract_solution(&[0, 1, 0, 1]); + let extracted = reduction.extract_solution(&[0, 1, 0, 1]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } @@ -170,7 +183,7 @@ fn test_all_satisfying_assignments_map_back() { let nae_solutions = solver.find_all_witnesses(naesat); for nae_sol in &nae_solutions { - let sat_sol = reduction.extract_solution(nae_sol); + let sat_sol = reduction.extract_solution(nae_sol).unwrap(); assert_eq!(sat_sol.len(), 2); assert!( sat.evaluate(&sat_sol).0, diff --git a/src/unit_tests/rules/satisfiability_nontautology.rs b/src/unit_tests/rules/satisfiability_nontautology.rs index e7a2101e1..4e7ee26d4 100644 --- a/src/unit_tests/rules/satisfiability_nontautology.rs +++ b/src/unit_tests/rules/satisfiability_nontautology.rs @@ -57,7 +57,7 @@ fn test_satisfiability_to_non_tautology_extract_solution_is_identity() { .expect("target should have a witness"); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), target_solution ); } diff --git a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index b33d2dbf7..ef961c920 100644 --- a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -53,7 +53,7 @@ fn test_solution_extraction() { // y vars: index 6 sol[6] = 1; // y_{0,1} = 1 - let extracted = reduction.extract_solution(&sol); + let extracted = reduction.extract_solution(&sol).unwrap(); assert_eq!(extracted, vec![0, 1]); // Each on separate processor: C(0)=1, C(1)=2, WCT = 1*3 + 2*1 = 5 assert_eq!(problem.evaluate(&extracted), Min(Some(5))); @@ -73,7 +73,7 @@ fn test_ilp_matches_bruteforce_small() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -91,7 +91,7 @@ fn test_issue_example_closed_loop() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(47))); } @@ -103,7 +103,7 @@ fn test_single_task_single_processor() { let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(15))); } @@ -122,7 +122,7 @@ fn test_equal_tasks_multiple_processors() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); diff --git a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs index 569da137e..63a0aec83 100644 --- a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs @@ -44,7 +44,7 @@ fn test_schedulingwithindividualdeadlines_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -57,7 +57,7 @@ fn test_schedulingwithindividualdeadlines_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIDToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should yield infeasible ILP" ); } @@ -71,7 +71,7 @@ fn test_schedulingwithindividualdeadlines_to_ilp_extract_solution() { // max_deadline=3: x_{j,t} at j*3+t // x_{0,0}=1, x_{0,1}=0, x_{0,2}=0, x_{1,0}=1, x_{1,1}=0, x_{1,2}=0, x_{2,0}=0, x_{2,1}=1, x_{2,2}=0 let ilp_solution = vec![1, 0, 0, 1, 0, 0, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index cd04dcf49..08f9cf976 100644 --- a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -18,7 +18,7 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -44,7 +44,7 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -55,6 +55,6 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_no_precedences() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs index 71199ad2e..893eb6a6c 100644 --- a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -33,7 +33,7 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -49,7 +49,7 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_all_on_time() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value.0, Some(0)); @@ -73,7 +73,7 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_optimal_ordering() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); let bf = BruteForce::new(); diff --git a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 5f599cb07..32336c7b1 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -42,7 +42,7 @@ fn test_extract_solution_encodes_schedule_as_lehmer_code() { // Completion times C0 = 3, C1 = 1 imply schedule [1, 0]. // y_{0,1} = 0 means task 1 before task 0. - let extracted = reduction.extract_solution(&[3, 1, 0]); + let extracted = reduction.extract_solution(&[3, 1, 0]).unwrap(); assert_eq!(extracted, vec![1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(14))); } @@ -58,7 +58,7 @@ fn test_issue_example_closed_loop() { let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 2, 0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(46))); @@ -81,7 +81,7 @@ fn test_ilp_matches_bruteforce_optimum() { let reduction: ReductionSTMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_metric = problem.evaluate(&extracted); assert_eq!(ilp_metric, brute_force_metric); @@ -98,7 +98,7 @@ fn test_cyclic_precedence_instance_is_infeasible() { let ilp = reduction.target_problem(); assert!( - ILPSolver::new().solve(ilp).is_none(), + ILPSolver::new().solve(ilp).is_err(), "cyclic precedences should make the ILP infeasible" ); } @@ -152,7 +152,7 @@ fn test_solve_reduced_matches_source_optimum() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let source_solution = reduction.extract_solution(&ilp_solution); + let source_solution = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source_solution, vec![1, 2, 0, 1, 0]); assert_eq!(problem.evaluate(&source_solution), Min(Some(46))); diff --git a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs index 1d68ac783..2ee464f3d 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -14,7 +14,7 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -32,7 +32,7 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -43,7 +43,7 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_infeasible() { SequencingToMinimizeWeightedTardiness::new(vec![10, 10], vec![1, 1], vec![1, 1], 0); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible STMWT should produce infeasible ILP" ); } @@ -61,6 +61,6 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_no_tardiness() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 23f97a1a8..6590da713 100644 --- a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -42,7 +42,7 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_feasible_paper_example() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -53,7 +53,7 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_infeasible() { SequencingWithDeadlinesAndSetUpTimes::new(vec![2, 2], vec![1, 1], vec![0, 0], vec![0]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -70,7 +70,7 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_setup_time_respected() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -90,14 +90,14 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_bf_vs_ilp_small() { let reduction = ReduceTo::>::reduce_to(&problem); let ilp_result = ILPSolver::new().solve(reduction.target_problem()); - let ilp_feasible = ilp_result.is_some(); + let ilp_feasible = ilp_result.is_ok(); assert_eq!( bf_feasible, ilp_feasible, "BF and ILP should agree on feasibility" ); - if let Some(ilp_solution) = ilp_result { - let extracted = reduction.extract_solution(&ilp_solution); + if let Ok(ilp_solution) = ilp_result { + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } } @@ -116,6 +116,6 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_no_setup_same_compiler() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("should be feasible with no switches"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs index fa04ef222..90f5922ac 100644 --- a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs +++ b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs @@ -56,7 +56,7 @@ fn test_sequencingwithinintervals_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -69,7 +69,7 @@ fn test_sequencingwithinintervals_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance (forced overlap) should yield infeasible ILP" ); } @@ -82,7 +82,7 @@ fn test_sequencingwithinintervals_to_ilp_extract_solution() { // task 0 at offset 0, task 1 at offset 0 // vars: x_{0,0}=1, x_{0,1}=0, x_{1,0}=1, x_{1,1}=0 let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 4ed4daca9..2201a7d11 100644 --- a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -32,7 +32,7 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -42,7 +42,7 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_infeasible() { let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![2, 2], vec![0, 0], vec![2, 2]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible SWRTD should produce infeasible ILP" ); } @@ -54,6 +54,6 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_single_task() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("single-task ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/setsplitting_betweenness.rs b/src/unit_tests/rules/setsplitting_betweenness.rs index 9177da2ab..476b5ff4b 100644 --- a/src/unit_tests/rules/setsplitting_betweenness.rs +++ b/src/unit_tests/rules/setsplitting_betweenness.rs @@ -52,7 +52,9 @@ fn test_setsplitting_to_betweenness_issue_yes_instance_structure() { ], ); assert_eq!( - reduction.extract_solution(&[8, 2, 9, 0, 1, 4, 3, 6, 7, 5]), + reduction + .extract_solution(&[8, 2, 9, 0, 1, 4, 3, 6, 7, 5]) + .unwrap(), vec![1, 0, 1, 0, 0] ); } diff --git a/src/unit_tests/rules/setsplitting_ilp.rs b/src/unit_tests/rules/setsplitting_ilp.rs index b15762c82..1d82a5888 100644 --- a/src/unit_tests/rules/setsplitting_ilp.rs +++ b/src/unit_tests/rules/setsplitting_ilp.rs @@ -46,7 +46,7 @@ fn test_setsplitting_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), @@ -64,7 +64,7 @@ fn test_setsplitting_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsplittable instance" ); } @@ -83,7 +83,7 @@ fn test_setsplitting_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_result = problem.evaluate(&extracted); assert_eq!(bf_result, ilp_result, "BruteForce and ILP must agree"); diff --git a/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs b/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs index 70cc18914..1a6480839 100644 --- a/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs +++ b/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs @@ -27,7 +27,7 @@ fn test_shortestcommonsupersequence_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( bf_value, ilp_value, @@ -47,7 +47,7 @@ fn test_shortestcommonsupersequence_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -60,7 +60,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs index 6584fb275..5eabec62d 100644 --- a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs @@ -52,13 +52,13 @@ fn test_shortestweightconstrainedpath_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution); + Ok(ilp_solution) => { + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should agree on the optimal length assert_eq!(ilp_value, bf_value); } - None => { + Err(_) => { // ILP found no feasible solution; brute force should agree assert_eq!(bf_value, Min(None)); } @@ -73,7 +73,7 @@ fn test_solution_extraction() { // Handcrafted ILP solution: path 0->1->2 // a_{0,fwd}=1, a_{0,rev}=0, a_{1,fwd}=1, a_{1,rev}=0, o_0=0, o_1=1, o_2=2 let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // length = 2 + 3 = 5 @@ -96,7 +96,7 @@ fn test_shortestweightconstrainedpath_to_ilp_trivial() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should solve the trivial s==t case"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); diff --git a/src/unit_tests/rules/sparsematrixcompression_ilp.rs b/src/unit_tests/rules/sparsematrixcompression_ilp.rs index d92744b41..a173f870e 100644 --- a/src/unit_tests/rules/sparsematrixcompression_ilp.rs +++ b/src/unit_tests/rules/sparsematrixcompression_ilp.rs @@ -64,7 +64,7 @@ fn test_smc_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/spinglass_maxcut.rs b/src/unit_tests/rules/spinglass_maxcut.rs index b6dcac86d..1e7837260 100644 --- a/src/unit_tests/rules/spinglass_maxcut.rs +++ b/src/unit_tests/rules/spinglass_maxcut.rs @@ -31,7 +31,7 @@ fn test_solution_extraction_no_ancilla() { let reduction = ReduceTo::>::reduce_to(&sg); let mc_sol = vec![0, 1]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -42,12 +42,12 @@ fn test_solution_extraction_with_ancilla() { // If ancilla is 0, don't flip let mc_sol = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![0, 1]); // If ancilla is 1, flip all let mc_sol = vec![0, 1, 1]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![1, 0]); // flipped and ancilla removed } diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 7925f7ed4..6db33e38a 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -48,7 +48,7 @@ fn test_steinertree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Min(Some(6))); assert_eq!(problem.evaluate(&extracted), Min(Some(6))); @@ -66,7 +66,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { ]; assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 1, 1, 0, 0, 0] ); } @@ -75,7 +75,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { fn test_solve_reduced_uses_new_rule() { let problem = canonical_instance(); let solution = ILPSolver::new() - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find the Steiner tree via ILP"); assert_eq!(problem.evaluate(&solution), Min(Some(6))); } diff --git a/src/unit_tests/rules/stringtostringcorrection_ilp.rs b/src/unit_tests/rules/stringtostringcorrection_ilp.rs index 3b1983b03..7a93273eb 100644 --- a/src/unit_tests/rules/stringtostringcorrection_ilp.rs +++ b/src/unit_tests/rules/stringtostringcorrection_ilp.rs @@ -30,7 +30,7 @@ fn test_stringtostringcorrection_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -43,7 +43,7 @@ fn test_solution_extraction_delete() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -62,7 +62,7 @@ fn test_stringtostringcorrection_to_ilp_infeasible() { let reduction: ReductionSTSCToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(reduction.target_problem()).is_none(), + ilp_solver.solve(reduction.target_problem()).is_err(), "reduced ILP should also be infeasible" ); } @@ -81,6 +81,6 @@ fn test_stringtostringcorrection_to_ilp_swap() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs index 924fcc0ec..e18631a1c 100644 --- a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs @@ -29,7 +29,7 @@ fn test_strongconnectivityaugmentation_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -44,7 +44,7 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 2); assert!(source.evaluate(&extracted).0); } @@ -56,7 +56,7 @@ fn test_trivial_single_vertex() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("trivial should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -90,7 +90,7 @@ fn test_infeasible_budget() { let reduction: ReductionSCAToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/subgraphisomorphism_ilp.rs b/src/unit_tests/rules/subgraphisomorphism_ilp.rs index a662292f0..293bc584a 100644 --- a/src/unit_tests/rules/subgraphisomorphism_ilp.rs +++ b/src/unit_tests/rules/subgraphisomorphism_ilp.rs @@ -37,7 +37,7 @@ fn test_subgraphisomorphism_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -65,7 +65,7 @@ fn test_subgraphisomorphism_to_ilp_path_in_cycle() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -78,7 +78,7 @@ fn test_subgraphisomorphism_to_ilp_infeasible() { let reduction: ReductionSubIsoToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!(result.is_none(), "K3 in path should be infeasible"); + assert!(result.is_err(), "K3 in path should be infeasible"); } #[test] @@ -91,7 +91,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs index 87b3fe6a3..fed29ae65 100644 --- a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs +++ b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs @@ -45,10 +45,15 @@ fn test_subsetsum_to_integerexpressionmembership_extract_solution_matches_choice let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&issue_example_target_config()), + reduction + .extract_solution(&issue_example_target_config()) + .unwrap(), issue_example_source_config() ); - assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 0, 1]).unwrap(), + vec![1, 0, 0, 1] + ); } #[test] diff --git a/src/unit_tests/rules/subsetsum_partition.rs b/src/unit_tests/rules/subsetsum_partition.rs index cda81b51d..d6507e4f8 100644 --- a/src/unit_tests/rules/subsetsum_partition.rs +++ b/src/unit_tests/rules/subsetsum_partition.rs @@ -30,8 +30,14 @@ fn test_subsetsum_to_partition_sigma_greater_than_two_t_extraction() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!(reduction.target_problem().sizes(), &[10, 20, 30, 40]); - assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0]); - assert_eq!(reduction.extract_solution(&[0, 1, 1, 0]), vec![1, 0, 0]); + assert_eq!( + reduction.extract_solution(&[1, 0, 0, 1]).unwrap(), + vec![1, 0, 0] + ); + assert_eq!( + reduction.extract_solution(&[0, 1, 1, 0]).unwrap(), + vec![1, 0, 0] + ); } #[test] @@ -40,7 +46,10 @@ fn test_subsetsum_to_partition_sigma_equals_two_t_extraction() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!(reduction.target_problem().sizes(), &[3, 5, 2, 6]); - assert_eq!(reduction.extract_solution(&[1, 1, 0, 0]), vec![1, 1, 0, 0]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0, 0]).unwrap(), + vec![1, 1, 0, 0] + ); } #[test] diff --git a/src/unit_tests/rules/sumofsquarespartition_ilp.rs b/src/unit_tests/rules/sumofsquarespartition_ilp.rs index 8fb35803e..765d58b6f 100644 --- a/src/unit_tests/rules/sumofsquarespartition_ilp.rs +++ b/src/unit_tests/rules/sumofsquarespartition_ilp.rs @@ -37,7 +37,7 @@ fn test_sumofsquarespartition_to_ilp_bf_vs_ilp() { let reduction: ReductionSSPToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( ilp_value, bf_value, @@ -59,7 +59,7 @@ fn test_solution_extraction() { ilp_solution[3] = 1; // x_{1,1} ilp_solution[5] = 1; // x_{2,1} ilp_solution[6] = 1; // x_{3,0} - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 1, 0]); } @@ -75,7 +75,7 @@ fn test_sumofsquarespartition_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // Optimal: {1},{2} -> 1+4=5 assert_eq!(value, Min(Some(5))); diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index 0873a4b65..dea427b3d 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -2,10 +2,10 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::models::misc::{ResourceConstrainedScheduling, ThreePartition}; use crate::models::set::ThreeDimensionalMatching; -use crate::rules::{MinimizeSteps, ReduceTo, ReductionGraph, ReductionResult}; +use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; -use crate::types::{Or, ProblemSize}; +use crate::types::Or; fn canonical_problem() -> ThreeDimensionalMatching { ThreeDimensionalMatching::new( @@ -98,7 +98,7 @@ fn test_threedimensionalmatching_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("direct ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 0, 0]); assert_eq!(problem.evaluate(&extracted), Or(true)); @@ -115,7 +115,7 @@ fn test_threedimensionalmatching_to_ilp_infeasible_instance() { "source instance should be infeasible" ); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "reduced ILP should be infeasible" ); } @@ -134,11 +134,11 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { let direct_solution = solver .solve(direct.target_problem()) .expect("direct ILP should solve"); - let direct_source = direct.extract_solution(&direct_solution); + let direct_source = direct.extract_solution(&direct_solution).unwrap(); assert_eq!(problem.evaluate(&direct_source), Or(true)); assert!( - solver.solve(indirect.target_problem()).is_some(), + solver.solve(indirect.target_problem()).is_ok(), "indirect ILP should agree on feasibility" ); assert!(direct.target_problem().num_vars < indirect.target_problem().num_vars); @@ -150,18 +150,10 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { let src = ReductionGraph::variant_to_map(&ThreeDimensionalMatching::variant()); let dst = ReductionGraph::variant_to_map(&ILP::::variant()); let path = graph - .find_cheapest_path( - "ThreeDimensionalMatching", - &src, - "ILP", - &dst, - &ProblemSize::new(vec![ - ("universe_size", problem.universe_size()), - ("num_triples", problem.num_triples()), - ]), - &MinimizeSteps, - ) - .expect("reduction graph should find a direct 3DM -> ILP path"); + .find_all_paths("ThreeDimensionalMatching", &src, "ILP", &dst) + .into_iter() + .find(|path| path.type_names() == ["ThreeDimensionalMatching", "ILP"]) + .expect("reduction graph should contain the direct 3DM -> ILP path"); assert_eq!(path.type_names(), vec!["ThreeDimensionalMatching", "ILP"]); } diff --git a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs index 271cb910c..4ca06949a 100644 --- a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -111,7 +111,7 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_q_zero() { for witness in &target_witnesses { // Sentinel codeword is the all-zero vector of length 1. assert_eq!(witness, &vec![0]); - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); // Source has 0 triples → extracted vector has length 0. assert_eq!(extracted.len(), source.num_triples()); assert_eq!(extracted, Vec::::new()); @@ -133,7 +133,7 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_no_triples() let target_witnesses = solver.find_all_witnesses(target); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source.num_triples()); // Empty triple set cannot cover non-empty universe. assert!( @@ -158,11 +158,13 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_solution_extraction_id assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted, *witness); assert!( source_witnesses.contains(&extracted), "extracted witness {extracted:?} must be a valid 3DM solution" ); } + + assert!(reduction.extract_solution(&[0, 1, 0]).is_err()); } diff --git a/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs b/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs index e88f0b415..f8757539d 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs @@ -1,9 +1,8 @@ use crate::models::set::{ThreeDimensionalMatching, ThreeMatroidIntersection}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; -use crate::rules::{MinimizeSteps, ReduceTo, ReductionGraph, ReductionResult}; +use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; use crate::solvers::BruteForce; use crate::traits::Problem; -use crate::types::ProblemSize; fn feasible_problem() -> ThreeDimensionalMatching { ThreeDimensionalMatching::new( @@ -76,24 +75,20 @@ fn test_threedimensionalmatching_to_threematroidintersection_missing_coordinate_ #[test] fn test_threedimensionalmatching_to_threematroidintersection_direct_path_exists() { - let source = feasible_problem(); let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&ThreeDimensionalMatching::variant()); let dst = ReductionGraph::variant_to_map(&ThreeMatroidIntersection::variant()); let path = graph - .find_cheapest_path( + .find_all_paths( "ThreeDimensionalMatching", &src, "ThreeMatroidIntersection", &dst, - &ProblemSize::new(vec![ - ("universe_size", source.universe_size()), - ("num_triples", source.num_triples()), - ]), - &MinimizeSteps, ) - .expect("reduction graph should find the direct 3DM -> 3MI edge"); + .into_iter() + .find(|path| path.type_names() == ["ThreeDimensionalMatching", "ThreeMatroidIntersection"]) + .expect("reduction graph should contain the direct 3DM -> 3MI edge"); assert_eq!( path.type_names(), diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index 3e5cb86b0..7a4ea920f 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -57,7 +57,7 @@ fn test_threedimensionalmatching_to_threepartition_extracts_manual_q1_witness() assert!(reduction.target_problem().evaluate(&target_config).0); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -68,7 +68,7 @@ fn test_threedimensionalmatching_to_threepartition_closed_loop_from_known_matchi let target_solution = reduction.build_target_witness(&[1]); assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -80,7 +80,7 @@ fn test_threedimensionalmatching_to_threepartition_round_trip_q2_minimal_matchin assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs index e33249f79..d1e91e870 100644 --- a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs @@ -64,7 +64,7 @@ fn test_threepartition_to_resourceconstrainedscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol); let source_valid = source.evaluate(&extracted); diff --git a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 58deb0fa5..637fa2b19 100644 --- a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -73,7 +73,7 @@ fn test_threepartition_to_sequencingwithreleasetimesanddeadlines_solution_extrac let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let source_valid = source.evaluate(&extracted); assert!( diff --git a/src/unit_tests/rules/timetabledesign_ilp.rs b/src/unit_tests/rules/timetabledesign_ilp.rs index f4cdd9522..a32abd2cc 100644 --- a/src/unit_tests/rules/timetabledesign_ilp.rs +++ b/src/unit_tests/rules/timetabledesign_ilp.rs @@ -45,7 +45,7 @@ fn test_timetabledesign_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -55,7 +55,7 @@ fn test_timetabledesign_to_ilp_infeasible() { let problem = TimetableDesign::new(1, 1, 1, vec![vec![true]], vec![vec![true]], vec![vec![2]]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible TD should produce infeasible ILP" ); } @@ -75,7 +75,7 @@ fn test_timetabledesign_to_ilp_identity_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Identity extraction: ILP solution == source config assert_eq!(extracted, ilp_solution); diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index a7c1acd69..becdf7b29 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -4,8 +4,8 @@ fn test_traits_compile() { } use crate::rules::traits::{ - AggregateReductionResult, DynAggregateReductionResult, ReduceTo, ReduceToAggregate, - ReductionResult, + validate_target_solution, AggregateReductionResult, DynAggregateReductionResult, ReduceTo, + ReduceToAggregate, ReductionResult, }; use crate::traits::Problem; use crate::types::Sum; @@ -55,8 +55,11 @@ impl ReductionResult for TestReduction { fn target_problem(&self) -> &TargetProblem { &self.target } - fn extract_solution(&self, target_config: &[usize]) -> Vec { - target_config.to_vec() + fn extract_solution( + &self, + target_config: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_config.to_vec()) } } @@ -75,7 +78,17 @@ fn test_reduction() { let result = >::reduce_to(&source); let target = result.target_problem(); assert_eq!(target.evaluate(&[1, 1]), 2); - assert_eq!(result.extract_solution(&[1, 0]), vec![1, 0]); + assert_eq!(result.extract_solution(&[1, 0]).unwrap(), vec![1, 0]); +} + +#[test] +fn target_solution_validation_rejects_shape_and_domain_errors() { + let target = TargetProblem; + + assert!(validate_target_solution(&target, &[1, 0]).is_ok()); + assert!(validate_target_solution(&target, &[1]).is_err()); + assert!(validate_target_solution(&target, &[1, 0, 0]).is_err()); + assert!(validate_target_solution(&target, &[1, 2]).is_err()); } #[derive(Clone)] diff --git a/src/unit_tests/rules/travelingsalesman_ilp.rs b/src/unit_tests/rules/travelingsalesman_ilp.rs index cb0040030..07dec72d1 100644 --- a/src/unit_tests/rules/travelingsalesman_ilp.rs +++ b/src/unit_tests/rules/travelingsalesman_ilp.rs @@ -38,7 +38,7 @@ fn test_reduction_c4_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify extracted solution is valid on source problem let metric = problem.evaluate(&extracted); @@ -56,7 +56,7 @@ fn test_reduction_k4_weighted_closed_loop() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Solve via brute force for cross-check let bf = BruteForce::new(); @@ -83,7 +83,7 @@ fn test_reduction_c5_unweighted_closed_loop() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); @@ -104,7 +104,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle (infeasible ILP)" ); } @@ -121,7 +121,7 @@ fn test_solution_extraction_structure() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should have one value per edge assert_eq!(extracted.len(), 4); @@ -136,7 +136,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); let metric = problem.evaluate(&solution); diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index 1095a6937..54843970a 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -16,7 +16,7 @@ fn test_travelingsalesman_to_qubo_closed_loop() { // All QUBO solutions should extract to valid TSP solutions for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = tsp.evaluate(&extracted); assert!(metric.is_valid(), "Extracted solution should be valid"); // K3 has only one Hamiltonian cycle (all 3 edges), cost = 1+2+3 = 6 @@ -44,7 +44,7 @@ fn test_travelingsalesman_to_qubo_k4() { // Every Hamiltonian cycle in K4 uses exactly 4 edges, so cost = 4 for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = tsp.evaluate(&extracted); assert!(metric.is_valid(), "Extracted solution should be valid"); assert_eq!(metric, Min(Some(4))); diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index 2969919dd..66179f48d 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -58,7 +58,7 @@ fn test_undirectedflowlowerbounds_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // extract_solution returns edge orientations z_e assert_eq!(extracted.len(), 2); @@ -73,7 +73,7 @@ fn test_undirectedflowlowerbounds_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionUFLBToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -86,7 +86,7 @@ fn test_undirectedflowlowerbounds_to_ilp_extract_solution() { // f_{01}=1, f_{10}=0, f_{12}=1, f_{21}=0, z_0=1, z_1=1 // z_e=1 means u→v direction; model expects config[e]=0 for u→v → extract returns 1-z_e let target_solution = vec![1, 0, 1, 0, 1, 1]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // z_0=1, z_1=1 → extracted = [1-1, 1-1] = [0, 0] (both u→v = 0→1 and 1→2) assert_eq!(extracted, vec![0, 0]); assert!( diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index 398f8c485..bd3b67e3d 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -65,9 +65,22 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_overhead_matches_target() { }) .expect("U2CIF -> ILP reduction should be registered"); - let overhead = (entry.overhead_eval_fn)(&problem as &dyn std::any::Any); - assert_eq!(overhead.get("num_vars"), Some(ilp.num_vars)); - assert_eq!(overhead.get("num_constraints"), Some(ilp.constraints.len())); + let source_size = (entry.source_size_measure_fn)(&problem as &dyn std::any::Any); + let predicted = entry + .size_contract() + .unwrap() + .transform() + .unwrap() + .evaluate(&crate::size::EvaluatedSize::from_problem_size(&source_size)) + .unwrap(); + assert_eq!( + predicted.values().get("num_vars"), + Some(&ilp.num_vars.into()) + ); + assert_eq!( + predicted.values().get("num_constraints"), + Some(&ilp.constraints.len().into()) + ); } #[test] @@ -86,7 +99,7 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -99,7 +112,7 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionU2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should yield infeasible ILP" ); } @@ -121,7 +134,7 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_extract_solution() { 0, 1, // d1_1=0, d2_1=1 1, 1, // d1_2=1, d2_2=1 ]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // extract_solution returns first 4*3=12 flow variables assert_eq!(extracted.len(), 12); assert!( diff --git a/src/unit_tests/size.rs b/src/unit_tests/size.rs new file mode 100644 index 000000000..11b668ef0 --- /dev/null +++ b/src/unit_tests/size.rs @@ -0,0 +1,104 @@ +use super::{EvaluatedSize, SizeRelation, SizeTransform, SizeTransformError, SizeValues}; +use crate::expr::Expr; +use num_bigint::BigUint; +use num_traits::One; + +#[test] +fn exact_transform_evaluates_exactly() { + let transform = SizeTransform::new( + "A -> B", + SizeRelation::Exact, + [("m", Expr::parse("n * (n - 1) / 2"))], + ) + .unwrap(); + let result = transform + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", 5u8)]))) + .unwrap(); + assert_eq!(result.relation(), SizeRelation::Exact); + assert_eq!(result.values().get("m"), Some(&BigUint::from(10u8))); +} + +#[test] +fn upper_bound_relation_survives_evaluation_and_composition() { + let first = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [("m", Expr::parse("n^2"))], + ) + .unwrap(); + let second = SizeTransform::new( + "B -> C", + SizeRelation::Exact, + [("k", Expr::parse("3*m + 1"))], + ) + .unwrap(); + let composed = first.compose(&second, "A -> C").unwrap(); + assert_eq!(composed.relation(), SizeRelation::UpperBound); + let result = composed + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", 4u8)]))) + .unwrap(); + assert_eq!(result.relation(), SizeRelation::UpperBound); + assert_eq!(result.values().get("k"), Some(&BigUint::from(49u8))); +} + +#[test] +fn upper_bound_cannot_cross_a_non_monotone_exact_transform() { + let first = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [("m", Expr::parse("n^2"))], + ) + .unwrap(); + let second = SizeTransform::new( + "B -> C", + SizeRelation::Exact, + [("k", Expr::parse("10 - m"))], + ) + .unwrap(); + assert!(matches!( + first.compose(&second, "A -> C"), + Err(SizeTransformError::CannotPropagateUpperBound { .. }) + )); +} + +#[test] +fn upper_bound_rational_results_round_up() { + let transform = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [("m", Expr::parse("n / 2"))], + ) + .unwrap(); + let result = transform + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", 5u8)]))) + .unwrap(); + assert_eq!(result.values().get("m"), Some(&BigUint::from(3u8))); +} + +#[test] +fn evaluation_stays_exact_beyond_machine_integer_range() { + let transform = + SizeTransform::new("A -> B", SizeRelation::Exact, [("m", Expr::parse("n^2"))]).unwrap(); + let n = BigUint::one() << 200usize; + let result = transform + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", n.clone())]))) + .unwrap(); + assert_eq!(result.values().get("m"), Some(&(&n * &n))); + assert!(matches!( + result.values().try_to_problem_size(), + Err(SizeTransformError::OutputOutOfRange { .. }) + )); +} + +#[test] +fn growth_projection_keeps_the_rule_relation() { + let transform = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [("m", Expr::parse("3*n^2"))], + ) + .unwrap(); + let growth = transform.project_growth(); + assert_eq!(growth.relation(), SizeRelation::UpperBound); + assert_eq!(growth.get("m").unwrap().to_big_o(), "O(n^2)"); +} diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index 75d31d717..725f494d7 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -2,6 +2,8 @@ use super::*; use crate::solvers::Solver; use crate::traits::Problem; use crate::types::{Max, Min, Or, Sum}; +use std::cell::Cell; +use std::rc::Rc; #[derive(Clone)] struct MaxSumProblem { @@ -87,6 +89,29 @@ struct SumProblem { weights: Vec, } +#[derive(Clone)] +struct CountingSatProblem { + evaluations: Rc>, +} + +impl Problem for CountingSatProblem { + const NAME: &'static str = "CountingSatProblem"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![2, 2] + } + + fn evaluate(&self, config: &[usize]) -> Self::Value { + self.evaluations.set(self.evaluations.get() + 1); + Or(config == [0, 0]) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + impl Problem for SumProblem { const NAME: &'static str = "SumProblem"; type Value = Sum; @@ -162,6 +187,19 @@ fn test_solver_find_witness_for_satisfaction_problem() { assert_eq!(problem.evaluate(&witness.unwrap()), Or(true)); } +#[test] +fn test_solver_find_witness_stops_after_first_optimal_configuration() { + let evaluations = Rc::new(Cell::new(0)); + let problem = CountingSatProblem { + evaluations: Rc::clone(&evaluations), + }; + + assert_eq!(BruteForce::new().find_witness(&problem), Some(vec![0, 0])); + // Four evaluations compute the aggregate; the witness pass stops at the + // first configuration instead of collecting every optimal witness. + assert_eq!(evaluations.get(), 5); +} + #[test] fn test_solver_find_witness_returns_none_for_sum_problem() { let problem = SumProblem { diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 310ab6fa2..d83350d7d 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -16,7 +16,7 @@ fn test_ilp_solver_basic_maximize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -40,7 +40,7 @@ fn test_ilp_solver_basic_minimize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -86,11 +86,11 @@ fn test_ilp_empty_problem() { let ilp = ILP::::empty(); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, Some(vec![])); + assert_eq!(solution, Ok(vec![])); } #[test] -fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { +fn test_ilp_empty_problem_with_infeasible_constraint_returns_infeasible() { let ilp = ILP::::new( 0, vec![LinearConstraint::le(vec![], -1.0)], @@ -99,7 +99,27 @@ fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { ); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, None); + assert_eq!(solution, Err(ILPSolveError::Infeasible)); +} + +#[test] +fn test_backend_errors_are_classified_without_losing_the_cause() { + assert_eq!( + classify_backend_error(ResolutionError::Infeasible, None), + ILPSolveError::Infeasible + ); + assert_eq!( + classify_backend_error(ResolutionError::Unbounded, None), + ILPSolveError::Unbounded + ); + assert_eq!( + classify_backend_error(ResolutionError::Other("NoSolutionFound"), Some(0.1)), + ILPSolveError::Timeout + ); + assert!(matches!( + classify_backend_error(ResolutionError::Other("SolveError"), None), + ILPSolveError::BackendFailure(message) if message.contains("SolveError") + )); } #[test] @@ -262,49 +282,34 @@ fn test_ilp_with_time_limit() { ); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); } #[test] -fn test_ilp_solve_via_reduction_success() { +fn test_registered_ilp_pipeline_success() { use crate::models::graph::MaximumIndependentSet; + use crate::registry::load_dyn; + use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; use crate::topology::SimpleGraph; use std::collections::BTreeMap; - let solver = ILPSolver::new(); let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]); let variant = BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i32".to_string()), ]); - let result = solver.try_solve_via_reduction("MaximumIndependentSet", &variant, &problem); - assert!(result.is_ok()); - let sol = result.unwrap(); - let eval = problem.evaluate(&sol); + let loaded = load_dyn( + "MaximumIndependentSet", + &variant, + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let result = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(result.solver, SolverExecution::Ilp { .. })); + let eval = problem.evaluate(result.config.as_ref().unwrap()); assert!(eval.is_valid()); } -#[test] -fn test_ilp_solve_via_reduction_no_path() { - use std::collections::BTreeMap; - - // Use a problem name that doesn't exist in the graph - let solver = ILPSolver::new(); - let ilp = ILP::::new( - 2, - vec![LinearConstraint::le(vec![(0, 1.0), (1, 1.0)], 1.0)], - vec![(0, 1.0)], - ObjectiveSense::Maximize, - ); - // solve_via_reduction on an ILP itself should succeed directly - let result = solver.try_solve_via_reduction( - "ILP", - &BTreeMap::from([("type".to_string(), "bool".to_string())]), - &ilp, - ); - assert!(result.is_ok()); -} - #[test] fn test_ilp_solve_dyn_bool() { let solver = ILPSolver::new(); @@ -315,7 +320,7 @@ fn test_ilp_solve_dyn_bool() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] @@ -328,63 +333,13 @@ fn test_ilp_solve_dyn_i32() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] -fn test_ilp_solve_dyn_unknown_type_returns_none() { +fn test_ilp_solve_dyn_unknown_type_returns_unsupported_problem_type() { let solver = ILPSolver::new(); let not_ilp: i32 = 42; let result = solver.solve_dyn(¬_ilp as &dyn std::any::Any); - assert!(result.is_none()); -} - -#[test] -fn test_ilp_supports_direct_dyn() { - let solver = ILPSolver::new(); - let ilp_bool = ILP::::empty(); - let ilp_i32 = ILP::::new(1, vec![], vec![], ObjectiveSense::Maximize); - let not_ilp: i32 = 42; - - assert!(solver.supports_direct_dyn(&ilp_bool as &dyn std::any::Any)); - assert!(solver.supports_direct_dyn(&ilp_i32 as &dyn std::any::Any)); - assert!(!solver.supports_direct_dyn(¬_ilp as &dyn std::any::Any)); -} - -#[test] -fn test_solve_via_reduction_error_display() { - use crate::solvers::ilp::SolveViaReductionError; - - let err = SolveViaReductionError::WitnessPathRequired { - name: "Foo".to_string(), - }; - assert!(err.to_string().contains("witness-capable")); - assert!(err.to_string().contains("Foo")); - - let err = SolveViaReductionError::NoReductionPath { - name: "Bar".to_string(), - }; - assert!(err.to_string().contains("No reduction path")); - assert!(err.to_string().contains("Bar")); - - let err = SolveViaReductionError::NoSolution { - name: "Baz".to_string(), - }; - assert!(err.to_string().contains("no solution")); - assert!(err.to_string().contains("Baz")); - - // std::error::Error is implemented - let _: &dyn std::error::Error = &err; -} - -#[test] -fn test_solve_via_reduction_returns_none_for_no_path() { - let solver = ILPSolver::new(); - let not_ilp: i32 = 42; - let result = solver.solve_via_reduction( - "NonexistentProblem", - &std::collections::BTreeMap::new(), - ¬_ilp as &dyn std::any::Any, - ); - assert!(result.is_none()); + assert_eq!(result, Err(ILPSolveError::UnsupportedProblemType)); } diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/native/solver.rs similarity index 76% rename from src/unit_tests/solvers/customized/solver.rs rename to src/unit_tests/solvers/native/solver.rs index 09127b0d5..dabfd8531 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/native/solver.rs @@ -1,9 +1,33 @@ use crate::config::DimsIterator; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::solvers::CustomizedSolver; +use crate::solvers::registry::solver_capability_registry; +use crate::solvers::ExactProblemKey; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; +struct NativeTestSolver; + +impl NativeTestSolver { + fn new() -> Self { + Self + } + + fn solve_dyn(&self, problem: &P) -> Option> { + let key = ExactProblemKey::new( + P::NAME, + P::variant() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ); + solver_capability_registry() + .unwrap() + .lookup(&key) + .native + .and_then(|registration| (registration.solve_fn)(problem)) + } +} + fn all_simple_graphs(num_vertices: usize) -> impl Iterator { let candidate_edges: Vec<(usize, usize)> = (0..num_vertices) .flat_map(|u| ((u + 1)..num_vertices).map(move |v| (u, v))) @@ -37,22 +61,22 @@ fn exact_rooted_tree_arrangement_min_stretch(graph: &SimpleGraph) -> Option Vec<(&'static str, &'static str)> { + Vec::new() +} + +fn no_solution(_: &dyn std::any::Any) -> Option> { + None +} + +static NATIVE_A: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-a", + solve_fn: no_solution, +}; +static NATIVE_B: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-b", + solve_fn: no_solution, +}; + +#[test] +fn solver_capability_registry_constructs_without_graph_search() { + solver_capability_registry().expect("production solver registrations must be valid"); +} + +#[test] +fn exact_problem_key_has_canonical_label() { + let key = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + assert_eq!(key.label(), "MaximumIndependentSet"); +} + +#[test] +fn solver_capability_registry_duplicate_ilp_registration_is_rejected_independent_of_order() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + for pipelines in [ + [&DIRECT_BOOL_A, &DIRECT_BOOL_B], + [&DIRECT_BOOL_B, &DIRECT_BOOL_A], + ] { + let error = build_registry(&variants, std::iter::empty(), pipelines, &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateIlp(_))); + } +} + +#[test] +fn solver_capability_registry_duplicate_native_registration_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, [&NATIVE_A, &NATIVE_B], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateNative(_))); +} + +#[test] +fn solver_capability_registry_unknown_native_variant_is_rejected() { + let error = build_registry(&BTreeSet::new(), [&NATIVE_A], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_unknown_pipeline_variant_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_empty_pipeline_is_rejected() { + let error = + build_registry(&BTreeSet::new(), std::iter::empty(), [&EMPTY_PIPELINE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::EmptyPipeline)); +} + +#[test] +fn solver_capability_registry_unsupported_pipeline_target_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, std::iter::empty(), [&UNSUPPORTED_TARGET], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnsupportedTarget(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_pipeline_with_missing_exact_edge_is_rejected() { + let variants = BTreeSet::from([ + ExactProblemKey::new("Source", BTreeMap::new()), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 0, .. } + )); +} + +#[test] +fn solver_capability_registry_pipeline_must_stop_at_first_supported_ilp_node() { + let variants = BTreeSet::from([ + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "i32".to_string())]), + ), + ]); + let error = + build_registry(&variants, std::iter::empty(), [&CONTINUES_AFTER_ILP], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::ContinuesAfterIlp(_))); +} + +#[test] +fn solver_capability_registry_production_registry_has_expected_exact_capability_counts() { + let registry = solver_capability_registry().unwrap(); + assert_eq!(registry.native_entries().count(), 7); + assert_eq!(registry.ilp_entries().count(), 151); +} + +#[test] +fn solver_capability_registry_exposes_representative_capability_classes() { + let key = |name: &str, variant: &[(&str, &str)]| { + ExactProblemKey::new( + name, + variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + }; + + let native_only = solver_capabilities(&key("TimetableDesign", &[])).unwrap(); + assert_eq!( + native_only.native.unwrap().implementation, + "timetable-required-assignments" + ); + assert!(native_only.ilp.is_none()); + + let direct_ilp = solver_capabilities(&key( + "MaximumClique", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(direct_ilp.native.is_none()); + assert_eq!( + direct_ilp.ilp.unwrap().path_labels(), + ["MaximumClique", "ILP"] + ); + + let multihop_ilp = solver_capabilities(&key( + "MaximumIndependentSet", + &[("graph", "SimpleGraph"), ("weight", "One")], + )) + .unwrap(); + assert!(multihop_ilp.ilp.unwrap().path_labels().len() > 2); + + let both = + solver_capabilities(&key("RootedTreeArrangement", &[("graph", "SimpleGraph")])).unwrap(); + assert!(both.native.is_some()); + assert!(both.ilp.is_some()); + + let brute_force_only = solver_capabilities(&key( + "MaxCut", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(brute_force_only.native.is_none()); + assert!(brute_force_only.ilp.is_none()); + + let ilp_itself = solver_capabilities(&key("ILP", &[("variable", "bool")])).unwrap(); + assert_eq!(ilp_itself.ilp.unwrap().path_labels(), ["ILP"]); +} + +#[test] +fn solver_capability_registry_does_not_leak_across_exact_variants() { + let registry = solver_capability_registry().unwrap(); + let key = ExactProblemKey::new( + "MinimumCardinalityKey", + BTreeMap::from([("unexpected".to_string(), "variant".to_string())]), + ); + let capabilities = registry.lookup(&key); + assert!(capabilities.native.is_none()); + assert!(capabilities.ilp.is_none()); +} + +#[test] +fn solver_capability_registry_ignores_unrelated_reduction_edges() { + let source = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + let registration = inventory::iter:: + .into_iter() + .find(|registration| { + registration.path.first().map(ExactProblemKey::from_static) == Some(source.clone()) + }) + .expect("production MIS pipeline must be registered"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let all_reductions = reduction_entries(); + let required_reductions = all_reductions + .iter() + .copied() + .filter(|entry| { + path.windows(2) + .any(|pair| edge_key(entry, true) == pair[0] && edge_key(entry, false) == pair[1]) + }) + .collect::>(); + let unrelated = all_reductions + .iter() + .copied() + .find(|entry| { + !required_reductions + .iter() + .any(|required| std::ptr::eq(*required, *entry)) + }) + .expect("catalog must contain an unrelated reduction edge"); + let mut with_unrelated = required_reductions.clone(); + with_unrelated.push(unrelated); + + let variants = registered_variant_keys(); + let minimal = build_registry( + &variants, + std::iter::empty(), + [registration], + &required_reductions, + ) + .unwrap(); + let expanded = build_registry( + &variants, + std::iter::empty(), + [registration], + &with_unrelated, + ) + .unwrap(); + let minimal_pipeline = minimal.lookup(&source).ilp.unwrap(); + let expanded_pipeline = expanded.lookup(&source).ilp.unwrap(); + + assert_eq!(minimal_pipeline.path(), expanded_pipeline.path()); + assert_eq!( + minimal_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>(), + expanded_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>() + ); +} + +#[test] +fn solver_capability_registry_ambiguous_exact_edge_is_rejected() { + let registration = inventory::iter:: + .into_iter() + .find(|registration| registration.path.len() == 2) + .expect("production catalog must contain a direct ILP pipeline"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let reduction = reduction_entries() + .into_iter() + .find(|entry| { + entry.capabilities().witness + && entry.reduce_fn.is_some() + && edge_key(entry, true) == path[0] + && edge_key(entry, false) == path[1] + }) + .expect("direct pipeline must have one witness reduction"); + let error = build_registry( + ®istered_variant_keys(), + std::iter::empty(), + [registration], + &[reduction, reduction], + ) + .unwrap_err(); + + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 2, .. } + )); +} diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs new file mode 100644 index 000000000..7e62066ab --- /dev/null +++ b/src/unit_tests/solvers/resolver.rs @@ -0,0 +1,238 @@ +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::registry::load_dyn; +use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; +use crate::traits::Problem; +use std::collections::BTreeMap; + +#[test] +fn deterministic_solver_dispatch_native_registration_wins_default_dispatch() { + use crate::models::set::MinimumCardinalityKey; + + let problem = MinimumCardinalityKey::new(3, vec![(vec![0], vec![1, 2])]); + let loaded = crate::registry::load_dyn( + MinimumCardinalityKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Native { + implementation: "fd-minimum-cardinality-key" + } + ); +} + +#[test] +fn deterministic_solver_dispatch_unregistered_ilp_override_is_a_capability_error_without_fallback() +{ + use crate::models::graph::MaxCut; + use crate::topology::SimpleGraph; + + // MaxCut has a discoverable graph route toward ILP, but that route is + // partial for valid negative-weight instances and is intentionally not a + // registered solver pipeline. + let problem = MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]); + let loaded = crate::registry::load_dyn( + MaxCut::::NAME, + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!(default.solver, SolverExecution::BruteForce); + let error = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::MissingIlpCapability(_) + )); +} + +#[test] +fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { + use crate::models::misc::AdditionalKey; + + // {0} is the only candidate key and it is already known, so the registered + // native solver has no witness. Brute force can still report the aggregate + // infeasibility result, which lets this test distinguish fallback from error. + let problem = AdditionalKey::new(3, vec![(vec![0], vec![1, 2])], vec![0, 1, 2], vec![vec![0]]); + let loaded = load_dyn( + AdditionalKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::NativeNoSolution { .. } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +fn deterministic_solver_dispatch_direct_ilp_uses_registered_one_node_pipeline() { + let problem = ILP::::new(0, vec![], vec![], ObjectiveSense::Minimize); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Ilp { + reduction_path: vec!["ILP".to_string()] + } + ); + assert_eq!(result.config, Some(vec![])); +} + +#[test] +fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { + let problem = ILP::::new( + 0, + vec![LinearConstraint::le(vec![], -1.0)], + vec![], + ObjectiveSense::Minimize, + ); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::IlpSolve { + source: crate::solvers::ILPSolveError::Infeasible, + .. + } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +fn deterministic_solver_execution_has_stable_tagged_json_contract() { + assert_eq!( + serde_json::to_value(SolverExecution::Native { + implementation: "native-id" + }) + .unwrap(), + serde_json::json!({"kind": "native", "implementation": "native-id"}) + ); + assert_eq!( + serde_json::to_value(SolverExecution::Ilp { + reduction_path: vec!["Source".to_string(), "ILP".to_string()] + }) + .unwrap(), + serde_json::json!({ + "kind": "ilp", + "reduction_path": ["Source", "ILP"] + }) + ); + assert_eq!( + serde_json::to_value(SolverExecution::BruteForce).unwrap(), + serde_json::json!({"kind": "brute-force"}) + ); +} + +#[test] +fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { + use crate::models::graph::MaximumIndependentSet; + use crate::topology::SimpleGraph; + + let problem = MaximumIndependentSet::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![crate::types::One; 3], + ); + let variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]); + let loaded = load_dyn( + MaximumIndependentSet::::NAME, + &variant, + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let first = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + let second = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert_eq!(first, second); + let SolverExecution::Ilp { reduction_path } = first.solver else { + panic!("expected ILP execution metadata"); + }; + assert_eq!( + reduction_path, + vec![ + "MaximumIndependentSet", + "MaximumIndependentSet", + "MaximumSetPacking", + "ILP", + ] + ); +} + +#[test] +fn deterministic_solver_dispatch_native_default_allows_explicit_ilp_override() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert!(matches!(default.solver, SolverExecution::Native { .. })); + + let explicit_ilp = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(explicit_ilp.solver, SolverExecution::Ilp { .. })); + assert_eq!(default.evaluation, explicit_ilp.evaluation); +} + +#[test] +fn deterministic_solver_dispatch_repeats_each_available_solver_class() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let mut evaluations = Vec::new(); + for request in [ + SolverRequest::Default, + SolverRequest::Ilp, + SolverRequest::BruteForce, + ] { + let first = solve_deterministically(&loaded, request).unwrap(); + let second = solve_deterministically(&loaded, request).unwrap(); + assert_eq!(first, second, "{request:?} changed its witness"); + evaluations.push(first.evaluation); + } + assert!(evaluations.windows(2).all(|pair| pair[0] == pair[1])); +} diff --git a/src/unit_tests/symbolic_size_contracts.rs b/src/unit_tests/symbolic_size_contracts.rs new file mode 100644 index 000000000..e5fd0123a --- /dev/null +++ b/src/unit_tests/symbolic_size_contracts.rs @@ -0,0 +1,82 @@ +use crate::models::algebraic::AlgebraicEquationsOverGF2; +use crate::models::graph::{MaximumClique, MaximumIndependentSet}; +use crate::models::set::ExactCoverBy3Sets; +use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; +use crate::size::SizeRelation; +use crate::topology::SimpleGraph; +use crate::types::ProblemSize; +use crate::Problem; +use num_bigint::BigUint; + +#[test] +fn exact_rule_formula_matches_the_constructed_target() { + let source = MaximumIndependentSet::::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + vec![1; 5], + ); + let reduction = as ReduceTo< + MaximumClique, + >>::reduce_to(&source); + let target = reduction.target_problem(); + let graph = ReductionGraph::new(); + let source_variant = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let target_variant = + ReductionGraph::variant_to_map(&MaximumClique::::variant()); + let path = graph + .find_all_paths( + MaximumIndependentSet::::NAME, + &source_variant, + MaximumClique::::NAME, + &target_variant, + ) + .into_iter() + .find(|path| path.len() == 1) + .expect("direct reduction is registered"); + + let predicted = graph + .evaluate_path_size( + &path, + &ProblemSize::new(vec![("num_vertices", 5), ("num_edges", 4)]), + ) + .unwrap(); + assert_eq!(predicted.relation(), SizeRelation::Exact); + assert_eq!( + predicted.values().get("num_vertices"), + Some(&BigUint::from(target.num_vertices())) + ); + assert_eq!( + predicted.values().get("num_edges"), + Some(&BigUint::from(target.num_edges())) + ); +} + +#[test] +fn incoming_rule_measures_every_declared_field_on_a_sink_variant() { + let source = ExactCoverBy3Sets::new(3, vec![[0, 1, 2]]); + let reduction = >::reduce_to(&source); + let target = reduction.target_problem(); + let target_variant = ReductionGraph::variant_to_map(&AlgebraicEquationsOverGF2::variant()); + + let measured = ReductionGraph::compute_problem_size( + AlgebraicEquationsOverGF2::NAME, + &target_variant, + target, + ); + + assert_eq!(measured.get("num_variables"), Some(target.num_variables())); + assert_eq!(measured.get("num_equations"), Some(target.num_equations())); +} + +#[test] +fn every_registered_rule_has_one_valid_size_contract() { + for entry in crate::rules::registry::reduction_entries() { + let contract = entry.size_contract().unwrap_or_else(|error| { + panic!( + "{} -> {} has an invalid size contract: {error}", + entry.source_name, entry.target_name + ) + }); + assert!(contract.transform().is_some() || !contract.unavailable().is_empty()); + } +} diff --git a/tests/main.rs b/tests/main.rs index 6f8e4c248..6abe686c5 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -8,9 +8,10 @@ mod integration; mod jl_parity; #[path = "suites/ksatisfiability_simultaneous_incongruences.rs"] mod ksatisfiability_simultaneous_incongruences; +#[path = "suites/numeric_boundaries.rs"] +mod numeric_boundaries; #[path = "suites/reductions.rs"] mod reductions; -#[cfg(feature = "ilp-solver")] #[path = "suites/register_assignment_reductions.rs"] mod register_assignment_reductions; #[path = "suites/simultaneous_incongruences.rs"] diff --git a/tests/suites/examples.rs b/tests/suites/examples.rs index dacff97ca..bf19f5139 100644 --- a/tests/suites/examples.rs +++ b/tests/suites/examples.rs @@ -1,90 +1,78 @@ -// Test remaining example binaries to keep them compiling and correct. -// Examples with `pub fn run()` are included directly; others are run as subprocesses. +// Test example behavior directly without spawning nested Cargo builds. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; // --- Chained reduction demo (has pub fn run()) --- -#[cfg(feature = "ilp-solver")] #[allow(unused)] mod chained_reduction_factoring_to_spinglass { include!("../../examples/chained_reduction_factoring_to_spinglass.rs"); } -#[cfg(feature = "ilp-solver")] #[test] fn test_chained_reduction_factoring_to_spinglass() { - chained_reduction_factoring_to_spinglass::run(); + chained_reduction_factoring_to_spinglass::run().unwrap(); } -// --- Subprocess tests for export utilities --- +#[allow(dead_code)] +#[path = "../../examples/export_graph.rs"] +mod export_graph; -fn run_example(name: &str) { - let status = std::process::Command::new(env!("CARGO")) - .args(["run", "--example", name, "--features", "ilp-highs"]) - .status() - .unwrap_or_else(|e| panic!("Failed to run example {name}: {e}")); - assert!(status.success(), "Example {name} failed with {status}"); -} +#[allow(dead_code)] +#[path = "../../examples/export_schemas.rs"] +mod export_schemas; + +#[allow(dead_code)] +#[path = "../../examples/export_petersen_mapping.rs"] +mod export_petersen_mapping; -fn temp_output_path(name: &str) -> PathBuf { +fn temp_output_dir(name: &str) -> PathBuf { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Clock must be after UNIX_EPOCH") .as_nanos(); std::env::temp_dir().join(format!( - "problemreductions_{name}_{}_{}.json", + "problemreductions_{name}_{}_{}", std::process::id(), timestamp )) } -fn run_example_with_output(name: &str, output_path: &Path) { - let output = output_path - .to_str() - .unwrap_or_else(|| panic!("Non-UTF-8 temp path for {name}: {output_path:?}")); - let status = std::process::Command::new(env!("CARGO")) - .args([ - "run", - "--example", - name, - "--features", - "ilp-highs", - "--", - output, - ]) - .status() - .unwrap_or_else(|e| panic!("Failed to run example {name}: {e}")); - assert!(status.success(), "Example {name} failed with {status}"); - assert!( - output_path.is_file(), - "Example {name} did not create expected output file at {}", - output_path.display() - ); -} - #[test] fn test_export_graph() { - let output_path = temp_output_path("export_graph"); - run_example_with_output("export_graph", &output_path); - let _ = std::fs::remove_file(output_path); + let output_dir = temp_output_dir("export_graph"); + let output_path = output_dir.join("reduction_graph.json"); + export_graph::run(&output_path); + assert!(output_path.is_file()); + std::fs::remove_dir_all(output_dir).unwrap(); } #[test] fn test_export_schemas() { - let output_path = temp_output_path("export_schemas"); - run_example_with_output("export_schemas", &output_path); - let _ = std::fs::remove_file(output_path); + let output_dir = temp_output_dir("export_schemas"); + let output_path = output_dir.join("problem_schemas.json"); + export_schemas::run(&output_path); + assert!(output_path.is_file()); + std::fs::remove_dir_all(output_dir).unwrap(); } #[test] fn test_export_petersen_mapping() { - run_example("export_petersen_mapping"); + let output_dir = temp_output_dir("export_petersen_mapping"); + export_petersen_mapping::run(&output_dir); + for filename in [ + "petersen_source.json", + "petersen_square_weighted.json", + "petersen_square_unweighted.json", + "petersen_triangular.json", + ] { + assert!(output_dir.join(filename).is_file()); + } + std::fs::remove_dir_all(output_dir).unwrap(); } // Note: detect_isolated_problems and detect_unreachable_from_3sat are diagnostic // tools that exit(1) when they find issues. They are run via `make` targets // (topology-sanity-check), not as part of `cargo test`. -// Note: export_examples requires the `example-db` feature which is not enabled -// in standard CI test runs. It is exercised via `make examples`. +// Note: export_examples is exercised by `make paper` with the example-db feature. diff --git a/tests/suites/numeric_boundaries.rs b/tests/suites/numeric_boundaries.rs new file mode 100644 index 000000000..a543f55f0 --- /dev/null +++ b/tests/suites/numeric_boundaries.rs @@ -0,0 +1,104 @@ +use problemreductions::models::formula::{ + CNFClause, KSatisfiability, Maximum2Satisfiability, NAESatisfiability, + OneInThreeSatisfiability, Planar3Satisfiability, QuantifiedBooleanFormulas, Quantifier, + Satisfiability, +}; +use problemreductions::models::graph::MinimumDominatingSet; +use problemreductions::models::set::MinimumSetCovering; +use problemreductions::rules::{ReduceTo, ReductionResult}; +use problemreductions::topology::SimpleGraph; +use problemreductions::variant::K3; +use problemreductions::Problem; + +#[test] +fn numeric_boundaries_weight_totals_use_i64() { + let dominating = + MinimumDominatingSet::new(SimpleGraph::new(2, vec![]), vec![i32::MAX, i32::MAX]); + assert_eq!(dominating.evaluate(&[1, 1]).0, Some(4_294_967_294_i64)); + + let covering = + MinimumSetCovering::with_weights(2, vec![vec![0], vec![1]], vec![i32::MAX, i32::MAX]); + assert_eq!(covering.evaluate(&[1, 1]).0, Some(4_294_967_294_i64)); + + let ordinary = MinimumSetCovering::with_weights(1, vec![vec![0]], vec![7i32]); + assert_eq!(ordinary.evaluate(&[1]).0, Some(7_i64)); +} + +#[test] +fn numeric_boundaries_all_cnf_models_reject_invalid_literals() { + for literal in [0, i32::MIN, 2] { + let errors = [ + Satisfiability::try_new(1, vec![CNFClause::new(vec![literal])]).unwrap_err(), + KSatisfiability::::try_new(1, vec![CNFClause::new(vec![literal, 1, 1])]) + .unwrap_err(), + NAESatisfiability::try_new(1, vec![CNFClause::new(vec![literal, 1])]).unwrap_err(), + Maximum2Satisfiability::try_new(1, vec![CNFClause::new(vec![literal, 1])]).unwrap_err(), + OneInThreeSatisfiability::try_new(1, vec![CNFClause::new(vec![literal, 1, 1])]) + .unwrap_err(), + Planar3Satisfiability::try_new(1, vec![CNFClause::new(vec![literal, 1, 1])]) + .unwrap_err(), + QuantifiedBooleanFormulas::try_new( + 1, + vec![Quantifier::Exists], + vec![CNFClause::new(vec![literal])], + ) + .unwrap_err(), + ]; + + for error in errors { + assert!(error.contains(&literal.to_string()), "{error}"); + assert!(error.contains("1..=1"), "{error}"); + } + } +} + +#[test] +fn numeric_boundaries_sat_variable_limit_does_not_allocate() { + let max = i32::MAX as usize; + let formula = Satisfiability::try_new(max, vec![CNFClause::new(vec![i32::MAX])]).unwrap(); + assert_eq!(formula.num_vars(), max); + + let error = Satisfiability::try_new(max + 1, vec![]).unwrap_err(); + assert!(error.contains(&(max + 1).to_string()), "{error}"); + assert!(error.contains(&i32::MAX.to_string()), "{error}"); +} + +#[test] +fn numeric_boundaries_serde_uses_cnf_validation() { + let error = + serde_json::from_str::(r#"{"num_vars":1,"clauses":[{"literals":[0]}]}"#) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid literal 0"), "{error}"); + assert!(error.contains("1..=1"), "{error}"); +} + +#[test] +fn numeric_boundaries_sat_reduction_rejects_exhausted_variable_ids() { + let source = Satisfiability::new(i32::MAX as usize, vec![CNFClause::new(vec![i32::MAX])]); + let panic = std::panic::catch_unwind(|| { + let _ = + >>::reduce_to(&source).target_problem(); + }) + .unwrap_err(); + let message = panic_message(panic); + assert!( + message.contains("Satisfiability -> KSatisfiability"), + "{message}" + ); + assert!( + message.contains("allocate 1 auxiliary variable"), + "{message}" + ); + assert!(message.contains(&i32::MAX.to_string()), "{message}"); +} + +fn panic_message(panic: Box) -> String { + if let Some(message) = panic.downcast_ref::() { + return message.clone(); + } + panic + .downcast_ref::<&str>() + .expect("panic payload must be a string") + .to_string() +} From 9104f312deed64cf223153ac4da3dca0de43e75c Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 19:39:27 +0800 Subject: [PATCH 02/15] Expose registry-driven CLI and MCP workflows Use the unified registry for creation, inspection, deterministic solver selection, reduction paths, and size reporting. Keep CLI and MCP outputs consistent and cover their user-facing error paths and round trips. --- problemreductions-cli/src/bin/pred_sym.rs | 74 +- problemreductions-cli/src/cli.rs | 1491 ++--------- problemreductions-cli/src/commands/create.rs | 1894 +------------- .../src/commands/create/schema_semantics.rs | 1327 ---------- .../src/commands/create/schema_support.rs | 2099 +++------------- .../src/commands/create/tests.rs | 967 +++----- problemreductions-cli/src/commands/extract.rs | 2 +- problemreductions-cli/src/commands/graph.rs | 1048 ++++++-- problemreductions-cli/src/commands/inspect.rs | 67 +- problemreductions-cli/src/commands/reduce.rs | 200 +- problemreductions-cli/src/commands/solve.rs | 202 +- problemreductions-cli/src/create_args.rs | 283 +++ problemreductions-cli/src/dispatch.rs | 312 ++- problemreductions-cli/src/main.rs | 26 +- problemreductions-cli/src/mcp/prompts.rs | 15 +- problemreductions-cli/src/mcp/tests.rs | 1091 +++++---- problemreductions-cli/src/mcp/tools.rs | 1233 ++-------- problemreductions-cli/src/problem_name.rs | 3 - problemreductions-cli/src/test_support.rs | 93 +- problemreductions-cli/src/util.rs | 228 -- problemreductions-cli/tests/cli_tests.rs | 2179 +++++++++++------ problemreductions-cli/tests/pred_sym_tests.rs | 33 +- 22 files changed, 4762 insertions(+), 10105 deletions(-) delete mode 100644 problemreductions-cli/src/commands/create/schema_semantics.rs create mode 100644 problemreductions-cli/src/create_args.rs diff --git a/problemreductions-cli/src/bin/pred_sym.rs b/problemreductions-cli/src/bin/pred_sym.rs index daa28bf7a..23fc71287 100644 --- a/problemreductions-cli/src/bin/pred_sym.rs +++ b/problemreductions-cli/src/bin/pred_sym.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use problemreductions::{big_o_normal_form, canonical_form, Expr, ProblemSize}; +use problemreductions::{big_o_normal_form, evaluate_approximate, Expr, ProblemSize}; #[derive(Parser)] #[command( @@ -19,11 +19,6 @@ enum Commands { /// Expression string expr: String, }, - /// Compute exact canonical form - Canon { - /// Expression string - expr: String, - }, /// Compute Big-O normal form BigO { /// Expression string @@ -33,7 +28,7 @@ enum Commands { #[arg(long)] raw: bool, }, - /// Compare two expressions (exits with code 1 if neither exact nor Big-O equal) + /// Compare two expressions for Big-O equivalence (exits 1 if not equal) Compare { /// First expression a: String, @@ -69,16 +64,6 @@ fn main() { let parsed = parse_expr_or_exit(&expr); println!("{parsed}"); } - Commands::Canon { expr } => { - let parsed = parse_expr_or_exit(&expr); - match canonical_form(&parsed) { - Ok(result) => println!("{result}"), - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } - } Commands::BigO { expr, raw } => { let parsed = parse_expr_or_exit(&expr); match big_o_normal_form(&parsed) { @@ -98,49 +83,49 @@ fn main() { Commands::Compare { a, b } => { let expr_a = parse_expr_or_exit(&a); let expr_b = parse_expr_or_exit(&b); - let canon_a = canonical_form(&expr_a); - let canon_b = canonical_form(&expr_b); let big_o_a = big_o_normal_form(&expr_a); let big_o_b = big_o_normal_form(&expr_b); println!("Expression A: {a}"); println!("Expression B: {b}"); - let mut exact_equal = false; - let mut big_o_equal = false; - if let (Ok(ca), Ok(cb)) = (&canon_a, &canon_b) { - exact_equal = ca == cb; - println!("Canonical A: {ca}"); - println!("Canonical B: {cb}"); - println!("Exact equal: {exact_equal}"); - } - if let (Ok(ba), Ok(bb)) = (&big_o_a, &big_o_b) { - big_o_equal = ba == bb; - println!("Big-O A: O({ba})"); - println!("Big-O B: O({bb})"); - println!("Big-O equal: {big_o_equal}"); - } - if !exact_equal && !big_o_equal { - std::process::exit(1); + match (&big_o_a, &big_o_b) { + (Ok(ba), Ok(bb)) => { + // Rendering is canonical, so equal growth ⇒ equal Big-O expr. + let big_o_equal = ba == bb; + println!("Big-O A: O({ba})"); + println!("Big-O B: O({bb})"); + println!("Big-O equal: {big_o_equal}"); + if !big_o_equal { + std::process::exit(1); + } + } + _ => { + if let Err(e) = &big_o_a { + println!("Big-O A: "); + } + if let Err(e) = &big_o_b { + println!("Big-O B: "); + } + std::process::exit(1); + } } } Commands::Eval { expr, vars } => { let parsed = parse_expr_or_exit(&expr); - let bindings: Vec<(&str, usize)> = vars + let bindings: Vec<(String, usize)> = vars .split(',') .filter_map(|pair| { let mut parts = pair.splitn(2, '='); let name = parts.next()?.trim(); let value: usize = parts.next()?.trim().parse().ok()?; - // Leak the name for &'static str compatibility - let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); - Some((leaked, value)) + Some((name.to_string(), value)) }) .collect(); // Check for unbound variables let expr_vars = parsed.variables(); let bound_vars: std::collections::HashSet<&str> = - bindings.iter().map(|(k, _)| *k).collect(); + bindings.iter().map(|(name, _)| name.as_str()).collect(); let mut unbound: Vec<&str> = expr_vars .iter() .filter(|v| !bound_vars.contains(*v)) @@ -156,8 +141,13 @@ fn main() { std::process::exit(1); } - let size = ProblemSize::new(bindings); - let result = parsed.eval(&size); + let size = ProblemSize { + components: bindings, + }; + let result = evaluate_approximate(&parsed, &size).unwrap_or_else(|error| { + eprintln!("Error: {error}"); + std::process::exit(1); + }); // Format as integer if it's a whole number if (result - result.round()).abs() < 1e-10 { diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 70fa1e5af..78c5c024e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,7 +1,10 @@ -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; -use std::collections::HashMap; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; +use problemreductions::registry::ProblemCategory; +use std::ffi::OsString; use std::path::PathBuf; +pub use crate::create_args::CreateArgs; + #[derive(Parser)] #[command( name = "pred", @@ -17,7 +20,7 @@ Piping (use - to read from stdin): pred create MIS --graph 0-1,1-2 | pred solve - # when an ILP reduction path exists pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 - pred create MIS --graph 0-1,1-2 | pred reduce - --to QUBO + pred create MIS --graph 0-1,1-2 | pred reduce - --via route.json JSON output (any command): pred list --json # JSON to stdout @@ -46,18 +49,65 @@ pub struct Cli { pub command: Commands, } +impl Cli { + pub fn try_parse() -> Result { + Self::try_parse_from(std::env::args_os()) + } + + pub fn try_parse_from(args: I) -> Result + where + I: IntoIterator, + T: Into, + { + // The discovery command treats the problem spec as an external subcommand, + // so it can capture the selected model without registering the whole catalog. + let args = args.into_iter().map(Into::into).collect::>(); + let command = ::command(); + let discovery_matches = command.clone().try_get_matches_from(args.clone())?; + let selected = discovery_matches + .subcommand_matches("create") + .and_then(|matches| matches.subcommand_name()); + + let mut matches = if let Some(selected) = selected { + crate::create_args::command_for_selected_problem(command, selected)? + .try_get_matches_from(args)? + } else { + discovery_matches + }; + Self::from_arg_matches_mut(&mut matches) + } +} + #[derive(Subcommand)] pub enum Commands { - /// List all registered problem types (or reduction rules with --rules) + /// Browse registered problem types (or reduction rules with --rules) #[command(after_help = "\ Examples: - pred list # list problem types - pred list --rules # list all reduction rules + pred list # show catalog summary and categories + pred list matching # search names and aliases + pred list --category graph # list graph problems + pred list --all # list every problem compactly + pred list --rules --all # list every reduction rule pred list -o problems.json # save as JSON")] List { + /// Case-insensitive substring to search in names and aliases + query: Option, + /// List reduction rules instead of problem types #[arg(long)] rules: bool, + + /// Restrict problems to a model category such as graph, set, or misc + #[arg(long, conflicts_with = "rules")] + category: Option, + + /// List the complete catalog instead of the summary + #[arg(long)] + all: bool, + + /// Include per-variant complexity, rule counts, or rule size contracts + #[arg(long)] + verbose: bool, }, /// Show details for a problem type or variant (fields, reductions, complexity) @@ -109,14 +159,13 @@ Use `pred to ` for incoming neighbors (what reduces to this).")] hops: usize, }, - /// Find the cheapest reduction path between two problems + /// Find reduction paths between two problems #[command(after_help = "\ Examples: - pred path MIS QUBO # cheapest path - pred path MIS QUBO --all # all paths - pred path MIS QUBO -o path.json # save for `pred reduce --via` - pred path MIS QUBO --all -o paths/ # save all paths to a folder - pred path MIS QUBO --cost minimize:num_variables + pred path MIS QUBO # inspect reduction paths + pred path MIS Clique mis.json # execute paths on an instance + pred path MIS QUBO --max-paths 50 # increase the output cap + pred path MIS QUBO -o paths.json # save the path set Use `pred list` to see available problems.")] Path { @@ -126,15 +175,11 @@ Use `pred list` to see available problems.")] /// Target problem (e.g., QUBO) #[arg(value_parser = crate::problem_name::ProblemNameParser)] target: String, - /// Cost function [default: minimize-steps] - #[arg(long, default_value = "minimize-steps")] - cost: String, - /// Show all paths instead of just the cheapest - #[arg(long)] - all: bool, - /// Maximum paths to return in --all mode + /// Maximum paths to return #[arg(long, default_value_t = 20)] max_paths: usize, + /// Source problem instance JSON. When present, execute every returned path and measure each constructed problem. + instance: Option, }, /// Export the reduction graph to JSON @@ -227,1008 +272,16 @@ pub enum ExampleSide { #[derive(clap::Args)] #[command(after_help = "\ -TIP: Run `pred create ` (no other flags) to see problem-specific help. - Not every flag applies to every problem — the above list shows ALL flags. - -Flags by problem type: - MIS, MVC, MaxClique, MinDomSet --graph, --weights - MaxCut, MaxMatching, TSP, BottleneckTravelingSalesman --graph, --edge-weights - LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex - HamiltonianPathBetweenTwoVertices --graph, --source-vertex, --target-vertex - ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --weight-bound - GraphPartitioning --graph, --num-partitions - MaximalIS --graph, --weights - SAT, NAESAT --num-vars, --clauses - KSAT --num-vars, --clauses [--k] - NonTautology --num-vars, --disjuncts - QUBO --matrix - SpinGlass --graph, --couplings, --fields - KColoring --graph, --k - KClique --graph, --k - DecisionMinimumVertexCover --graph, --weights, --bound - MinimumMultiwayCut --graph, --terminals, --edge-weights - MonochromaticTriangle --graph - PartitionIntoTriangles --graph - GeneralizedHex --graph, --source, --sink - IntegralFlowWithMultipliers --arcs, --capacities, --source, --sink, --multipliers, --requirement - MinimumEdgeCostFlow --arcs, --edge-weights (prices), --capacities, --source, --sink, --requirement - MinimumCostMaximumFlow --arcs, --capacities, --costs, --source, --sink - MinimumCostCirculation, MCC --arcs, --capacities, --costs - MinimumCutIntoBoundedSets --graph, --edge-weights, --source, --sink, --size-bound - HamiltonianCircuit, HC --graph - MaximumLeafSpanningTree --graph - LongestCircuit --graph, --edge-weights - BoundedComponentSpanningForest --graph, --weights, --k, --max-weight - UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement - IntegralFlowBundles --arcs, --bundles, --bundle-capacities, --source, --sink, --requirement [--num-vertices] - UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 - DisjointConnectingPaths --graph, --terminal-pairs - IntegralFlowHomologousArcs --arcs, --capacities, --source, --sink, --requirement, --homologous-pairs - IsomorphicSpanningTree --graph, --tree - KthBestSpanningTree --graph, --edge-weights, --k, --bound - LengthBoundedDisjointPaths --graph, --source, --sink, --max-length - PathConstrainedNetworkFlow --arcs, --capacities, --source, --sink, --paths, --requirement - Factoring --target, --m, --n - BinPacking --sizes, --capacity - Clustering --distance-matrix, --k, --diameter-bound - CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --cost-budget, --delay-budget - ProductionPlanning --num-periods, --demands, --capacities, --setup-costs, --production-costs, --inventory-costs, --cost-bound - SubsetProduct --sizes, --target - SubsetSum --sizes, --target - MinimumAxiomSet --n, --true-sentences, --implications - Numerical3DimensionalMatching --w-sizes, --x-sizes, --y-sizes, --bound - Betweenness --n, --sets (triples a,b,c) - CyclicOrdering --n, --sets (triples a,b,c) - ThreePartition --sizes, --bound - DynamicStorageAllocation --release-times, --deadlines, --sizes, --capacity - KthLargestMTuple --sets, --k, --bound - QuadraticCongruences --coeff-a, --coeff-b, --coeff-c - QuadraticDiophantineEquations --coeff-a, --coeff-b, --coeff-c - SimultaneousIncongruences --pairs (semicolon-separated a,b pairs) - SumOfSquaresPartition --sizes, --num-groups - ExpectedRetrievalCost --probabilities, --num-sectors - PaintShop --sequence - MaximumSetPacking --subsets [--weights] - MinimumHittingSet --universe-size, --subsets - MinimumSetCovering --universe-size, --subsets [--weights] - EnsembleComputation --universe-size, --subsets, --budget - ComparativeContainment --universe-size, --r-sets, --s-sets [--r-weights] [--s-weights] - X3C (ExactCoverBy3Sets) --universe-size, --subsets (3 elements each) - 3DM (ThreeDimensionalMatching) --universe-size, --subsets (triples w,x,y) - ThreeMatroidIntersection --universe-size, --partitions, --bound - SetBasis --universe-size, --subsets, --k - MinimumCardinalityKey --num-attributes, --dependencies - PrimeAttributeName --universe-size, --dependencies, --query-attribute - RootedTreeStorageAssignment --universe-size, --subsets, --bound - TwoDimensionalConsecutiveSets --alphabet-size, --subsets - BicliqueCover --left, --right, --biedges, --k - BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k - BiconnectivityAugmentation --graph, --potential-weights, --budget [--num-vertices] - PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices] - BMF --matrix (0/1), --rank - ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k - ConsecutiveOnesMatrixAugmentation --matrix (0/1), --bound - ConsecutiveOnesSubmatrix --matrix (0/1), --k - SparseMatrixCompression --matrix (0/1), --bound - MaximumLikelihoodRanking --matrix (i32 rows, semicolon-separated) - MinimumMatrixCover --matrix (i64 rows, semicolon-separated) - MinimumWeightDecoding --matrix (JSON 2D bool), --rhs (comma-separated booleans) - FeasibleBasisExtension --matrix (JSON 2D i64), --rhs, --required-columns - SteinerTree --graph, --edge-weights, --terminals - MultipleCopyFileAllocation --graph, --usage, --storage - AcyclicPartition --arcs [--weights] [--arc-weights] --weight-bound --cost-bound [--num-vertices] - CVP --basis, --target-vec [--bounds] - MultiprocessorScheduling --lengths, --num-processors, --deadline - SchedulingToMinimizeWeightedCompletionTime --lengths, --weights, --num-processors - SequencingWithinIntervals --release-times, --deadlines, --lengths - OptimalLinearArrangement --graph - RootedTreeArrangement --graph, --bound - MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k - MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-weights [--num-vertices] - RuralPostman (RPP) --graph, --edge-weights, --required-edges - StackerCrane --arcs, --graph, --arc-lengths, --edge-lengths [--num-vertices] - MultipleChoiceBranching --arcs [--weights] --partition --threshold [--num-vertices] - AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] - ConsistencyOfDatabaseFrequencyTables --num-objects, --attribute-domains, --frequency-tables [--known-values] - SubgraphIsomorphism --graph (host), --pattern (pattern) - GroupingBySwapping --string, --bound [--alphabet-size] - LCS --strings [--alphabet-size] - ClosestString --alphabet-size, --strings - ClosestSubstring --alphabet-size, --strings, --substring-length - FAS --arcs [--weights] [--num-vertices] - FVS --arcs [--weights] [--num-vertices] - QBF --num-vars, --clauses, --quantifiers - SteinerTreeInGraphs --graph, --edge-weights, --terminals - PartitionIntoPathsOfLength2 --graph - ResourceConstrainedScheduling --num-processors, --resource-bounds, --resource-requirements, --deadline - IntegerKnapsack --sizes, --values, --capacity - PartiallyOrderedKnapsack --sizes, --values, --capacity, --precedences - QAP --matrix (cost), --distance-matrix - StrongConnectivityAugmentation --arcs, --candidate-arcs, --bound [--num-vertices] - JobShopScheduling --jobs [--num-processors] - FlowShopScheduling --task-lengths, --deadline [--num-processors] - StaffScheduling --schedules, --requirements, --num-workers, --k - TimetableDesign --num-periods, --num-craftsmen, --num-tasks, --craftsman-avail, --task-avail, --requirements - MinimumTardinessSequencing --num-tasks, --deadlines [--precedences] - RectilinearPictureCompression --matrix (0/1), --k - SchedulingWithIndividualDeadlines --num-tasks, --num-processors/--m, --deadlines [--precedences] - SequencingToMinimizeMaximumCumulativeCost --costs [--precedences] - SequencingToMinimizeTardyTaskWeight --lengths, --weights, --deadlines - SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedences] - SequencingToMinimizeWeightedTardiness --lengths, --weights, --deadlines, --bound - SequencingWithDeadlinesAndSetUpTimes --lengths, --deadlines, --compilers, --setup-times - MinimumExternalMacroDataCompression --string, --pointer-cost [--alphabet-size] - MinimumInternalMacroDataCompression --string, --pointer-cost [--alphabet-size] - SCS --strings [--alphabet-size] - StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] - D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 - MinimumDummyActivitiesPert --arcs [--num-vertices] - FeasibleRegisterAssignment --arcs, --assignment, --k [--num-vertices] - MinimumFaultDetectionTestSet --arcs, --inputs, --outputs [--num-vertices] - MinimumWeightAndOrGraph --arcs, --source, --gate-types, --weights [--num-vertices] - MinimumCodeGenerationOneRegister --arcs [--num-vertices] - MinimumCodeGenerationParallelAssignments --num-variables, --assignments - MinimumCodeGenerationUnlimitedRegisters --left-arcs, --right-arcs [--num-vertices] - MinimumRegisterSufficiencyForLoops --loop-length, --loop-variables - RegisterSufficiency --arcs, --bound [--num-vertices] - CBQ --domain-size, --relations, --conjuncts-spec - IntegerExpressionMembership --expression (JSON), --target - MinimumGeometricConnectedDominatingSet --positions (float x,y pairs), --radius - MinimumDecisionTree --test-matrix (JSON 2D bool), --num-objects, --num-tests - MinimumDisjunctiveNormalForm (MinDNF) --num-vars, --truth-table - SquareTiling (WangTiling) --num-colors, --tiles, --grid-size - ILP, CircuitSAT (via reduction only) - -Geometry graph variants (use slash notation, e.g., MIS/KingsSubgraph): - KingsSubgraph, TriangularSubgraph --positions (integer x,y pairs) - UnitDiskGraph --positions (float x,y pairs) [--radius] - -Random generation: - --random --num-vertices N [--edge-prob 0.5] [--seed 42] - Examples: - pred create --example MIS/SimpleGraph/i32 - pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 - pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 --example-side target - pred create MIS --graph 0-1,1-2,2-3 --weights 1,1,1 - pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" - pred create NonTautology --num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\" - pred create QUBO --matrix \"1,0.5;0.5,2\" - pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12 - pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80 - pred create GeneralizedHex --graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5 - pred create IntegralFlowWithMultipliers --arcs \"0>1,0>2,1>3,2>3\" --capacities 1,1,2,2 --source 0 --sink 3 --multipliers 1,2,3,1 --requirement 2 - pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10 - pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 | pred solve - --solver brute-force - pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force - pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" - pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 - pred create MIS --random --num-vertices 10 --edge-prob 0.3 - pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10 - pred create SchedulingToMinimizeWeightedCompletionTime --lengths 1,2,3,4,5 --weights 6,4,3,2,1 --num-processors 2 - pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3 - pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" - pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-weights 0-2:3,0-3:4,1-3:2 --budget 5 - pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 - pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6 - pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1 - pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\" - pred create X3C --universe 9 --subsets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" - pred create SetBasis --universe 4 --subsets \"0,1;1,2;0,2;0,1,2\" --k 3 - pred create MinimumCardinalityKey --num-attributes 6 --dependencies \"0,1>2;0,2>3;1,3>4;2,4>5\" - pred create PrimeAttributeName --universe 6 --dependencies \"0,1>2,3,4,5;2,3>0,1,4,5\" --query-attribute 3 - pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --subsets \"0,1,2;3,4,5;1,3;2,4;0,5\"")] -pub struct CreateArgs { - /// Problem type (e.g., MIS, QUBO, SAT). Omit when using --example. - #[arg(value_parser = crate::problem_name::ProblemNameParser)] - pub problem: Option, - /// Build a problem from the canonical example database using a structural problem spec. - #[arg(long, value_parser = crate::problem_name::ProblemNameParser)] - pub example: Option, - /// Target problem spec for canonical rule example lookup. - #[arg(long = "to", value_parser = crate::problem_name::ProblemNameParser)] - pub example_target: Option, - /// Which side of a rule example to emit [default: source]. - #[arg(long, value_enum, default_value = "source")] - pub example_side: ExampleSide, - /// Graph edge list (e.g., 0-1,1-2,2-3) - #[arg(long)] - pub graph: Option, - /// Vertex weights (e.g., 1,1,1,1) [default: all 1s] - #[arg(long)] - pub weights: Option, - /// Edge weights (e.g., 2,3,1) [default: all 1s] - #[arg(long)] - pub edge_weights: Option, - /// Edge lengths (e.g., 2,3,1) [default: all 1s] - #[arg(long)] - pub edge_lengths: Option, - /// Capacities (edge capacities for flow problems, capacity levels for CapacityAssignment) - #[arg(long)] - pub capacities: Option, - /// Demands for ProductionPlanning (comma-separated, e.g., "5,3,7,2,8,5") - #[arg(long)] - pub demands: Option, - /// Setup costs for ProductionPlanning (comma-separated, e.g., "10,10,10,10,10,10") - #[arg(long)] - pub setup_costs: Option, - /// Per-unit production costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") - #[arg(long)] - pub production_costs: Option, - /// Per-unit inventory costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") - #[arg(long)] - pub inventory_costs: Option, - /// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1) - #[arg(long)] - pub bundle_capacities: Option, - /// Cost matrix for CapacityAssignment (semicolon-separated rows, e.g., "1,3,6;2,4,7") - #[arg(long)] - pub cost_matrix: Option, - /// Delay matrix for CapacityAssignment (semicolon-separated rows, e.g., "8,4,1;7,3,1") - #[arg(long)] - pub delay_matrix: Option, - /// Edge lower bounds for lower-bounded flow problems (e.g., 1,1,0,0,1,0,1) - #[arg(long)] - pub lower_bounds: Option, - /// Vertex multipliers in vertex order (e.g., 1,2,3,1) - #[arg(long)] - pub multipliers: Option, - /// Source vertex for path-based graph problems and MinimumCutIntoBoundedSets - #[arg(long)] - pub source: Option, - /// Sink vertex for path-based graph problems and MinimumCutIntoBoundedSets - #[arg(long)] - pub sink: Option, - /// Required total flow R for IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, PathConstrainedNetworkFlow, and UndirectedFlowLowerBounds - #[arg(long)] - pub requirement: Option, - /// Required number of paths for LengthBoundedDisjointPaths - #[arg(long)] - pub num_paths_required: Option, - /// Prescribed directed s-t paths as semicolon-separated arc-index sequences (e.g., "0,2,5;1,4,6") - #[arg(long)] - pub paths: Option, - /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] - #[arg(long)] - pub couplings: Option, - /// On-site fields h_i for SpinGlass (e.g., 0,0,1) [default: all 0s] - #[arg(long)] - pub fields: Option, - /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") - #[arg(long)] - pub clauses: Option, - /// Disjuncts for NonTautology (semicolon-separated, e.g., "1,2;-1,3") - #[arg(long)] - pub disjuncts: Option, - /// Number of variables (for SAT/KSAT) - #[arg(long)] - pub num_vars: Option, - /// Matrix input. QUBO uses semicolon-separated numeric rows ("1,0.5;0.5,2"); - /// ConsecutiveBlockMinimization uses a JSON 2D bool array ('[[true,false],[false,true]]') - #[arg(long)] - pub matrix: Option, - /// Shared integer parameter (use `pred create ` for the problem-specific meaning) - #[arg(long)] - pub k: Option, - /// Number of partitions for GraphPartitioning (currently must be 2) - #[arg(long)] - pub num_partitions: Option, - /// Generate a random instance (graph-based problems only) - #[arg(long)] - pub random: bool, - /// Number of vertices for random graph generation - #[arg(long)] - pub num_vertices: Option, - /// Source vertex for path problems - #[arg(long)] - pub source_vertex: Option, - /// Target vertex for path problems - #[arg(long)] - pub target_vertex: Option, - /// Edge probability for random graph generation (0.0 to 1.0) [default: 0.5] - #[arg(long)] - pub edge_prob: Option, - /// Random seed for reproducibility - #[arg(long)] - pub seed: Option, - /// Target value (for Factoring, SubsetSum, and SubsetProduct) - #[arg(long)] - pub target: Option, - /// Bits for first factor (for Factoring); also accepted as a processor-count alias for scheduling create commands - #[arg(long)] - pub m: Option, - /// Bits for second factor (for Factoring) - #[arg(long)] - pub n: Option, - /// Vertex positions for geometry-based graphs (semicolon-separated x,y pairs, e.g., "0,0;1,0;1,1") - #[arg(long)] - pub positions: Option, - /// Radius for UnitDiskGraph [default: 1.0] - #[arg(long)] - pub radius: Option, - /// Source vertex s_1 for commodity 1 - #[arg(long)] - pub source_1: Option, - /// Sink vertex t_1 for commodity 1 - #[arg(long)] - pub sink_1: Option, - /// Source vertex s_2 for commodity 2 - #[arg(long)] - pub source_2: Option, - /// Sink vertex t_2 for commodity 2 - #[arg(long)] - pub sink_2: Option, - /// Required flow R_1 for commodity 1 - #[arg(long)] - pub requirement_1: Option, - /// Required flow R_2 for commodity 2 - #[arg(long)] - pub requirement_2: Option, - /// Item sizes for BinPacking (comma-separated, e.g., "3,3,2,2") - #[arg(long)] - pub sizes: Option, - /// Record access probabilities for ExpectedRetrievalCost (comma-separated, e.g., "0.2,0.15,0.15,0.2,0.1,0.2") - #[arg(long)] - pub probabilities: Option, - /// Link lengths for MinimumDiscretePlanarInverseKinematics (comma-separated positive reals, e.g., "2.0,1.0") - #[arg(long)] - pub link_lengths: Option, - /// Target point (x,y) for MinimumDiscretePlanarInverseKinematics (e.g., "2.0,1.0") - #[arg(long)] - pub target_point: Option, - /// Sampled absolute orientations per link for MinimumDiscretePlanarInverseKinematics (semicolon-separated angle lists, e.g., "0.0,1.5707963267948966;0.0,1.5707963267948966") - #[arg(long)] - pub orientation_samples: Option, - /// Admissible (a_{j-1}, a_j) pair sets per junction for MinimumDiscretePlanarInverseKinematics (pipe-separated junctions, each comma-separated "i-j" pairs, e.g., "0-0,0-1,1-1") - #[arg(long)] - pub allowed_pairs: Option, - /// Source labelled digraph G1 for MaximumCommonEdgeSubgraph. Format: ":,,..." with each arc "-

types serialize as {inner: {graph, weights, ...}, bound} but schema - // fields are flat (graph, weights, bound). Restructure when the canonical name - // indicates a Decision wrapper. - let data = if canonical.starts_with("Decision") { - let bound = json_map - .remove("bound") - .expect("Decision types require a bound field"); - let mut outer = serde_json::Map::new(); - outer.insert("inner".to_string(), serde_json::Value::Object(json_map)); - outer.insert("bound".to_string(), bound); - serde_json::Value::Object(outer) - } else { - serde_json::Value::Object(json_map) - }; - validate_schema_driven_semantics(args, canonical, resolved_variant, &data) - .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; - (variant_entry.factory)(data.clone()).map_err(|error| { - with_schema_usage( +fn normalize_registered_input( + input: &problemreductions::registry::CreateInputInfo, + concrete_type: &str, + raw: &str, +) -> Result { + use problemreductions::registry::CreateInputCodec; + + let value = match input.codec { + CreateInputCodec::Json => serde_json::from_str(raw).map_err(|error| { anyhow::anyhow!( - "Schema-driven factory rejected generated data for {canonical}: {error}" - ), - canonical, - resolved_variant, - ) - })?; + "Invalid JSON for --{}: {error}", + input.name.replace('_', "-") + ) + })?, + CreateInputCodec::EdgeList | CreateInputCodec::BipartiteEdgeList => { + serde_json::to_value(util::parse_edge_pairs(raw)?)? + } + CreateInputCodec::ArcList => serde_json::to_value(parse_registered_arcs(raw)?)?, + CreateInputCodec::EqualityPairList => { + serde_json::to_value(parse_registered_equality_pairs(raw)?)? + } + CreateInputCodec::FunctionalDependencyList => { + serde_json::to_value(parse_registered_functional_dependencies(raw)?)? + } + CreateInputCodec::CharacterRows => { + serde_json::to_value(parse_registered_character_rows(raw))? + } + CreateInputCodec::Auto + | CreateInputCodec::Scalar + | CreateInputCodec::CommaSeparated + | CreateInputCodec::SemicolonSeparated => { + parse_field_value(concrete_type, input.name, raw, &CreateContext::default())? + } + }; + Ok(value) +} + +fn parse_registered_character_rows(raw: &str) -> Vec> { + let mut alphabet = BTreeMap::new(); + raw.split(';') + .map(|row| { + row.chars() + .map(|symbol| { + let next = alphabet.len(); + *alphabet.entry(symbol).or_insert(next) + }) + .collect() + }) + .collect() +} + +fn parse_registered_arcs(raw: &str) -> Result> { + raw.split(',') + .map(|arc| { + let (source, target) = arc.trim().split_once('>').ok_or_else(|| { + anyhow::anyhow!("Invalid arc '{}': expected format u>v", arc.trim()) + })?; + Ok((source.trim().parse()?, target.trim().parse()?)) + }) + .collect() +} + +fn parse_registered_equality_pairs(raw: &str) -> Result> { + raw.split(';') + .map(|pair| { + let (left, right) = pair.trim().split_once('=').ok_or_else(|| { + anyhow::anyhow!("Invalid pair '{}': expected format left=right", pair.trim()) + })?; + Ok((left.trim().parse()?, right.trim().parse()?)) + }) + .collect() +} - Ok(Some((data, resolved_variant.clone()))) +fn parse_registered_functional_dependencies(raw: &str) -> Result, Vec)>> { + raw.split(';') + .map(|dependency| { + let (left, right) = dependency.trim().split_once(':').ok_or_else(|| { + anyhow::anyhow!( + "Invalid functional dependency '{}': expected format lhs:rhs", + dependency.trim() + ) + })?; + Ok(( + util::parse_comma_list(left)?, + util::parse_comma_list(right)?, + )) + }) + .collect() } pub(super) fn missing_schema_field_error( @@ -221,211 +277,136 @@ pub(super) fn missing_schema_field_error( field_type: &str, is_geometry: bool, ) -> anyhow::Error { - let display = problem_help_flag_name(canonical, field_name, field_type, is_geometry); - let flags: Vec = display - .split('/') - .filter_map(|part| { - let trimmed = part.trim().trim_start_matches("--"); - (!trimmed.is_empty()).then(|| format!("--{trimmed}")) - }) - .collect(); - let requirement = match flags.as_slice() { - [] => format!("--{}", field_name.replace('_', "-")), - [flag] => flag.clone(), - [first, second] => format!("{first} or {second}"), - _ => { - let last = flags.last().cloned().unwrap_or_default(); - format!("{}, or {}", flags[..flags.len() - 1].join(", "), last) - } - }; + let flag = problem_help_flag_name(field_name, field_type, is_geometry); + let requirement = format!("--{flag}"); anyhow::anyhow!("{canonical} requires {requirement}") } pub(super) fn parse_schema_field_value( - args: &CreateArgs, - canonical: &str, concrete_type: &str, field_name: &str, raw: &str, context: &CreateContext, ) -> Result { - match (canonical, field_name) { - ("BoyceCoddNormalFormViolation", "functional_deps") => { - let num_attributes = args.n.ok_or_else(|| { - anyhow::anyhow!("BoyceCoddNormalFormViolation requires --n, --sets, and --target") - })?; - Ok(serde_json::to_value(parse_bcnf_functional_deps( - raw, - num_attributes, - )?)?) - } - ("BoundedComponentSpanningForest", "max_weight") => { - let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --max-weight 6"; - let bound_raw = args.bound.ok_or_else(|| { - anyhow::anyhow!("BoundedComponentSpanningForest requires --max-weight\n\n{usage}") - })?; - let max_weight = i32::try_from(bound_raw).map_err(|_| { - anyhow::anyhow!( - "BoundedComponentSpanningForest requires --max-weight within i32 range\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_weight)) - } - ("ConsecutiveBlockMinimization", "matrix") => { - let usage = "Usage: pred create ConsecutiveBlockMinimization --matrix '[[true,false,true],[false,true,true]]' --bound-k 2"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "ConsecutiveBlockMinimization requires --matrix as a JSON 2D bool array (e.g., '[[true,false,true],[false,true,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("FeasibleBasisExtension", "matrix") => { - let usage = "Usage: pred create FeasibleBasisExtension --matrix '[[1,0,1],[0,1,0]]' --rhs '7,5' --required-columns '0'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "FeasibleBasisExtension requires --matrix as a JSON 2D integer array (e.g., '[[1,0,1],[0,1,0]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("IntegralFlowBundles", "bundle_capacities") => { - let usage = "Usage: pred create IntegralFlowBundles --arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4"; - let arcs_str = args - .arcs - .as_deref() - .ok_or_else(|| anyhow::anyhow!("IntegralFlowBundles requires --arcs\n\n{usage}"))?; - let (_, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices) - .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - let bundles = parse_bundles(args, num_arcs, usage)?; - Ok(serde_json::to_value(parse_bundle_capacities( - args, - bundles.len(), - usage, - )?)?) - } - ("IntegralFlowHomologousArcs", "homologous_pairs") => { - Ok(serde_json::to_value(parse_homologous_pairs(args)?)?) - } - ("LengthBoundedDisjointPaths", "max_length") => { - let usage = "Usage: pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 --source 0 --sink 6 --max-length 3"; - let bound = args.bound.ok_or_else(|| { - anyhow::anyhow!("LengthBoundedDisjointPaths requires --max-length\n\n{usage}") - })?; - let max_length = usize::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "--max-length must be a nonnegative integer for LengthBoundedDisjointPaths\n\n{usage}" - ) - })?; - Ok(serde_json::json!(max_length)) - } - ("LongestCommonSubsequence", "strings") => { - let (strings, _) = parse_lcs_strings(raw)?; - Ok(serde_json::to_value(strings)?) - } - ("MinimumDecisionTree", "test_matrix") => { - let usage = "Usage: pred create MinimumDecisionTree --test-matrix '[[true,true,false,false],[true,false,false,false],[false,true,false,true]]' --num-objects 4 --num-tests 3"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumDecisionTree requires --test-matrix as a JSON 2D bool array\n\n{usage}\n\nFailed to parse --test-matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightDecoding", "matrix") => { - let usage = "Usage: pred create MinimumWeightDecoding --matrix '[[true,false,true],[false,true,true]]' --rhs 'true,true'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightDecoding requires --matrix as a JSON 2D bool array (e.g., '[[true,false],[false,true]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("MinimumWeightSolutionToLinearEquations", "matrix") => { - let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix '[[1,2,3,1],[2,1,1,3]]' --rhs '5,4'"; - let matrix: Vec> = serde_json::from_str(raw).map_err(|err| { - anyhow::anyhow!( - "MinimumWeightSolutionToLinearEquations requires --matrix as a JSON 2D integer array (e.g., '[[1,2,3],[4,5,6]]')\n\n{usage}\n\nFailed to parse --matrix: {err}" - ) - })?; - Ok(serde_json::to_value(matrix)?) - } - ("GroupingBySwapping", "string") - | ("StringToStringCorrection", "source") - | ("StringToStringCorrection", "target") => { - Ok(serde_json::to_value(parse_symbol_list_allow_empty(raw)?)?) + parse_field_value(concrete_type, field_name, raw, context) +} + +pub(crate) fn create_inputs_for( + canonical: &str, + resolved_variant: &BTreeMap, +) -> Vec { + let variant_entry = + problemreductions::registry::find_variant_entry(canonical, resolved_variant) + .unwrap_or_else(|| { + panic!("missing registered variant for `{canonical}` with {resolved_variant:?}") + }); + let mut inputs = BTreeMap::::new(); + + if let Some(custom_inputs) = variant_entry.create_inputs { + for input in custom_inputs { + let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); + insert_create_input( + &mut inputs, + &input.name.replace('_', "-"), + input_value_kind(&concrete_type), + input.name, + ); } - ("MultipleCopyFileAllocation", "usage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.usage.as_deref(), - "usage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) + } else { + let schema = problemreductions::registry::find_problem_type(canonical) + .unwrap_or_else(|| panic!("missing schema for `{canonical}`")); + let graph_type = resolved_graph_type(resolved_variant); + let is_geometry = matches!( + graph_type, + "KingsSubgraph" | "TriangularSubgraph" | "UnitDiskGraph" + ); + for field in schema.fields { + let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); + match concrete_type.as_str() { + "DirectedGraph" => { + insert_create_input(&mut inputs, "arcs", InputValueKind::Text, field.name); + } + _ => { + let name = problem_help_flag_name(field.name, field.type_name, is_geometry); + insert_create_input( + &mut inputs, + &name, + input_value_kind(&concrete_type), + field.name, + ); + } + } } - ("MultipleCopyFileAllocation", "storage") => { - let (_, num_vertices) = parse_graph(args) - .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; - Ok(serde_json::to_value(parse_vertex_i64_values( - args.storage.as_deref(), - "storage", - num_vertices, - "MultipleCopyFileAllocation", - MULTIPLE_COPY_FILE_ALLOCATION_USAGE, - )?)?) + if schema.fields.iter().any(|field| { + let concrete_type = resolve_schema_field_type(field.type_name, resolved_variant); + matches!(concrete_type.as_str(), "SimpleGraph" | "DirectedGraph") + }) { + insert_create_input( + &mut inputs, + "num-vertices", + InputValueKind::Usize, + "graph vertex count", + ); } - ("SequencingToMinimizeMaximumCumulativeCost", "precedences") => { - Ok(serde_json::to_value(parse_precedence_pairs( - args.precedences - .as_deref() - .or(args.precedence_pairs.as_deref()), - )?)?) + if graph_type == "UnitDiskGraph" { + insert_create_input( + &mut inputs, + "radius", + InputValueKind::F64, + "unit-disk graph radius", + ); } - ("UndirectedTwoCommodityIntegralFlow", "capacities") => { - let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; - let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; - Ok(serde_json::to_value(parse_capacities( - args, - graph.num_edges(), - usage, - )?)?) + } + if let Some(random) = variant_entry.random { + insert_create_input( + &mut inputs, + "random", + InputValueKind::Bool, + "random generation", + ); + for input in random.inputs { + let concrete_type = resolve_schema_field_type(input.type_name, resolved_variant); + insert_create_input( + &mut inputs, + &input.name.replace('_', "-"), + input_value_kind(&concrete_type), + input.name, + ); } - _ => parse_field_value(concrete_type, field_name, raw, context), } -} -pub(super) fn schema_driven_supported_problem(canonical: &str) -> bool { - canonical != "ILP" && canonical != "CircuitSAT" + inputs + .into_iter() + .map(|(name, (kind, _))| CreateInput { name, kind }) + .collect() } -pub(super) fn schema_field_flag_keys( - canonical: &str, - field_name: &str, - field_type: &str, - is_geometry: bool, -) -> Vec { - let mut keys = vec![field_name.replace('_', "-")]; - for display_key in problem_help_flag_name(canonical, field_name, field_type, is_geometry) - .split('/') - .map(|key| key.trim().trim_start_matches("--").to_string()) - .filter(|key| !key.is_empty()) - { - if !keys.contains(&display_key) { - keys.push(display_key); - } +fn insert_create_input( + inputs: &mut BTreeMap, + name: &str, + kind: InputValueKind, + source: &str, +) { + if let Some((existing_kind, existing_source)) = inputs.get(name) { + assert_eq!( + *existing_kind, kind, + "create input --{name} has conflicting types from `{existing_source}` and `{source}`" + ); + return; } - keys + inputs.insert(name.to_string(), (kind, source.to_string())); } -pub(super) fn get_schema_flag_value( - flag_map: &std::collections::HashMap<&'static str, Option>, - keys: &[String], -) -> Option { - keys.iter() - .find_map(|key| flag_map.get(key.as_str()).cloned().flatten()) +fn input_value_kind(concrete_type: &str) -> InputValueKind { + match normalize_type_name(concrete_type).as_str() { + "usize" => InputValueKind::Usize, + "u64" => InputValueKind::U64, + "i32" => InputValueKind::I32, + "i64" => InputValueKind::I64, + "f64" => InputValueKind::F64, + "bool" => InputValueKind::Bool, + _ => InputValueKind::Text, + } } pub(super) fn resolve_schema_field_type( @@ -456,7 +437,7 @@ pub(super) fn resolve_schema_field_type( pub(super) fn weight_sum_type(weight_type: &str) -> &'static str { match weight_type { - "One" | "i32" => "i32", + "One" | "i32" => "i64", "f64" => "f64", _ => "i32", } @@ -467,277 +448,15 @@ pub(super) fn seed_schema_context_from_cli( graph_type: &str, context: &mut CreateContext, ) -> Result<()> { - if let Some(num_vertices) = args.num_vertices { + if let Some(num_vertices) = args.value::("num-vertices") { context.seed_field("num_vertices", num_vertices)?; } if graph_type == "UnitDiskGraph" { - context.seed_field("radius", args.radius.unwrap_or(1.0))?; + context.seed_field("radius", args.value::("radius").unwrap_or(1.0))?; } Ok(()) } -pub(super) fn derive_schema_field_value( - args: &CreateArgs, - canonical: &str, - field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - if let Some(defaulted) = - derive_schema_default_value(canonical, field_name, concrete_type, context)? - { - return Ok(Some(defaulted)); - } - - if field_name == "graph" && concrete_type == "MixedGraph" { - let usage = format!( - "Usage: pred create {canonical} {}", - example_for(canonical, None) - ); - return Ok(Some(serde_json::to_value(parse_mixed_graph( - args, &usage, - )?)?)); - } - - if field_name == "graph" && concrete_type == "BipartiteGraph" { - let left = args - .left - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --left"))?; - let right = args - .right - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --right"))?; - let edges_raw = args - .biedges - .as_deref() - .ok_or_else(|| anyhow::anyhow!("{canonical} requires --biedges"))?; - let edges = util::parse_edge_pairs(edges_raw)?; - validate_bipartite_edges(canonical, left, right, &edges)?; - return Ok(Some(serde_json::to_value(BipartiteGraph::new( - left, right, edges, - ))?)); - } - - if canonical == "ClosestVectorProblem" - && field_name == "bounds" - && normalize_type_name(concrete_type) == "Vec" - { - return Ok(Some(parse_cvp_bounds_value( - args.bounds.as_deref(), - context, - )?)); - } - - if canonical == "ConjunctiveBooleanQuery" - && field_name == "num_variables" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .conjuncts_spec - .as_deref() - .ok_or_else(|| anyhow::anyhow!("ConjunctiveBooleanQuery requires --conjuncts-spec"))?; - return Ok(Some(serde_json::json!(infer_cbq_num_variables(raw)?))); - } - - if canonical == "GroupingBySwapping" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .string - .as_deref() - .ok_or_else(|| anyhow::anyhow!("GroupingBySwapping requires --string"))?; - let string = parse_symbol_list_allow_empty(raw)?; - let inferred = string.iter().copied().max().map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } - - if canonical == "JobShopScheduling" - && field_name == "num_processors" - && normalize_type_name(concrete_type) == "usize" - { - let usage = "Usage: pred create JobShopScheduling --jobs \"0:3,1:4;1:2,0:3,1:2;0:4,1:3\" --num-processors 2"; - let inferred_processors = match args.job_tasks.as_deref() { - Some(job_tasks) => { - let jobs = parse_job_shop_jobs(job_tasks)?; - jobs.iter() - .flat_map(|job| job.iter().map(|(processor, _)| *processor)) - .max() - .map(|processor| processor + 1) - } - None => None, - }; - let num_processors = - resolve_processor_count_flags("JobShopScheduling", usage, args.num_processors, args.m)? - .or(inferred_processors) - .ok_or_else(|| { - anyhow::anyhow!( - "Cannot infer num_processors from empty job list; use --num-processors" - ) - })?; - return Ok(Some(serde_json::json!(num_processors))); - } - - if canonical == "LongestCommonSubsequence" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let raw = args - .strings - .as_deref() - .ok_or_else(|| anyhow::anyhow!("LongestCommonSubsequence requires --strings"))?; - let (_, inferred_alphabet_size) = parse_lcs_strings(raw)?; - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred_alphabet_size)))); - } - - if canonical == "LongestCommonSubsequence" - && field_name == "max_length" - && normalize_type_name(concrete_type) == "usize" - { - let strings: Vec> = - serde_json::from_value(context.parsed_fields.get("strings").cloned().ok_or_else( - || anyhow::anyhow!("LCS max_length derivation requires parsed strings"), - )?)?; - let max_length = strings.iter().map(Vec::len).min().unwrap_or(0); - return Ok(Some(serde_json::json!(max_length))); - } - - if canonical == "QUBO" - && field_name == "num_vars" - && normalize_type_name(concrete_type) == "usize" - { - let matrix = parse_matrix(args)?; - return Ok(Some(serde_json::json!(matrix.len()))); - } - - if canonical == "StringToStringCorrection" - && field_name == "alphabet_size" - && normalize_type_name(concrete_type) == "usize" - { - let source = parse_symbol_list_allow_empty(args.source_string.as_deref().unwrap_or(""))?; - let target = parse_symbol_list_allow_empty(args.target_string.as_deref().unwrap_or(""))?; - let inferred = source - .iter() - .chain(target.iter()) - .copied() - .max() - .map_or(0, |value| value + 1); - return Ok(Some(serde_json::json!(args - .alphabet_size - .unwrap_or(inferred)))); - } - - if field_name == "precedences" - && normalize_type_name(concrete_type) == "Vec<(usize,usize)>" - && args.precedences.is_none() - && args.precedence_pairs.is_none() - { - return Ok(Some(serde_json::json!([]))); - } - - if canonical == "ComparativeContainment" - && matches!(field_name, "r_weights" | "s_weights") - && matches!( - normalize_type_name(concrete_type).as_str(), - "Vec" | "Vec" | "Vec" - ) - { - let sets_len = context - .parsed_fields - .get(match field_name { - "r_weights" => "r_sets", - _ => "s_sets", - }) - .and_then(serde_json::Value::as_array) - .map(Vec::len); - if let Some(len) = sets_len { - let value = match normalize_type_name(concrete_type).as_str() { - "Vec" | "Vec" => serde_json::json!(vec![1_i32; len]), - "Vec" => serde_json::json!(vec![1.0_f64; len]), - _ => unreachable!(), - }; - return Ok(Some(value)); - } - } - - if canonical == "ConsistencyOfDatabaseFrequencyTables" - && field_name == "known_values" - && normalize_type_name(concrete_type) == "Vec" - && args.known_values.is_none() - { - return Ok(Some(serde_json::json!([]))); - } - - if canonical == "LengthBoundedDisjointPaths" - && field_name == "max_paths" - && normalize_type_name(concrete_type) == "usize" - { - let graph_value = context.parsed_fields.get("graph").cloned(); - let source = context.usize_field("source"); - let sink = context.usize_field("sink"); - if let (Some(graph_value), Some(source), Some(sink)) = (graph_value, source, sink) { - let graph: SimpleGraph = - serde_json::from_value(graph_value).context("Failed to deserialize graph")?; - let max_paths = graph - .neighbors(source) - .len() - .min(graph.neighbors(sink).len()); - return Ok(Some(serde_json::json!(max_paths))); - } - } - - Ok(None) -} - -pub(super) fn derive_schema_default_value( - canonical: &str, - field_name: &str, - concrete_type: &str, - context: &CreateContext, -) -> Result> { - let normalized = normalize_type_name(concrete_type); - - let one_list = |len: usize| match normalized.as_str() { - "Vec" | "Vec" => Some(serde_json::json!(vec![1_i32; len])), - "Vec" => Some(serde_json::json!(vec![1_u64; len])), - "Vec" => Some(serde_json::json!(vec![1_i64; len])), - "Vec" => Some(serde_json::json!(vec![1_usize; len])), - "Vec" => Some(serde_json::json!(vec![1.0_f64; len])), - _ => None, - }; - - let derived = match field_name { - "weights" | "vertex_weights" => context.num_vertices.and_then(one_list), - "edge_weights" | "edge_lengths" => context.num_edges.and_then(one_list), - "arc_weights" | "arc_lengths" if context.num_arcs.is_some() => { - context.num_arcs.and_then(one_list) - } - "capacities" if canonical == "PathConstrainedNetworkFlow" => { - context.num_arcs.and_then(one_list) - } - "couplings" if canonical == "SpinGlass" => context.num_edges.and_then(one_list), - "fields" if canonical == "SpinGlass" => match normalized.as_str() { - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0_i32; len])), - "Vec" => context - .num_vertices - .map(|len| serde_json::json!(vec![0.0_f64; len])), - _ => None, - }, - _ => None, - }; - - Ok(derived) -} - -pub(super) fn schema_field_requires_derived_input(field_name: &str, concrete_type: &str) -> bool { - field_name == "graph" && matches!(concrete_type, "MixedGraph" | "BipartiteGraph") -} - pub(super) fn with_schema_usage( error: anyhow::Error, canonical: &str, @@ -747,11 +466,38 @@ pub(super) fn with_schema_usage( if message.contains("Usage: pred create") { return error; } - let graph_type = resolved_variant.get("graph").map(String::as_str); - anyhow::anyhow!( - "{message}\n\nUsage: pred create {canonical} {}", - example_for(canonical, graph_type) - ) + let flags = create_inputs_for(canonical, resolved_variant) + .into_iter() + .map(|input| { + if input.kind == InputValueKind::Bool { + format!("[--{}]", input.name) + } else { + format!("--{} ", input.name) + } + }) + .collect::>() + .join(" "); + anyhow::anyhow!("{message}\n\nUsage: pred create {canonical} {flags}",) +} + +pub(super) fn with_registered_usage( + error: anyhow::Error, + canonical: &str, + inputs: &[problemreductions::registry::CreateInputInfo], +) -> anyhow::Error { + let flags = inputs + .iter() + .map(|input| { + let flag = format!("--{} ", input.name.replace('_', "-")); + if input.required { + flag + } else { + format!("[{flag}]") + } + }) + .collect::>() + .join(" "); + anyhow::anyhow!("{error}\n\nUsage: pred create {canonical} {flags}") } pub(super) fn parse_field_value( @@ -802,6 +548,7 @@ pub(super) fn parse_field_value( "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, + "Vec<(i32,i32)>" => serde_json::to_value(util::parse_positions::(raw, "0,0")?)?, "(f64,f64)" => parse_f64_pair_value(raw)?, "Vec>" => parse_nested_pair_list_value(raw)?, "Vec" => { @@ -993,31 +740,6 @@ pub(super) fn parse_nested_pair_list_value(raw: &str) -> Result Result { - let mut num_vars = 0usize; - for conjunct in raw.split(';').filter(|entry| !entry.trim().is_empty()) { - let (_, args_str) = conjunct.trim().split_once(':').ok_or_else(|| { - anyhow::anyhow!( - "Invalid conjunct format: expected 'rel_idx:args', got '{}'", - conjunct.trim() - ) - })?; - for arg in args_str - .split(',') - .map(str::trim) - .filter(|arg| !arg.is_empty()) - { - if let Some(rest) = arg.strip_prefix('v') { - let index: usize = rest - .parse() - .map_err(|err| anyhow::anyhow!("Invalid variable index '{rest}': {err}"))?; - num_vars = num_vars.max(index + 1); - } - } - } - Ok(num_vars) -} - pub(super) fn parse_cbq_relations(raw: &str, context: &CreateContext) -> Result> { let domain_size = context.usize_field("domain_size").ok_or_else(|| { anyhow::anyhow!("CBQ relation parsing requires a prior domain_size field") @@ -1245,91 +967,6 @@ pub(super) fn parse_string_list_value(raw: &str) -> Result { Ok(serde_json::to_value(values)?) } -pub(super) fn parse_symbol_list_allow_empty(raw: &str) -> Result> { - let raw = raw.trim(); - if raw.is_empty() { - return Ok(Vec::new()); - } - raw.split(',') - .map(|value| { - value - .trim() - .parse::() - .context("invalid symbol index") - }) - .collect() -} - -pub(super) fn parse_lcs_strings(raw: &str) -> Result<(Vec>, usize)> { - let segments: Vec<&str> = raw.split(';').map(str::trim).collect(); - let comma_mode = segments.iter().any(|segment| segment.contains(',')); - - if comma_mode { - let strings = segments - .iter() - .map(|segment| parse_symbol_list_allow_empty(segment)) - .collect::>>()?; - let inferred_alphabet_size = strings - .iter() - .flat_map(|string| string.iter()) - .copied() - .max() - .map(|value| value + 1) - .unwrap_or(0); - return Ok((strings, inferred_alphabet_size)); - } - - let mut encoding = BTreeMap::new(); - let mut next_symbol = 0usize; - let strings = segments - .iter() - .map(|segment| { - segment - .as_bytes() - .iter() - .map(|byte| { - let entry = encoding.entry(*byte).or_insert_with(|| { - let current = next_symbol; - next_symbol += 1; - current - }); - *entry - }) - .collect::>() - }) - .collect::>(); - Ok((strings, next_symbol)) -} - -pub(super) fn parse_bcnf_functional_deps( - raw: &str, - num_attributes: usize, -) -> Result, Vec)>> { - raw.split(';') - .map(|fd_str| { - let parts: Vec<&str> = fd_str.split(':').collect(); - anyhow::ensure!( - parts.len() == 2, - "Each FD must be lhs:rhs, got '{}'", - fd_str - ); - let lhs: Vec = util::parse_comma_list(parts[0])?; - let rhs: Vec = util::parse_comma_list(parts[1])?; - ensure_attribute_indices_in_range( - &lhs, - num_attributes, - &format!("Functional dependency '{fd_str}' lhs"), - )?; - ensure_attribute_indices_in_range( - &rhs, - num_attributes, - &format!("Functional dependency '{fd_str}' rhs"), - )?; - Ok((lhs, rhs)) - }) - .collect() -} - pub(super) fn parse_cdft_frequency_tables_value( raw: &str, context: &CreateContext, @@ -1580,1116 +1217,20 @@ pub(super) fn parse_unit_disk_graph_value( Ok(serde_json::to_value(UnitDiskGraph::new(positions, radius))?) } -pub(super) fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { - match type_name { - "SimpleGraph" => "edge list: 0-1,1-2,2-3", - "G" => match graph_type { - Some("KingsSubgraph" | "TriangularSubgraph") => "integer positions: \"0,0;1,0;1,1\"", - Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"", - _ => "edge list: 0-1,1-2,2-3", - }, - "Vec<(Vec, Vec)>" => "semicolon-separated dependencies: \"0,1>2;0,2>3\"", - "Vec" => "comma-separated integers: 4,5,3,2,6", - "Vec" => "comma-separated: 1,2,3", - "W" | "N" | "W::Sum" | "N::Sum" => "numeric value: 10", - "Vec" => "comma-separated indices: 0,2,4", - "Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => { - "comma-separated weighted edges: 0-2:3,1-3:5" - } - "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", - "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", - "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", - "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", - "usize" => "integer", - "u64" => "integer", - "i64" => "integer", - "BigUint" => "nonnegative decimal integer", - "Vec" => "comma-separated nonnegative decimal integers: 3,7,1,8", - "Vec" => "comma-separated integers: 3,7,1,8", - "DirectedGraph" => "directed arcs: 0>1,1>2,2>0", - "LabelledDigraph" => { - "labelled digraph \":-

` 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()` - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows +- Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. +- Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. - CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, `Or(true)`, or `Sum(56)`) - Graph types: SimpleGraph, PlanarGraph, BipartiteGraph, UnitDiskGraph, KingsSubgraph, TriangularSubgraph - Weight types: `One` (unit weight marker), `i32`, `f64` — all implement `WeightElement` trait @@ -165,10 +167,10 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - Weight management via inherent methods (`weights()`, `set_weights()`, `is_weighted()`), not traits - `NumericSize` supertrait bundles common numeric bounds (`Clone + Default + PartialOrd + Num + Zero + Bounded + AddAssign + 'static`) -### Overhead System -Reduction overhead is expressed using `Expr` AST (in `src/expr.rs`) with the `#[reduction]` macro. The `overhead` attribute is **required** — omitting it is a compile error: +### Size Relations +Each reduction declares one rule-level size relation using the `Expr` AST in `src/expr.rs`. The `size` declaration is required: ```rust -#[reduction(overhead = { +#[reduction(size = upper_bound { num_vertices = "num_vertices + num_clauses", num_edges = "3 * num_clauses", })] @@ -177,9 +179,10 @@ impl ReduceTo for Source { ... } - Expression strings are parsed at compile time by a Pratt parser in the proc macro crate - Variable names are validated against actual getter methods on the source type — typos cause compile errors - Each problem type provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) that the overhead expressions reference -- **Overhead expressions describe scaling (asymptotic upper bounds), not exact sizes.** To determine the actual target problem size for a specific instance, read the `reduce_to()` construction code and count the actual variables/constraints/vertices built. -- `ReductionOverhead` stores `Vec<(&'static str, Expr)>` — field name to symbolic expression mappings -- `ReductionEntry` has both symbolic (`overhead_fn`) and compiled (`overhead_eval_fn`) evaluation — the compiled version calls getters directly +- Use `size = exact { ... }` when every formula is an equality and `size = upper_bound { ... }` when every formula is only an upper bound. One rule cannot mix relations. +- Use `size = unavailable { ... }` when no formula is representable, or an auxiliary `unavailable = { ... }` block for omitted target fields. +- `SizeTransform` evaluates and composes formulas with exact rational and arbitrary-precision integer arithmetic. It never performs budget pruning or Pareto ranking. +- Concrete instance sizes are measured independently by compiled endpoint getters on `ReductionEntry`, including target fields on sink variants. - `VariantEntry` has both a complexity string and compiled `complexity_eval_fn` — same pattern - Expressions support: constants, variables, `+`, `-`, `*`, `/`, `^`, `exp()`, `log()`, `sqrt()`, `factorial()` - Complexity strings must use **concrete numeric values only** (e.g., `"2^(2.372 * num_vertices / 3)"`, not `"2^(omega * num_vertices / 3)"`) @@ -197,25 +200,42 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Nodes come exclusively from `#[reduction]` registrations; natural edges between same-name variants are inferred from the graph/weight subtype partial order - Each primitive reduction is determined by the exact `(source_variant, target_variant)` endpoint pair - Reduction edges carry `EdgeCapabilities { witness, aggregate, turing }`; graph search defaults to witness mode, aggregate mode is available through `ReductionMode::Aggregate`, and Turing (multi-query) mode via `ReductionMode::Turing` -- `#[reduction]` accepts only `overhead = { ... }` and currently registers witness/config reductions; aggregate-only and Turing edges require manual `ReductionEntry` registration +- `#[reduction]` requires one `size = exact`, `size = upper_bound`, or `size = unavailable` declaration and currently registers witness/config reductions; aggregate-only and Turing edges require manual `ReductionEntry` registration - `Decision

→ P` is an aggregate-only edge (solve optimization, compare to bound); `P → Decision

` is a Turing edge (binary search over decision bound) ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI -- **CLI creation is schema-driven:** `pred create` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. New models need only: (1) matching CLI flags in `CreateArgs` + `flag_map()`, and (2) type parser support in `parse_field_value()` if using a new field type. No match arm in `create.rs` is needed. -- **CLI flag names must match schema field names.** The canonical name for a CLI flag is the schema field name in kebab-case (e.g., schema field `universe_size` → `--universe-size`, field `subsets` → `--subsets`). Old aliases (e.g., `--universe`, `--sets`) may exist as clap `alias` for backward compatibility at the clap level, but `flag_map()`, help text, error messages, and documentation must use the schema-derived name. Do not add new backward-compat aliases; if a field is renamed in the schema, update the CLI flag name to match. -- **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. Schema-driven CLI creation auto-restructures flat JSON into `{inner: {...}, bound}`. +- **Model category is explicit registry metadata.** Every `ProblemSchemaEntry` declares exactly one of `Algebraic`, `Formula`, `Graph`, `Misc`, or `Set`; catalog behavior never derives it from `module_path!()` or source location. +- **CLI creation is registry-driven and two-stage:** the static parser discovers the requested problem spec without registering model subcommands, then a second parse adds flags only for the selected concrete variant. Ordinary models use `ProblemSchemaEntry.fields` directly. Models whose construction differs from persisted JSON own a typed `CreateSpec` and fallible conversion beside the model; CLI and MCP only normalize transport values and invoke the registered constructor. +- **Each construction input has one name and one concrete type per variant.** Do not add compatibility aliases or infer types from flag names. `CreateSpec` field names render as `snake_case → kebab-case` in CLI and remain `snake_case` in MCP. Add a reusable codec only for a genuinely new transport representation, never a model-name parser branch. +- **Random generation is optional and variant-owned.** Not every model has a useful, well-defined random-instance distribution. Add `RandomGenerate` only when the generator has clear semantics and a concrete use (for example, testing or examples); never invent arbitrary bounds or distributions merely to make every model support `--random`. Implement it beside the model (normally through `impl_random_generate!` and a typed `CreateSpec` input DTO), then add `random` only to the applicable `declare_variants!` entries. CLI and MCP discover the exact variant's inputs and callback; never add a model-name random dispatch or advertise random generation on an unsupported variant. +- **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. The generated construction spec accepts flat inner fields plus `bound`; persisted JSON remains `{inner: {...}, bound}`. - Aggregate-only models are first-class in `declare_variants!`; aggregate-only and Turing reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers witness/config reductions today - Exact registry dispatch lives in `src/registry/`; alias resolution and partial/default variant resolution live in `problemreductions-cli/src/problem_name.rs` - `pred create` schema-driven dispatch lives in `problemreductions-cli/src/commands/create.rs` (`create_schema_driven()`) -- Canonical paper and CLI examples live in `src/example_db/model_builders.rs` and `src/example_db/rule_builders.rs` +- Canonical model examples live in `src/example_db/model_builders.rs`; rule examples live beside their rules and are collected by `src/rules/mod.rs` ## Conventions +### Numeric Contract + +Follow the [numeric types and arithmetic standard](../docs/src/design.md#numeric-types-and-arithmetic) +for every model and reduction. Before implementation, identify each numeric +input and domain, each computed total and result type, the largest supported +value, every range/sign-changing conversion, overflow behavior, and whether +arithmetic is exact or approximate. Use `TryFrom` at range boundaries and +checked arithmetic for derived values that may overflow. Rust construction, +serde, CLI, and MCP must enforce the same range. + +Issue contributors provide the mathematical definition, domains, and +constraints; implementers derive the Rust representation. Do not require issue +authors to choose implementation types or add implementation-specific numeric +fields to issue templates. Changes to issue templates require user approval. + ### File Naming - Reduction files: `src/rules/_.rs` (e.g., `maximumindependentset_qubo.rs`) - Model files: `src/models//.rs` — category is by input structure: `graph/` (graph input), `formula/` (boolean formula/circuit), `set/` (universe + subsets), `algebraic/` (matrix/linear system/lattice), `misc/` (other) -- Canonical examples: builder functions in `src/example_db/rule_builders.rs` and `src/example_db/model_builders.rs` +- Canonical examples: model builders in `src/example_db/model_builders.rs`; rule-local `canonical_rule_example_specs()` functions collected by `src/rules/mod.rs` - Example binaries in `examples/`: utility/export tools and pedagogical demos only (not per-reduction files) - Test naming: `test__to__closed_loop` @@ -261,7 +281,7 @@ Model review automation checks for a dedicated test file under `src/unit_tests/m - `.claude/` — Claude Code instructions and skills - `docs/book/` — mdBook user documentation (built with `make doc`) - `docs/paper/reductions.typ` — Typst paper with problem definitions and reduction theorems -- `src/example_db/` — Canonical model/rule examples: `model_builders.rs`, `rule_builders.rs` (in-memory builders), `specs.rs` (per-module invariant specs), consumed by `pred create --example` and paper exports +- `src/example_db/` — Model builders, shared example specs, and rule-example aggregation consumed by `pred create --example` and paper exports - `examples/` — Export utilities, graph-analysis helpers, and pedagogical demos ## Documentation Requirements @@ -309,8 +329,8 @@ The complexity string represents the **worst-case time complexity of the best kn 5. Use only concrete numeric values — no symbolic constants (epsilon, omega); inline the actual numbers with citations 6. Variable names must match getter methods on the problem type (enforced at compile time) -### Reduction Overhead (`#[reduction(overhead = {...})]`) -Overhead expressions describe how target problem size relates to source problem size. To verify correctness: +### Reduction Size Relation (`#[reduction(size = exact|upper_bound {...})]`) +Size expressions describe how target problem size relates to source problem size. To verify correctness: 1. Read the `reduce_to()` implementation and count the actual output sizes 2. Check that each field (e.g., `num_vertices`, `num_edges`, `num_sets`) matches the constructed target problem 3. Watch for common errors: universe elements mismatch (edge indices vs vertex indices), worst-case edge counts in intersection graphs (quadratic, not linear), constant factors in circuit constructions diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 54f4c2292..87e472566 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -68,14 +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: -- `ProblemSchemaEntry` metadata is complete for the current schema shape (`display_name`, `aliases`, `dimensions`, and constructor-facing `fields`) +- 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 (`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 current registry schema shape, including `display_name`, `aliases`, `dimensions`, and constructor-facing `fields` +- **Schema metadata:** `ProblemSchemaEntry` must include the explicit structural `category` and reflect the construction interface through `display_name`, `aliases`, `dimensions`, and `fields` - **Objective problems:** use `type Value = Max<_>`, `Min<_>`, or `Extremum<_>` when the model should expose optimization-style witness helpers - **Witness problems:** use `type Value = Or` for existential feasibility problems - **Aggregate-only problems:** use a value-only aggregate such as `Sum<_>`, `And`, or a custom `Aggregate` when witnesses are not meaningful @@ -168,22 +170,32 @@ The CLI now loads, serializes, and brute-force solves problems through the core 1. **Registry-backed dispatch comes from `declare_variants!`:** - Make sure every concrete variant you want the CLI to load is listed in `declare_variants!` - Mark the intended default variant with `default` when applicable + - Declare well-established problem aliases in `ProblemSchemaEntry.aliases` and variant-specific aliases in `declare_variants!`; CLI and MCP discover both from the registry + +## Step 4.5: Add construction support + +CLI and MCP construction are registry-driven. Do not edit either frontend to recognize a model name. + +1. If user-facing inputs exactly match persisted JSON fields, do nothing. The ordinary `declare_variants!` entry uses `ProblemSchemaEntry.fields` as required construction inputs and deserializes the model directly. + +2. If construction has derived fields, renamed inputs, defaults depending on other inputs, or a composite value assembled from multiple inputs, define a model-local DTO with `#[derive(Deserialize, CreateSpec)]`. Its named fields are the complete public construction contract. Use `Option` only for genuinely optional inputs, doc comments for help text, and `#[create(codec = "...")]` when the transport syntax cannot be inferred from the Rust type. Set `ProblemSchemaEntry.fields` to `LocalCreateSpec::FIELDS` so the catalog and executable constructor share the derived metadata. -2. **`problemreductions-cli/src/problem_name.rs`:** - - Add a lowercase alias mapping in `resolve_alias()` (e.g., `"newproblem" => "NewProblem".to_string()`) - - Only add short aliases to the `ALIASES` array if the abbreviation is **well-established in the literature** (e.g., MIS, MVC, SAT, TSP, CVP are standard; "KS" for Knapsack or "BP" for BinPacking are NOT — do not invent new abbreviations) +3. Implement `TryFrom for Model`. Validate before calling constructors that assert or panic, return a descriptive error, compute derived state there, and build the canonical model value. -## Step 4.5: Add CLI creation support +4. Register the spec on each applicable variant: `default Model => "..." create LocalCreateSpec`. Both frontends then discover the inputs automatically and serialize the constructed typed model back to canonical persisted JSON. -CLI creation is **schema-driven** — `pred create ` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. No match arm in `create.rs` is needed. +5. A new reusable external syntax may add one transport codec. It must dispatch by codec/type, never by canonical model name. Unknown or missing inputs are rejected by the core construction contract. -1. **Ensure CLI flags exist** in `problemreductions-cli/src/cli.rs` (`CreateArgs` struct) for each field in your `ProblemSchemaEntry`. The flag name must match the field name via `snake_case → kebab-case` (e.g., field `edge_weights` → flag `--edge-weights`). If a flag already exists with the right name, you're done. +### Optional random generation -2. **Add new CLI flags** only if the problem needs flags not already present. Add them to `CreateArgs` and update `all_data_flags_empty()` accordingly. Also add entries to the `flag_map()` method on `CreateArgs`. +Random generation is an optional model capability, not a model-completeness requirement. Many models do not have a natural or useful probability distribution over instances; leave random generation unregistered for those models. Do not invent arbitrary size limits, value ranges, or distributions merely to make `--random` available. -3. **Add type parser support** if the field uses a type not yet handled by `parse_field_value()` in `create.rs`. Check the existing type dispatch table — most standard types (`Vec`, `Vec`, `Vec<(usize, usize)>`, graph types, etc.) are already covered. Only add a new parser for genuinely new types. +When the model does have a well-defined generator with a concrete testing or example use, random generation is registry-driven and belongs beside the model. Do not edit CLI or MCP dispatch code. -4. **Schema alignment**: The `ProblemSchemaEntry` fields should list **constructor parameters** (what the user provides), not internal derived fields. For example, if `m` and `n` are derived from a matrix, only list `matrix` and `k` in the schema. Field names must match the struct field names exactly (used for JSON serialization and CLI flag mapping). +1. Define a typed random input DTO with `#[derive(Deserialize, CreateSpec)]`, or reuse a matching shared spec from `crate::random`. +2. Implement `RandomGenerate` with `crate::impl_random_generate!(ConcreteModel, RandomSpec, |spec| { ... })`. Validate values and return `Result`; do not round, clamp, or silently replace invalid inputs. +3. Add `random` only to the exact `declare_variants!` entries that implement the trait: `default Model => "..." create LocalCreateSpec random`. +4. The generated problem must have the same canonical name and variant as the selected registry entry. Use the concrete variant's actual graph and numeric types instead of attaching requested metadata to a different concrete instance. ## Step 4.6: Add canonical model example to example_db @@ -303,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` | @@ -310,12 +323,12 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Forgetting `declare_variants!` | Required for variant complexity metadata and registry-backed load/serialize/solve dispatch | | Wrong aggregate wrapper | Use `Max` / `Min` / `Extremum` for objective problems, `Or` for existential witness problems, and `Sum` / `And` (or a custom aggregate) for value-only folds | | Wrong `declare_variants!` syntax | Entries no longer use `opt` / `sat`; one entry per problem may be marked `default` | -| Forgetting CLI alias | Must add lowercase entry in `problem_name.rs` `resolve_alias()` | +| Adding aliases in CLI code | Declare problem aliases in `ProblemSchemaEntry.aliases` and variant aliases in `declare_variants!` | | Adding a hand-written decision model | Use `Decision

` wrapper instead — see `decision_problem_meta!` + `register_decision_variant!` in `src/models/graph/minimum_vertex_cover.rs` for the pattern | | Inventing short aliases | Only use well-established literature abbreviations (MIS, SAT, TSP); do NOT invent new ones | -| Forgetting CLI flags | Schema-driven create needs matching CLI flags in `CreateArgs` for each `ProblemSchemaEntry` field (snake_case → kebab-case). Also add to `flag_map()`. | -| Missing type parser | If the problem uses a new field type, add a handler in `parse_field_value()` in `create.rs` | -| Schema lists derived fields | Schema should list constructor params, not internal fields (e.g., `matrix, k` not `matrix, m, n, k`) | +| Adding frontend model-name branches | Construction is model-owned. Use a local `CreateSpec` and register it with `declare_variants!`; CLI and MCP must discover it. | +| Hand-maintaining custom construction fields twice | Derive `CreateSpec`, use `LocalCreateSpec::FIELDS` in `ProblemSchemaEntry`, and register the same type in `declare_variants!`. | +| Calling a panicking constructor from `TryFrom` | Validate the spec first and return a descriptive conversion error. | | Missing canonical model example | Add a builder in `src/example_db/model_builders.rs` and keep it aligned with paper/example workflows | | Paper example not tested | Must include `test__paper_example` that verifies the exact instance, solution, and solution count shown in the paper | | Claiming direct ILP solving but leaving ` -> ILP` for later | If the issue promises a direct ILP path, implement that rule in the same PR with exact overhead metadata and production-level ILP tests | diff --git a/.claude/skills/add-rule/SKILL.md b/.claude/skills/add-rule/SKILL.md index 33a303af6..c9862a188 100644 --- a/.claude/skills/add-rule/SKILL.md +++ b/.claude/skills/add-rule/SKILL.md @@ -56,6 +56,16 @@ grep "type Value = " src/models/*/.rs src/models/*/.rs If incompatible, STOP and comment on the issue explaining the type mismatch and options. Do NOT proceed. +## Numeric Safety Gate + +Read `docs/src/design.md#numeric-types-and-arithmetic`. Derive implementation +types, supported ranges, and checked conversions from the mathematical source, +target, and reduction algorithm. Ask the contributor only when a mathematical +domain or constraint is ambiguous; do not ask them to choose Rust types. Do not +use `as` for range/sign changes. Check target-size arithmetic and auxiliary +identifiers before constructing the target, verify serde/CLI uses the same +ranges, and add focused boundary tests. + ## Reference Implementations Read these first to understand the patterns: @@ -106,13 +116,19 @@ impl ReductionResult for ReductionXToY { type Source = SourceType; type Target = TargetType; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Map target solution back to source solution - // If Step 1 ran: translate the verified Python extract_solution() logic + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let source_solution = /* translate the verified mathematical mapping exactly */; + Ok(source_solution) } } ``` +Every direct extractor must call `validate_target_solution()` once before decoding. It checks only length and value domains, not feasibility, optimality, or rule-specific structure; reject malformed structure with `ExtractionError`. + **ReduceTo with `#[reduction]` macro** (overhead is **required**): ```rust #[reduction(overhead = { @@ -156,6 +172,8 @@ Additional recommended tests: - Edge cases (empty graph, single vertex, etc.) - Weight preservation (if applicable) +Test every malformed representation distinguished by the decoder (for example, zero or multiple one-hot selections, or duplicate permutation entries). The canonical example supplies shared wrong-length and out-of-domain tests. + For aggregate-only reductions, replace the closed-loop witness test with value-chain tests: - Solve the target with `Solver::solve()` - Map the aggregate value back with `extract_value()` @@ -163,9 +181,9 @@ For aggregate-only reductions, replace the closed-loop witness test with value-c Link via `#[cfg(test)] #[path = "..."] mod tests;` at the bottom of the rule file. -## Step 5: Add canonical example to example_db +## Step 5: Add canonical example -Add a builder function in `src/example_db/rule_builders.rs` that constructs a small, canonical instance for this reduction. Follow the existing patterns in that file. Register the builder in `build_rule_examples()`. +Define `canonical_rule_example_specs()` in the rule module and include it from `src/rules/mod.rs::canonical_rule_example_specs()`. This enrolls the rule in shared round-trip, wrong-length, and out-of-domain extraction tests. ## Step 6: Document in paper (MANDATORY — DO NOT SKIP) @@ -231,11 +249,11 @@ Checklist: notation self-contained, complexity cited, overhead consistent, examp ```bash cargo run --example export_graph # Generate reduction_graph.json for docs/paper builds cargo run --example export_schemas # Generate problem schemas for docs/paper builds -make regenerate-fixtures # Regenerate example_db/fixtures/examples.json (slow, needs ILP) +cargo run --features "example-db" --example export_examples make test clippy # Must pass ``` -`make regenerate-fixtures` is required so the paper can load the new rule's example data from `src/example_db/fixtures/examples.json`. Without it, the `reduction-rule` entry in Step 6 will reference missing fixture data. +`export_examples` refreshes the gitignored `docs/paper/data/examples.json` used by the paper. Structural and quality review is handled by the `review-pipeline` stage, not here. The run stage just needs to produce working code. @@ -249,7 +267,9 @@ 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. Aggregate-only reductions currently have a narrower CLI surface: - `pred solve ` can still compute direct aggregate values for aggregate-only problems @@ -261,7 +281,7 @@ Aggregate-only reductions currently have a narrower CLI surface: - Rule file: `src/rules/_.rs` -- no underscores within a problem name - e.g., `maximumindependentset_qubo.rs`, `minimumvertexcover_maximumindependentset.rs` - Test file: `src/unit_tests/rules/_.rs` -- Canonical example: builder function in `src/example_db/rule_builders.rs` +- Canonical example: `canonical_rule_example_specs()` in the rule module, included from `src/rules/mod.rs` ## Common Mistakes @@ -272,9 +292,10 @@ Aggregate-only reductions currently have a narrower CLI surface: | Wrong overhead expression | Must accurately reflect the size relationship | | Adding extra reduction metadata or duplicate primitive endpoint registration | Keep one primitive registration per endpoint pair and use only the `overhead` form of `#[reduction]` | | Missing `extract_solution` mapping state | Store any index maps needed in the ReductionResult struct | -| Not adding canonical example to `example_db` | Add builder in `src/example_db/rule_builders.rs` | +| Permissive extraction | Validate first, then map exactly or return `ExtractionError` | +| 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 5 (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 | +| 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 `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/final-review/SKILL.md b/.claude/skills/final-review/SKILL.md index 633d86ad8..ced146b7d 100644 --- a/.claude/skills/final-review/SKILL.md +++ b/.claude/skills/final-review/SKILL.md @@ -168,12 +168,12 @@ Use `AskUserQuestion` with your recommendation: Scan the PR diff for dangerous actions: -- **Blacklisted files**: If the diff touches `docs/src/reductions/reduction_graph.json`, `docs/src/reductions/problem_schemas.json`, or `src/example_db/fixtures/examples.json` (legacy, no longer exists), **block merge**. These files are auto-generated and must not be committed in PRs — they are rebuilt by CI/`make doc`/`make paper`. Flag immediately and recommend OnHold. +- **Blacklisted files**: If the diff touches `docs/src/reductions/reduction_graph.json` or `docs/src/reductions/problem_schemas.json`, **block merge**. These files are auto-generated and must not be committed in PRs — they are rebuilt by CI/`make doc`/`make paper`. Flag immediately and recommend OnHold. - **Removed features**: Any existing model, rule, test, or example deleted? - **Unrelated changes**: Files modified that don't belong to this PR (e.g., changes to unrelated models/rules, CI config, Cargo.toml dependency changes not needed for this PR) - **Force push indicators**: Any sign of history rewriting - **Broad modifications**: Changes to core traits, macros, or shared infrastructure that could affect other features -- **No committed `examples.json`**: The example database is generated on demand by `make paper` (via `export_examples`). PRs should not commit `src/example_db/fixtures/examples.json` (legacy path, deleted) or `docs/paper/data/examples.json` (current output path) — both are gitignored build artifacts. +- **No committed `examples.json`**: The example database is generated on demand by `make paper` (via `export_examples`). Do not commit the gitignored `docs/paper/data/examples.json` build artifact. Report findings with fix options for each concern: 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/issue-to-pr/SKILL.md b/.claude/skills/issue-to-pr/SKILL.md index 2573bedd5..87734784e 100644 --- a/.claude/skills/issue-to-pr/SKILL.md +++ b/.claude/skills/issue-to-pr/SKILL.md @@ -92,12 +92,12 @@ Write implementation plan to `docs/plans/YYYY-MM-DD-.md` using `superpower The plan MUST reference the appropriate implementation skill and follow its steps: - **For ordinary `[Model]` issues:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 as the action pipeline -- **For `[Model]` issues that explicitly claim direct ILP solving:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 **and** [add-rule](../add-rule/SKILL.md) Steps 1-6 for the direct ` -> ILP` rule in the same plan / PR +- **For `[Model]` issues that explicitly claim direct ILP solving:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 **and** [add-rule](../add-rule/SKILL.md) Steps 1-7 for the direct ` -> ILP` rule in the same plan / PR - **For `[Rule]` issues:** Follow [add-rule](../add-rule/SKILL.md) Steps 1-7 as the action pipeline. By default, `/add-rule` runs mathematical verification (Step 1) before implementation. If `--no-verify` was passed, include `--no-verify` when invoking `/add-rule` to skip verification. Include the concrete details from the issue (problem definition, reduction algorithm, example, etc.) mapped onto each step. -**Plan batching:** The paper writing step (add-model Step 6 / add-rule Step 5) MUST be in a **separate batch** from the implementation steps, so it gets its own subagent with fresh context. It depends on the implementation being complete (needs exports). Example batch structure for a `[Model]` plan: +**Plan batching:** The paper writing step (add-model Step 6 / add-rule Step 6) MUST be in a **separate batch** from the implementation steps, so it gets its own subagent with fresh context. It depends on the implementation being complete (needs exports). Example batch structure for a `[Model]` plan: - Batch 1: Steps 1-5.5 (implement model, register, CLI, tests) - Batch 2: Step 6 (write paper entry — depends on batch 1 for exports) @@ -112,8 +112,8 @@ For a `[Model]` issue with an explicit direct ILP claim, use: - Otherwise, ensure the information provided is enough to implement a solver. **Example rules:** -- Implement the user-provided example instance in the canonical `example_db` path for the issue (`src/example_db/model_builders.rs` or `src/example_db/rule_builders.rs`, as appropriate). -- Run the relevant export and fixture regeneration steps; verify the generated example data against the user-provided information. +- Implement the user-provided example in `src/example_db/model_builders.rs` for a model, or in the rule-local `canonical_rule_example_specs()` for a rule. +- Run the relevant exports and verify the generated example data against the user-provided information. - Present in `docs/paper/reductions.typ` in tutorial style with clear intuition (see KColoring->QUBO section for reference). ### 6. Create PR (or Resume Existing) diff --git a/.claude/skills/review-paper/SKILL.md b/.claude/skills/review-paper/SKILL.md index d1c98cdf6..e5a8e2378 100644 --- a/.claude/skills/review-paper/SKILL.md +++ b/.claude/skills/review-paper/SKILL.md @@ -46,7 +46,7 @@ For each of the 10 entries, read the full entry text and evaluate against the ch | M3. Self-contained notation | Every symbol in `def` is defined before first use | | M4. Background text | Body contains at least 2 sentences of background/motivation | | M5. Example present | Body contains `*Example.*` or `Example.` | -| M6. Example from fixture | Example data matches `src/example_db/fixtures/examples.json` (not invented) — check by loading the JSON and comparing | +| M6. Example from fixture | Example data matches `docs/paper/data/examples.json` (not invented) — check by loading the JSON and comparing | | M7. Figure present | Body contains `#figure(` | | M8. Pred commands | Body contains `pred-commands(` or `pred create` | | M9. Algorithm citation | Complexity claims have `@citation` or a footnote explaining absence | @@ -72,7 +72,7 @@ For each of the 10 entries, read the full entry text and evaluate against the ch | M3. Proof length | Proof is at least 3 sentences (not just "trivial" or a one-liner) | | M4. Overhead documented | Overhead is auto-generated from JSON (verify edge exists in `reduction_graph.json`) | | M5. Example present | `example: true` and example renders correctly | -| M6. Example from fixture | Example data matches `src/example_db/fixtures/examples.json` | +| M6. Example from fixture | Example data matches `docs/paper/data/examples.json` | | M7. Pred commands | Example section contains `pred-commands(` with create/reduce/evaluate pipeline | | M8. Both directions | If the reverse rule also exists in the graph, check it has its own entry | 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 5c30e15b6..85b7d55c8 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -61,11 +61,12 @@ 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")` | +| 17 | Numeric contract | Derive the expected representation from the mathematical definition, then compare schema types, Rust fields, aggregate/total type, constructor and serde validation, conversions, overflow behavior, and boundary tests against `docs/src/design.md#numeric-types-and-arithmetic` | ### Rule Checklist @@ -81,16 +82,17 @@ Only run if review type includes "rule". Given: source `S`, target `T`, rule fil | 6 | Test file exists | `Glob("src/unit_tests/rules/{R}.rs")` | | 7 | Closed-loop test present | `Grep("fn test_.*closed_loop\|fn test_.*to_.*basic", test_file)` | | 8 | Registered in `rules/mod.rs` | `Grep("mod {R}", "src/rules/mod.rs")` | -| 9 | Canonical rule example registered | `Grep("{S}|{T}|{R}", "src/example_db/rule_builders.rs")` | +| 9 | Canonical rule example registered | `Grep("canonical_rule_example_specs", rule file)` and verify it is included by `src/rules/mod.rs` | | 10 | Example-db lookup tests exist | `Grep("find_rule_example|build_rule_db", "src/unit_tests/example_db.rs")` | | 11 | Paper `reduction-rule` entry | `Grep('reduction-rule.*"{S}".*"{T}"', "docs/paper/reductions.typ")` | +| 12 | Extraction contract | Direct decoders call `validate_target_solution()`, enforce rule-specific structure, and test malformed cases; the helper does not establish feasibility or optimality. Composed extractors may delegate. | +| 13 | Numeric contract | Compare source/target types, size arithmetic, coefficients, bounds, auxiliary IDs, conversions, overflow behavior, and boundary tests against `docs/src/design.md#numeric-types-and-arithmetic` | ## Step 2b: Blacklisted File Check Scan the PR's changed files for auto-generated files that must never be committed: - `docs/src/reductions/reduction_graph.json` - `docs/src/reductions/problem_schemas.json` -- `src/example_db/fixtures/examples.json` (legacy path, deleted on main) - `docs/paper/data/examples.json` (current output path, gitignored) If any of these files appear in the diff, report **FAIL — blacklisted auto-generated file committed**. These files are rebuilt by CI/`make doc`/`make paper` and must not be in PRs. @@ -111,12 +113,14 @@ Report pass/fail. If tests fail, identify which tests. **Do NOT fix anything** 2. **`dims()` correctness** — Does it return the actual configuration space? (e.g., `vec![2; n]` for binary) 3. **Size getter consistency** — Do inherent getter methods (e.g., `num_vertices()`, `num_edges()`) match names used in overhead expressions? 4. **Weight handling** — Are weights managed via inherent methods, not traits? +5. **Numeric safety** — Are element and total types distinct where required, do serde and constructors enforce the same range, and are overflow and non-finite values rejected explicitly? ### For Rules: -1. **`extract_solution` correctness** — Does it correctly invert the reduction? Does the returned solution have the right length (source dimensions)? +1. **`extract_solution` correctness** — Does it implement the mathematical inverse? Is every branch either a defined mathematical case or an `ExtractionError`, with no defaulting, truncation, clamping, panic, or recovery? 2. **Overhead accuracy** — Does `overhead = { field = "expr" }` reflect the actual size relationship? 3. **Example quality** — Is it tutorial-style? Does the JSON export include both source and target data? 4. **Paper quality** — Is the reduction-rule statement precise? Is the proof sketch sound? +5. **Numeric safety** — Are target sizes and auxiliary IDs checked before construction, with no unchecked narrowing or exact-to-`f64` shortcut? ## Step 5: Issue Compliance Review 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/.claude/skills/verify-reduction/SKILL.md b/.claude/skills/verify-reduction/SKILL.md index 76f1246f6..ad101fc7e 100644 --- a/.claude/skills/verify-reduction/SKILL.md +++ b/.claude/skills/verify-reduction/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-reduction -description: Standalone mathematical verification of a reduction rule — generates Typst proof, constructor Python script (>=5000 checks), and adversary Python script (>=5000 independent checks). Reports verdict. No artifacts saved. +description: Standalone mathematical verification of a reduction rule — generates a Typst proof plus constructor and independent adversary scripts with at least 5000 checks each. Reports a verdict without saving artifacts. --- # Verify Reduction @@ -36,22 +36,61 @@ pred show --json ### Type compatibility gate — MANDATORY -Check source/target `Value` types before any work: +Check source/target `Value` types before any work. The `grep` only locates the definitions; it does +not resolve generic parameters or associated types: ```bash grep "type Value = " src/models/*/.rs src/models/*/.rs ``` +Resolve both concrete types completely before declaring compatibility: + +1. Substitute every concrete generic argument from the proposed rule. +2. Follow every type alias and associated type to its defining `impl`. +3. Record the substitution chain and the source file evidence in the verification report. +4. If any generic or associated type remains unresolved, run a compile-backed temporary Rust probe + using `std::any::type_name::<::Value>()`. Build the probe from `/tmp` + with a path dependency on this repository; do not modify the repository. + +Never infer a Rust value type from the mathematical problem name, from unit-weight terminology, or +from the Python verifier's integer representation. In particular, arbitrary-precision Python +integers do not establish that a Rust objective type is `usize` or that it is closed under all +legal source instances. + +Required report format: + +```text +TYPE RESOLUTION: + Source syntax: Min + Substitutions: W = One; ::Sum = i32 + Source resolved: Min + Target syntax: Min + Target resolved: Min + Full-domain compatibility: FAILED +``` + **Compatible pairs for `ReduceTo` (witness-capable):** -- `Or`->`Or`, `Min`->`Min`, `Max`->`Max` (same type) +- `Or`->`Or` +- `Min`->`Min`, `Max`->`Max` (identical resolved inner type) - `Or`->`Min`, `Or`->`Max` (feasibility embeds into optimization) +`Min`->`Min` or `Max`->`Max` with `S != T` is not automatically compatible. Proceed +only if the rule or source model declares a bound covering every legal source instance and the +verification proves a total, order-preserving conversion over that full declared domain. Otherwise +STOP and report a value-domain mismatch. + **Incompatible — STOP if any of these:** - `Min`->`Or` or `Max`->`Or` — optimization source has no threshold K; needs a decision-variant source model - `Max`->`Min` or `Min`->`Max` — opposite optimization directions; needs `ReduceToAggregate` or a decision-variant wrapper - `Or`->`Sum` or `Min`->`Sum` — Sum is aggregate-only; needs `ReduceToAggregate` - Any pair involving `And` or `Sum` on the target side +**Regression case:** `MinimumDominatingSet` resolves to `Min` because +`::Sum = i32`; `MinimumHittingSet` resolves to `Min`. Report +`Min -> Min`, not `Min -> Min`. Without an explicit source-size bound, +the full-domain type gate fails even though the classical cardinality reduction is mathematically +correct and exhaustive small-instance checks pass. + If incompatible, STOP and report the type mismatch and options. Do NOT proceed. ### If compatible @@ -199,6 +238,10 @@ Every item must be YES. If any is NO, go back and fix. - [ ] Zero hand-waving language - [ ] Zero scratch work +### Type gate +- [ ] Concrete Rust `Value` types fully resolved with substitution evidence +- [ ] Different numeric domains either rejected or covered by an explicit full-domain range proof + ### Constructor Python - [ ] 0 failures, >=5,000 total checks - [ ] All 7 sections present and non-empty diff --git a/.claude/skills/write-model-in-paper/SKILL.md b/.claude/skills/write-model-in-paper/SKILL.md index 9ded09507..e3c95d4dd 100644 --- a/.claude/skills/write-model-in-paper/SKILL.md +++ b/.claude/skills/write-model-in-paper/SKILL.md @@ -126,16 +126,16 @@ achieves $O^*(2^n)$ @bjorklund2009. ### 3c. Example with Visualization -A concrete small instance that illustrates the problem. **The example must use data from the checked-in canonical fixture DB**, not an independently invented instance. +A concrete small instance that illustrates the problem. **Use the generated canonical example data**, not an independently invented instance. #### Sourcing example data -1. If you changed example builders/specs, run `make regenerate-fixtures` to refresh `src/example_db/fixtures/examples.json`. -2. Find the problem's entry in `src/example_db/fixtures/examples.json` under `models` — it contains the canonical `instance`, `samples`, and `optimal` fields. +1. If you changed example builders/specs, run `cargo run --features "example-db" --example export_examples`. +2. Find the problem's entry in `docs/paper/data/examples.json` under `models` — it contains the canonical `instance`, `samples`, and `optimal` fields. 3. Use the values from `instance` in the paper example (translating 0-indexed code values to 1-indexed math notation where conventional, e.g., vertices {0,...,n-1} → {1,...,n}). 4. Use `optimal` configurations to show the solution. -**Do not invent a different instance.** If the canonical example is too large or not pedagogically ideal, fix it in `canonical_model_example_specs()` first, re-run `make regenerate-fixtures`, then write the paper entry from the updated JSON. +**Do not invent a different instance.** If the canonical example is unsuitable, fix it in `canonical_model_example_specs()`, re-run `export_examples`, then use the updated JSON. #### Requirements @@ -206,7 +206,7 @@ make paper - [ ] **Notation self-contained**: every symbol in `def` is defined before first use - [ ] **Background present**: historical context, applications, or structural properties - [ ] **Algorithms cited**: every complexity claim has `@citation` or footnote warning -- [ ] **Example from JSON**: instance data matches `src/example_db/fixtures/examples.json` canonical example (not independently invented) +- [ ] **Example from JSON**: instance data matches the canonical entry in `docs/paper/data/examples.json` - [ ] **Evaluation shown**: objective/verifier computed on the example solution - [ ] **Diagram included**: figure with caption and label for graph/matrix/set visualization - [ ] **Paper compiles**: `make paper` succeeds without errors diff --git a/.claude/skills/write-rule-in-paper/SKILL.md b/.claude/skills/write-rule-in-paper/SKILL.md index 0d9755b1b..b1bda1d9c 100644 --- a/.claude/skills/write-rule-in-paper/SKILL.md +++ b/.claude/skills/write-rule-in-paper/SKILL.md @@ -7,7 +7,7 @@ description: Use when writing or improving a reduction-rule entry in the Typst p Full authoring guide for writing a `reduction-rule` entry in `docs/paper/reductions.typ`. Covers Typst mechanics, writing quality, and verification. -> **Note:** This content is also inlined in `add-rule` Step 5 (condensed form). This standalone version has more detail and is useful for improving existing entries. +> **Note:** This content is also inlined in `add-rule` Step 6 (condensed form). This standalone version has more detail and is useful for improving existing entries. ## Reference Example @@ -17,8 +17,8 @@ Full authoring guide for writing a `reduction-rule` entry in `docs/paper/reducti Before using this skill, ensure: - The reduction is implemented and tested (`src/rules/_.rs`) -- A canonical example exists in `src/example_db/rule_builders.rs` -- If the canonical example changed, fixtures are regenerated (`make regenerate-fixtures`) +- A rule-local `canonical_rule_example_specs()` exists and is included by `src/rules/mod.rs` +- If the canonical example changed, regenerate the paper data with `cargo run --features "example-db" --example export_examples` - The reduction graph and schemas are up to date (`cargo run --example export_graph && cargo run --example export_schemas`) ## Source Material @@ -38,7 +38,7 @@ Do NOT invent proofs — always cross-check against the issue and derivation sou ``` Where: -- `load-example(source, target, ...)` looks up the canonical rule entry from `src/example_db/fixtures/examples.json` +- `load-example(source, target, ...)` looks up the canonical rule entry from `docs/paper/data/examples.json` - The returned record contains `source`, `target`, and `solutions` - Access fields: `src_tgt.source.instance`, `src_tgt.target.instance`, `src_tgt_sol.source_config`, `src_tgt_sol.target_config` diff --git a/docs/agent-profiles/FEATURES.md b/docs/agent-profiles/FEATURES.md index 7980ec62f..65fae1cc9 100644 --- a/docs/agent-profiles/FEATURES.md +++ b/docs/agent-profiles/FEATURES.md @@ -6,5 +6,5 @@ - [Reduction Graph] — Automatic shortest-path search through registered reductions between problem types - [BruteForce Solver] — Enumerate all configurations to find optimal or satisfying solutions - [Variant System] — Graph/weight type parameterization with compile-time complexity registration -- [Overhead System] — Symbolic expressions describing how target problem size relates to source after reduction +- [Size Analysis] — Explain how problem size changes along a path and measure complete instances - [Serialization] — JSON schema export and serde-based serialization for all problem types diff --git a/docs/agent-profiles/SKILLS.md b/docs/agent-profiles/SKILLS.md index b7c7a6e3a..df3ffde16 100644 --- a/docs/agent-profiles/SKILLS.md +++ b/docs/agent-profiles/SKILLS.md @@ -1,20 +1,20 @@ # Skills -Example generation now goes through the example catalog and checked-in fixture DB. +Example generation goes through the example catalog and generated paper data. When a workflow needs a paper/example instance, prefer the catalog path over ad hoc `examples/reduction_*.rs` binaries: -- use `src/example_db/fixtures/examples.json` directly for paper/example data -- use `make regenerate-fixtures` when canonical examples change +- use `docs/paper/data/examples.json` directly for paper/example data +- run `cargo run --features "example-db" --example export_examples` when canonical examples change - use `pred create --example ` to materialize a canonical model example as normal problem JSON - use `pred create --example --to ` to materialize a canonical rule example as normal problem JSON - when adding new example coverage, register a catalog entry instead of creating a new standalone reduction example file Post-refactor extension points: -- new model load/serialize/brute-force dispatch comes from `declare_variants!` in the model file, with explicit `opt` or `sat` markers and an optional `default` +- new model load/serialize/brute-force dispatch comes from `declare_variants!` in the model file, with an optional `default` - alias resolution lives in `problemreductions-cli/src/problem_name.rs` - `pred create` UX lives in `problemreductions-cli/src/commands/create.rs` -- canonical examples live in `src/example_db/model_builders.rs` and `src/example_db/rule_builders.rs` +- model examples live in `src/example_db/model_builders.rs`; rule examples live beside their rules and are collected by `src/rules/mod.rs` - [issue-to-pr] — Convert a GitHub issue into a PR with an implementation plan - [add-model] — Add a new problem model to the codebase diff --git a/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md b/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md index 10ad103ed..bb0dccce7 100644 --- a/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md +++ b/docs/agent-profiles/pred-sym-prof-yuki-tanaka.md @@ -6,7 +6,7 @@ pred-sym (symbolic expression CLI) ## Use Case Three combined scenarios: 1. **Complexity comparison** — Compare algorithm complexity expressions to determine asymptotic equivalence (e.g., O(n^2 + n) == O(n^2), O(n log n) != O(n^2)). -2. **Reduction overhead audit** — Parse and simplify overhead expressions from reduction rules to verify they match expected growth (e.g., '3*num_vertices + num_edges^2'). +2. **Reduction size-contract audit** — Parse and simplify each rule's exact or upper-bound size relation, and verify it against constructed examples. 3. **Teaching complexity notation** — Use pred-sym as a learning/demonstration tool to explore how expressions simplify, evaluate at concrete sizes, and compare growth rates. ## Expected Outcome diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 88cd07556..fe06421ad 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8,7 +8,8 @@ target: e.target, source-name: graph-data.nodes.at(e.source).name, target-name: graph-data.nodes.at(e.target).name, - overhead: e.overhead, + size-fields: e.size_fields, + size-contract-error: e.size_contract_error, )) #let _edges-by-source-name = { @@ -65,7 +66,7 @@ #show: thmrules.with(qed-symbol: $square$) // === Example JSON helpers === -// Load canonical example database directly from the checked-in fixture file. +// Load the generated canonical example database. #let example-db = json("data/examples.json") // Pre-index rules by (source, target) and models by name so lookups are O(bucket) @@ -496,12 +497,6 @@ ] } -// Format target problem spec for pred reduce --to (handles empty variant dicts) -#let target-spec(data) = { - if data.target.variant.len() == 0 { data.target.problem } - else { data.target.problem + "/" + data.target.variant.values().join("/") } -} - // Format a canonical example's problem spec for pred create --example #let problem-spec(data) = { if data.variant.len() == 0 { data.problem } @@ -558,10 +553,14 @@ if parts.len() > 0 { [#base (#parts.join(", "))] } else { base } } -// Format overhead fields as inline text -#let format-overhead(overhead) = { - let parts = overhead.map(o => raw(o.field + " = " + o.formula)) - [_Overhead:_ #parts.join(", ").] +// Format explicitly classified size fields as inline text. +#let format-size-contract(fields) = { + let parts = fields.map(o => { + if o.contract == "exact" { raw(o.field + " = " + o.formula) } + else if o.contract == "bound-only" { raw(o.field + " <= " + o.formula) } + else { raw(o.field + " unavailable: " + o.reason) } + }) + [_Size contract:_ #parts.join(", ").] } // Unified function for reduction rules: theorem + proof + optional example @@ -582,7 +581,7 @@ else { display-name.at(target) } let src-lbl = label("def:" + source) let tgt-lbl = label("def:" + target) - let overhead = if edge != none and edge.overhead.len() > 0 { edge.overhead } else { none } + let size-fields = if edge != none and edge.size-fields.len() > 0 { edge.size-fields } else { none } let thm-lbl = label("thm:" + source + "-to-" + target) covered-rules.update(old => old + ((source, target),)) @@ -590,7 +589,7 @@ #v(1em) #theorem[ *(*#context { if query(src-lbl).len() > 0 { link(src-lbl)[#src-disp] } else [#src-disp] }* #arrow *#context { if query(tgt-lbl).len() > 0 { link(tgt-lbl)[#tgt-disp] } else [#tgt-disp] }*)* #theorem-body - #if overhead != none { linebreak(); format-overhead(overhead) } + #if size-fields != none { linebreak(); format-size-contract(size-fields) } ] #thm-lbl] proof[#proof-body] @@ -8147,7 +8146,6 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let sets = x.instance.sets let k = x.instance.k let bound = x.instance.bound - let config = x.optimal_config let m = sets.len() // Count qualifying tuples by enumerating the Cartesian product let total = sets.fold(1, (acc, s) => acc * s.len()) @@ -8157,12 +8155,11 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ][ The $K$th Largest $m$-Tuple problem is MP10 in Garey and Johnson's appendix @garey1979. It is _not known to be in NP_, because a "yes" certificate may need to exhibit $K$ qualifying tuples and $K$ can be exponentially large. The problem is PP-complete under polynomial-time Turing reductions @haase2016, though the special case $m = 2$, $K = 1$ is NP-complete via reduction from Subset Sum. In the general case, the only known exact approach is brute-force enumeration of all $product_(i=1)^m |X_i|$ tuples, so the registered catalog complexity is `total_tuples * num_sets`#footnote[No algorithm improving on brute-force is known for the general $K$th Largest $m$-Tuple problem.]. - *Example.* Let $m = #m$, $B = #bound$, and $K = #k$ with sets #sets.enumerate().map(((i, s)) => [$X_#(i+1) = {#s.map(str).join(", ")}$]).join([, ]). The Cartesian product has $#total$ tuples. For instance, the tuple $(#config.enumerate().map(((i, c)) => str(sets.at(i).at(c))).join(", "))$ has sum $#config.enumerate().map(((i, c)) => sets.at(i).at(c)).sum() >= #bound$, contributing 1 to the count. In total, #k of the #total tuples satisfy the bound, so the answer is _yes_ (count $= K$). + *Example.* Let $m = #m$, $B = #bound$, and $K = #k$ with sets #sets.enumerate().map(((i, s)) => [$X_#(i+1) = {#s.map(str).join(", ")}$]).join([, ]). The Cartesian product has $#total$ tuples. Exactly #k tuples have sum at least #bound, so the answer is _yes_ (count $= K$). The evaluator enumerates the Cartesian product internally and stops once it has found $K$ qualifying tuples. #pred-commands( "pred create --example KthLargestMTuple -o kth-largest-m-tuple.json", "pred solve kth-largest-m-tuple.json --solver brute-force", - "pred evaluate kth-largest-m-tuple.json --config " + config.map(str).join(","), ) ] ] @@ -11434,7 +11431,10 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| = Reductions -Each reduction is presented as a *Rule* (with linked problem names and overhead from the graph data), followed by a *Proof* (construction, correctness, variable mapping, solution extraction), and optionally a *Concrete Example* (a small instance with verified solution). Problem names in the rule title link back to their definitions in @sec:problems. +Each reduction is presented as a *Rule* (with linked problem names and explicit size contracts from the graph data), followed by a *Proof* (construction, correctness, variable mapping, solution extraction), and optionally a *Concrete Example* (a small instance with verified solution). Problem names in the rule title link back to their definitions in @sec:problems. + +The command blocks assume `route.json` contains the explicitly chosen direct route for +the displayed rule, extracted from the corresponding `pred path` entry. #let max2sat_mc = load-example("Maximum2Satisfiability", "MaxCut") @@ -11445,7 +11445,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example " + problem-spec(max2sat_mc.source) + " -o max2sat.json", - "pred reduce max2sat.json --to " + target-spec(max2sat_mc) + " -o bundle.json", + "pred reduce max2sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate max2sat.json --config " + max2sat_mc_sol.source_config.map(str).join(","), ) @@ -11496,7 +11496,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example Maximum2Satisfiability -o max2sat.json", - "pred reduce max2sat.json --to " + target-spec(max2sat_ilp) + " -o bundle.json", + "pred reduce max2sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate max2sat.json --config " + max2sat_ilp_sol.source_config.map(str).join(","), ) @@ -11658,7 +11658,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example MVC -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_mis) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_mis_sol.source_config.map(str).join(","), ) @@ -11691,7 +11691,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example " + problem-spec(dmds_mmmc.source) + " -o dmds.json", - "pred reduce dmds.json --to " + target-spec(dmds_mmmc) + " -o bundle.json", + "pred reduce dmds.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate dmds.json --config " + dmds_mmmc_sol.source_config.map(str).join(","), ) @@ -11726,7 +11726,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example " + problem-spec(dmds_msmc.source) + " -o dmds.json", - "pred reduce dmds.json --to " + target-spec(dmds_msmc) + " -o bundle.json", + "pred reduce dmds.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate dmds.json --config " + dmds_msmc_sol.source_config.map(str).join(","), ) @@ -11807,7 +11807,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead [ #pred-commands( "pred create --example " + problem-spec(mvc_lcs.source) + " -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_lcs) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_lcs_sol.source_config.map(str).join(","), ) @@ -11848,7 +11848,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example MVC -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_fvs) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_fvs_sol.source_config.map(str).join(","), ) @@ -11892,7 +11892,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example MIS -o mis.json", - "pred reduce mis.json --to " + target-spec(mis_clique) + " -o bundle.json", + "pred reduce mis.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mis.json --config " + mis_clique_sol.source_config.map(str).join(","), ) @@ -11948,7 +11948,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example " + problem-spec(dmvc_cc.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(dmvc_cc) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + dmvc_cc_sol.source_config.map(str).join(","), ) @@ -11989,7 +11989,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example MVC -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_aog) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_aog_sol.source_config.map(str).join(","), ) @@ -12043,7 +12043,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example SpinGlass -o spinglass.json", - "pred reduce spinglass.json --to " + target-spec(sg_qubo) + " -o bundle.json", + "pred reduce spinglass.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate spinglass.json --config " + sg_qubo_sol.source_config.map(str).join(","), ) @@ -12092,7 +12092,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead extra: [ #pred-commands( "pred create --example CVP -o cvp.json", - "pred reduce cvp.json --to " + target-spec(cvp_qubo) + " -o bundle.json", + "pred reduce cvp.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate cvp.json --config " + cvp_qubo_sol.source_config.map(str).join(","), ) @@ -12115,7 +12115,7 @@ Each reduction is presented as a *Rule* (with linked problem names and overhead $ w_(i,p) = 2^p quad (0 <= p < L_i - 1), quad w_(i,L_i-1) = r_i + 1 - 2^(L_i - 1) $ so that every bit vector represents an offset in ${0, dots, r_i}$. Then $ x_i = ell_i + sum_(p=0)^(L_i-1) w_(i,p) z_(i,p) $ - and the total number of QUBO variables is $N = sum_i L_i$, exactly the exported overhead `num_vars = num_encoding_bits`. + and the total number of QUBO variables is $N = sum_i L_i$, exactly the exported size map `num_vars = num_encoding_bits`. Let $G = A^top A$ and $h = A^top bold(t)$. Writing $bold(x) = bold(ell) + B bold(z)$ for the encoding matrix $B in RR^(n times N)$ gives $ norm(A bold(x) - bold(t))_2^2 = bold(z)^top (B^top G B) bold(z) + 2 bold(z)^top B^top (G bold(ell) - h) + "const" $ @@ -12144,7 +12144,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(kc_qubo.source) + " -o kcoloring.json", - "pred reduce kcoloring.json --to " + target-spec(kc_qubo) + " -o bundle.json", + "pred reduce kcoloring.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate kcoloring.json --config " + kc_qubo_sol.source_config.map(str).join(","), ) @@ -12242,7 +12242,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_qc.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_qc) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred evaluate ksat.json --config " + ksat_qc_sol.source_config.map(str).join(","), ) @@ -12305,7 +12305,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_ss.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_ss) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_ss_sol.source_config.map(str).join(","), ) @@ -12346,7 +12346,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SubsetSum -o subsetsum.json", - "pred reduce subsetsum.json --to " + target-spec(ss-cvp) + " -o bundle.json", + "pred reduce subsetsum.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate subsetsum.json --config " + ss-cvp-sol.source_config.map(str).join(","), ) @@ -12432,7 +12432,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(part_ks.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_ks) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_ks_sol.source_config.map(str).join(","), ) @@ -12474,7 +12474,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(part_ss.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_ss) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_ss_sol.source_config.map(str).join(","), ) @@ -12516,7 +12516,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(part_ifwm.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_ifwm) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_ifwm_sol.source_config.map(str).join(","), ) @@ -12559,7 +12559,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example Knapsack -o knapsack.json", - "pred reduce knapsack.json --to " + target-spec(ks_qubo) + " -o bundle.json", + "pred reduce knapsack.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate knapsack.json --config " + ks_qubo_sol.source_config.map(str).join(","), ) @@ -12600,7 +12600,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example MinimumDiscretePlanarInverseKinematics -o ik.json", - "pred reduce ik.json --to " + target-spec(mdpik_qubo) + " -o bundle.json", + "pred reduce ik.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ik.json --config " + mdpik_qubo_sol.source_config.map(str).join(","), ) @@ -12653,7 +12653,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example MinimumMultiwayCut -o minimummultiwaycut.json", - "pred reduce minimummultiwaycut.json --to " + target-spec(mwc_qubo) + " -o bundle.json", + "pred reduce minimummultiwaycut.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate minimummultiwaycut.json --config " + mwc_qubo_sol.source_config.map(str).join(","), ) @@ -12712,7 +12712,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example QUBO -o qubo.json", - "pred reduce qubo.json --to " + target-spec(qubo_ilp) + " -o bundle.json", + "pred reduce qubo.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate qubo.json --config " + qubo_ilp_sol.source_config.map(str).join(","), ) @@ -12754,7 +12754,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example CircuitSAT -o circuitsat.json", - "pred reduce circuitsat.json --to " + target-spec(cs_ilp) + " -o bundle.json", + "pred reduce circuitsat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate circuitsat.json --config " + cs_ilp_sol.source_config.map(str).join(","), ) @@ -12805,7 +12805,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SAT -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_mis) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_mis_sol.source_config.map(str).join(","), ) @@ -12835,7 +12835,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SAT -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_kc) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_kc_sol.source_config.map(str).join(","), ) @@ -12863,7 +12863,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SAT -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_ds) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_ds_sol.source_config.map(str).join(","), ) @@ -12889,7 +12889,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(sat_ifha.source) + " -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_ifha) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_ifha_sol.source_config.map(str).join(","), ) @@ -12945,7 +12945,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SAT -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_ksat) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_ksat_sol.source_config.map(str).join(","), ) @@ -12976,7 +12976,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(sat_max2sat.source) + " -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_max2sat) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_max2sat_sol.source_config.map(str).join(","), ) @@ -13041,7 +13041,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SAT -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_cs) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_cs_sol.source_config.map(str).join(","), ) @@ -13074,7 +13074,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example " + problem-spec(cs_sat.source) + " -o circuitsat.json", - "pred reduce circuitsat.json --to " + target-spec(cs_sat) + " -o bundle.json", + "pred reduce circuitsat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate circuitsat.json --config " + cs_sat_sol.source_config.map(str).join(","), ) @@ -13114,7 +13114,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example CircuitSAT -o circuitsat.json", - "pred reduce circuitsat.json --to " + target-spec(cs_sg) + " -o bundle.json", + "pred reduce circuitsat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate circuitsat.json --config " + cs_sg_sol.source_config.map(str).join(","), ) @@ -13166,7 +13166,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example Factoring -o factoring.json", - "pred reduce factoring.json --to " + target-spec(fact_cs) + " -o bundle.json", + "pred reduce factoring.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate factoring.json --config " + fact_cs_sol.source_config.map(str).join(","), ) @@ -13198,7 +13198,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example MaxCut -o maxcut.json", - "pred reduce maxcut.json --to " + target-spec(mc_sg) + " -o bundle.json", + "pred reduce maxcut.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate maxcut.json --config " + mc_sg_sol.source_config.map(str).join(","), ) @@ -13224,7 +13224,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m extra: [ #pred-commands( "pred create --example SpinGlass -o spinglass.json", - "pred reduce spinglass.json --to " + target-spec(sg_mc) + " -o bundle.json", + "pred reduce spinglass.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate spinglass.json --config " + sg_mc_sol.source_config.map(str).join(","), ) @@ -13380,7 +13380,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mfdts_ilp.source) + " -o mfdts.json", - "pred reduce mfdts.json --to " + target-spec(mfdts_ilp) + " -o bundle.json", + "pred reduce mfdts.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mfdts.json --config " + mfdts_ilp_sol.source_config.map(str).join(","), ) @@ -13455,7 +13455,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example MinimumFeedbackVertexSet -o fvs.json", - "pred reduce fvs.json --to " + target-spec(fvs_cg) + " -o bundle.json", + "pred reduce fvs.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate fvs.json --config " + fvs_cg_sol.source_config.map(str).join(","), ) @@ -13506,7 +13506,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mckp_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(mckp_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + mckp_ilp_sol.source_config.map(str).join(","), ) @@ -13540,7 +13540,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mces_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(mces_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + mces_ilp_sol.source_config.map(str).join(","), ) @@ -13578,7 +13578,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(cmo_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(cmo_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + cmo_ilp_sol.source_config.map(str).join(","), ) @@ -13618,7 +13618,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mewkc_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(mewkc_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + mewkc_ilp_sol.source_config.map(str).join(","), ) @@ -13654,7 +13654,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example Knapsack -o knapsack.json", - "pred reduce knapsack.json --to " + target-spec(ks_ilp) + " -o bundle.json", + "pred reduce knapsack.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate knapsack.json --config " + ks_ilp_sol.source_config.map(str).join(","), ) @@ -13704,7 +13704,7 @@ The following reductions to Integer Linear Programming are straightforward formu [ #pred-commands( "pred create --example " + problem-spec(ik_ilp.source) + " -o integer-knapsack.json", - "pred reduce integer-knapsack.json --to " + target-spec(ik_ilp) + " -o bundle.json", + "pred reduce integer-knapsack.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate integer-knapsack.json --config " + ik_ilp_sol.source_config.map(str).join(","), ) @@ -13759,7 +13759,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example MaximumClique -o maximumclique.json", - "pred reduce maximumclique.json --to " + target-spec(clique_mis) + " -o bundle.json", + "pred reduce maximumclique.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate maximumclique.json --config " + clique_mis_sol.source_config.map(str).join(","), ) @@ -13836,7 +13836,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(ola_seqmwct.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(ola_seqmwct) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + ola_seqmwct_sol.source_config.map(str).join(","), ) @@ -13872,7 +13872,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(dola_c1ma.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(dola_c1ma) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + dola_c1ma_sol.source_config.map(str).join(","), ) @@ -13937,7 +13937,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(hc_tsp.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_tsp) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_tsp_sol.source_config.map(str).join(","), ) @@ -13968,7 +13968,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example TSP -o tsp.json", - "pred reduce tsp.json --to " + target-spec(tsp_ilp) + " -o bundle.json", + "pred reduce tsp.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate tsp.json --config " + tsp_ilp_sol.source_config.map(str).join(","), ) @@ -14014,7 +14014,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example LongestPath -o longest-path.json", - "pred reduce longest-path.json --to " + target-spec(lp_ilp) + " -o bundle.json", + "pred reduce longest-path.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate longest-path.json --config " + lp_ilp_sol.source_config.map(str).join(","), ) @@ -14059,7 +14059,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example TSP -o tsp.json", - "pred reduce tsp.json --to " + target-spec(tsp_qubo) + " -o bundle.json", + "pred reduce tsp.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate tsp.json --config " + tsp_qubo_sol.source_config.map(str).join(","), ) @@ -14096,7 +14096,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example LCS -o lcs.json", - "pred reduce lcs.json --to " + target-spec(lcs_mis) + " -o bundle.json", + "pred reduce lcs.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate lcs.json --config " + lcs_mis_sol.source_config.map(str).join(","), ) @@ -14131,7 +14131,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(cs_ilp_str.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(cs_ilp_str) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + cs_ilp_str_sol.source_config.map(str).join(","), ) @@ -14174,7 +14174,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(css_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(css_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + css_ilp_sol.source_config.map(str).join(","), ) @@ -14276,7 +14276,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example SteinerTree -o steinertree.json", - "pred reduce steinertree.json --to " + target-spec(st_ilp) + " -o bundle.json", + "pred reduce steinertree.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate steinertree.json --config " + st_ilp_sol.source_config.map(str).join(","), ) @@ -14331,7 +14331,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example 'MVC {weight: One}' -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_hs) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_hs_sol.source_config.map(str).join(","), ) @@ -14426,7 +14426,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mono_ilp.source) + " -o monochromatic-triangle.json", - "pred reduce monochromatic-triangle.json --to " + target-spec(mono_ilp) + " -o bundle.json", + "pred reduce monochromatic-triangle.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate monochromatic-triangle.json --config " + mono_ilp_sol.source_config.map(str).join(","), ) @@ -14463,7 +14463,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(ss_bt.source) + " -o set-splitting.json", - "pred reduce set-splitting.json --to " + target-spec(ss_bt) + " -o bundle.json", + "pred reduce set-splitting.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate set-splitting.json --config " + ss_bt_sol.source_config.map(str).join(","), ) @@ -14538,7 +14538,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(kc_bcbs.source) + " -o kclique.json", - "pred reduce kclique.json --to " + target-spec(kc_bcbs) + " -o bundle.json", + "pred reduce kclique.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate kclique.json --config " + kc_bcbs_sol.source_config.map(str).join(","), ) @@ -14603,7 +14603,7 @@ The following reductions to Integer Linear Programming are straightforward formu [ #pred-commands( "pred create --example " + problem-spec(mmm_ach.source) + " -o mmm.json", - "pred reduce mmm.json --to " + target-spec(mmm_ach) + " -o bundle.json", + "pred reduce mmm.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mmm.json --config " + mmm_ach_sol.source_config.map(str).join(","), ) @@ -14664,7 +14664,7 @@ The following reductions to Integer Linear Programming are straightforward formu [ #pred-commands( "pred create --example " + problem-spec(mmm_mmd.source) + " -o mmm.json", - "pred reduce mmm.json --to " + target-spec(mmm_mmd) + " -o bundle.json", + "pred reduce mmm.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mmm.json --config " + s-cfg.map(str).join(","), ) @@ -14946,7 +14946,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example PartitionIntoPathsOfLength2 -o ppl2.json", - "pred reduce ppl2.json --to " + target-spec(ppl2_bcsf) + " -o bundle.json", + "pred reduce ppl2.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ppl2.json --config " + ppl2_bcsf_sol.source_config.map(str).join(","), ) @@ -15592,7 +15592,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(hcd_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(hcd_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + hcd_ilp_sol.source_config.map(str).join(","), ) @@ -15626,7 +15626,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(ep_ilp.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(ep_ilp) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + ep_ilp_sol.source_config.map(str).join(","), ) @@ -15688,7 +15688,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(hc_lc.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_lc) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_lc_sol.source_config.map(str).join(","), ) @@ -16283,7 +16283,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(ps_qubo.source) + " -o paintshop.json", - "pred reduce paintshop.json --to " + target-spec(ps_qubo) + " -o bundle.json", + "pred reduce paintshop.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate paintshop.json --config " + ps_qubo_sol.source_config.map(str).join(","), ) @@ -16363,7 +16363,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(rta_rtsa.source) + " -o rta.json", - "pred reduce rta.json --to " + target-spec(rta_rtsa) + " -o bundle.json", + "pred reduce rta.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate rta.json --config " + rta_rtsa_sol.source_config.map(str).join(","), ) @@ -16486,7 +16486,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mcmf_mcc.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(mcmf_mcc) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + mcmf_mcc_sol.source_config.map(str).join(","), ) @@ -16555,7 +16555,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example " + problem-spec(mfas_mlr.source) + " -o mfas.json", - "pred reduce mfas.json --to " + target-spec(mfas_mlr) + " -o bundle.json", + "pred reduce mfas.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mfas.json --config " + mfas_mlr_sol.source_config.map(str).join(","), ) @@ -16607,7 +16607,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example MaximumLikelihoodRanking -o mlr.json", - "pred reduce mlr.json --to " + target-spec(mlr_ilp) + " -o bundle.json", + "pred reduce mlr.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mlr.json --config " + mlr_ilp_sol.source_config.map(str).join(","), ) @@ -16651,7 +16651,7 @@ The following reductions to Integer Linear Programming are straightforward formu extra: [ #pred-commands( "pred create --example OptimumCommunicationSpanningTree -o ocst.json", - "pred reduce ocst.json --to " + target-spec(ocst_ilp) + " -o bundle.json", + "pred reduce ocst.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ocst.json --config " + ocst_ilp_sol.source_config.map(str).join(","), ) @@ -16762,10 +16762,10 @@ See #link("https://github.com/CodingThrust/problem-reductions/blob/main/examples == Variant Cast Reductions -Problems parameterized by graph type, weight type, or clause-width ($k$) admit identity reductions between specialised and general variants. Each cast preserves the problem structure exactly (same number of vertices/variables, same constraints), converting only the type parameter to a more general one. These are registered as self-edges in the reduction graph with identity overhead. +Problems parameterized by graph type, weight type, or clause-width ($k$) admit identity reductions between specialised and general variants. Each cast preserves the problem structure exactly (same number of vertices/variables, same constraints), converting only the type parameter to a more general one. These are registered as self-edges in the reduction graph with exact identity size maps. #reduction-rule("MaximumIndependentSet", "MaximumIndependentSet")[ - The graph hierarchy $"KingsSubgraph" subset "UnitDiskGraph" subset "SimpleGraph"$ and weight hierarchy $"One" subset ZZ subset RR$ induce identity-overhead casts between MIS variants. Graph casts discard geometric information (grid coordinates $arrow.r$ Euclidean coordinates $arrow.r$ adjacency list); weight casts embed unit weights into integers ($1 arrow.r 1_ZZ$) or integers into floats ($w arrow.r w_RR$). All edges and weights are preserved verbatim. + The graph hierarchy $"KingsSubgraph" subset "UnitDiskGraph" subset "SimpleGraph"$ and weight hierarchy $"One" subset ZZ subset RR$ induce exact identity size maps between MIS variants. Graph casts discard geometric information (grid coordinates $arrow.r$ Euclidean coordinates $arrow.r$ adjacency list); weight casts embed unit weights into integers ($1 arrow.r 1_ZZ$) or integers into floats ($w arrow.r w_RR$). All edges and weights are preserved verbatim. ][ _Construction._ Given $"MIS"(G, bold(w))$ with graph type $G_"sub"$ and weight type $W_"sub"$, construct $"MIS"(G', bold(w)')$ where $G' = "cast"(G_"sub")$ lifts the graph to its parent type and $bold(w)' = "cast"(bold(w))$ lifts each weight. The `CastToParent` trait defines the concrete maps: - _KingsSubgraph $arrow.r$ UnitDiskGraph:_ integer grid positions $(i, j)$ map to float coordinates with radius $r = 1.5$. @@ -16854,7 +16854,7 @@ Problems parameterized by graph type, weight type, or clause-width ($k$) admit i == Resource Estimation from Examples -The following table shows concrete variable overhead for example instances, taken directly from the canonical fixture examples. +The following table shows concrete target-variable counts for example instances, taken directly from the canonical fixture examples. #let example-files = ( (source: "MaximumIndependentSet", target: "MinimumVertexCover"), @@ -17094,7 +17094,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_hp.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_hp) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_hp_sol.source_config.map(str).join(","), ) @@ -17125,7 +17125,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(kc_si.source) + " -o kclique.json", - "pred reduce kclique.json --to " + target-spec(kc_si) + " -o bundle.json", + "pred reduce kclique.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate kclique.json --config " + kc_si_sol.source_config.map(str).join(","), ) @@ -17189,7 +17189,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_mps.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_mps) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_mps_sol.source_config.map(str).join(","), ) @@ -17229,7 +17229,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_sosp.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_sosp) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_sosp_sol.source_config.map(str).join(","), ) @@ -17262,7 +17262,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_btsp.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_btsp) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_btsp_sol.source_config.map(str).join(","), ) @@ -17293,7 +17293,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(kc_cbq.source) + " -o kclique.json", - "pred reduce kclique.json --to " + target-spec(kc_cbq) + " -o bundle.json", + "pred reduce kclique.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate kclique.json --config " + kc_cbq_sol.source_config.map(str).join(","), ) @@ -17342,7 +17342,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_ss.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_ss) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_ss_sol.source_config.map(str).join(","), ) @@ -17381,7 +17381,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_dmvc.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_dmvc) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_dmvc_sol.source_config.map(str).join(","), ) @@ -17417,7 +17417,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(dmvc_hc.source) + " -o dmvc.json", - "pred reduce dmvc.json --to " + target-spec(dmvc_hc) + " -o bundle.json", + "pred reduce dmvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate dmvc.json --config " + dmvc_hc_sol.source_config.map(str).join(","), ) @@ -17448,7 +17448,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_mvc.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_mvc) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_mvc_sol.source_config.map(str).join(","), ) @@ -17489,7 +17489,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_mono.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_mono) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_mono_sol.source_config.map(str).join(","), ) @@ -17520,7 +17520,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_1in3.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_1in3) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_1in3_sol.source_config.map(str).join(","), ) @@ -17572,7 +17572,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_d2cif.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_d2cif) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_d2cif_sol.source_config.map(str).join(","), ) @@ -17617,7 +17617,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_rs.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_rs) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_rs_sol.source_config.map(str).join(","), ) @@ -17678,7 +17678,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(mvc_mfas.source) + " -o mvc.json", - "pred reduce mvc.json --to " + target-spec(mvc_mfas) + " -o bundle.json", + "pred reduce mvc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mvc.json --config " + mvc_mfas_sol.source_config.map(str).join(","), ) @@ -17722,7 +17722,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_kc.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_kc) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_kc_sol.source_config.map(str).join(","), ) @@ -17761,7 +17761,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_co.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_co) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_co_sol.source_config.map(str).join(","), ) @@ -17800,7 +17800,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_ps.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_ps) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_ps_sol.source_config.map(str).join(","), ) @@ -17853,7 +17853,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_td.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_td) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_td_sol.source_config.map(str).join(","), ) @@ -17896,7 +17896,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_ap.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_ap) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_ap_sol.source_config.map(str).join(","), ) @@ -17940,7 +17940,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_bicon.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_bicon) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_bicon_sol.source_config.map(str).join(","), ) @@ -17986,7 +17986,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_sca.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_sca) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_sca_sol.source_config.map(str).join(","), ) @@ -18021,7 +18021,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_sc.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_sc) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_sc_sol.source_config.map(str).join(","), ) @@ -18053,7 +18053,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_rp.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_rp) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_rp_sol.source_config.map(str).join(","), ) @@ -18084,7 +18084,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(mis_ifb.source) + " -o mis.json", - "pred reduce mis.json --to " + target-spec(mis_ifb) + " -o bundle.json", + "pred reduce mis.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate mis.json --config " + mis_ifb_sol.source_config.map(str).join(","), ) @@ -18136,7 +18136,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hc_qa.source) + " -o hc.json", - "pred reduce hc.json --to " + target-spec(hc_qa) + " -o bundle.json", + "pred reduce hc.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hc.json --config " + hc_qa_sol.source_config.map(str).join(","), ) @@ -18179,7 +18179,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_bp.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_bp) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_bp_sol.source_config.map(str).join(","), ) @@ -18210,7 +18210,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_msp.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_msp) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_msp_sol.source_config.map(str).join(","), ) @@ -18250,7 +18250,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_mfdts.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_mfdts) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_mfdts_sol.source_config.map(str).join(","), ) @@ -18285,7 +18285,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_mas.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_mas) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_mas_sol.source_config.map(str).join(","), ) @@ -18334,7 +18334,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ss_part.source) + " -o subsetsum.json", - "pred reduce subsetsum.json --to " + target-spec(ss_part) + " -o bundle.json", + "pred reduce subsetsum.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate subsetsum.json --config " + ss_part_sol.source_config.map(str).join(","), ) @@ -18435,7 +18435,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(sat_nt.source) + " -o sat.json", - "pred reduce sat.json --to " + target-spec(sat_nt) + " -o bundle.json", + "pred reduce sat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate sat.json --config " + sat_nt_sol.source_config.map(str).join(","), ) @@ -18467,7 +18467,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(kc_pic.source) + " -o kcoloring.json", - "pred reduce kcoloring.json --to " + target-spec(kc_pic) + " -o bundle.json", + "pred reduce kcoloring.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate kcoloring.json --config " + kc_pic_sol.source_config.map(str).join(","), ) @@ -18557,7 +18557,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(clustering_ilp.source) + " -o clustering.json", - "pred reduce clustering.json --to " + target-spec(clustering_ilp) + " -o bundle.json", + "pred reduce clustering.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate clustering.json --config " + clustering_ilp_sol.source_config.map(str).join(","), ) @@ -18602,7 +18602,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(pic_mcbc.source) + " -o partition-into-cliques.json", - "pred reduce partition-into-cliques.json --to " + target-spec(pic_mcbc) + " -o bundle.json", + "pred reduce partition-into-cliques.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition-into-cliques.json --config " + pic_mcbc_sol.source_config.map(str).join(","), ) @@ -18641,7 +18641,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(mcbc_migb.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(mcbc_migb) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + mcbc_migb_sol.source_config.map(str).join(","), ) @@ -18668,7 +18668,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_ker.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_ker) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_ker_sol.source_config.map(str).join(","), ) @@ -18713,7 +18713,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hp_dcst.source) + " -o hampath.json", - "pred reduce hampath.json --to " + target-spec(hp_dcst) + " -o bundle.json", + "pred reduce hampath.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hampath.json --config " + hp_dcst_sol.source_config.map(str).join(","), ) @@ -18745,7 +18745,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(nae_ss.source) + " -o naesat.json", - "pred reduce naesat.json --to " + target-spec(nae_ss) + " -o bundle.json", + "pred reduce naesat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate naesat.json --config " + nae_ss_sol.source_config.map(str).join(","), ) @@ -18784,7 +18784,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(nae_ppm.source) + " -o naesat.json", - "pred reduce naesat.json --to " + target-spec(nae_ppm) + " -o bundle.json", + "pred reduce naesat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate naesat.json --config " + nae_ppm_sol.source_config.map(str).join(","), ) @@ -18828,7 +18828,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_sp.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_sp) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_sp_sol.source_config.map(str).join(","), ) @@ -18869,7 +18869,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_bdst.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_bdst) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_bdst_sol.source_config.map(str).join(","), ) @@ -18915,7 +18915,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ss_iem.source) + " -o subsetsum.json", - "pred reduce subsetsum.json --to " + target-spec(ss_iem) + " -o bundle.json", + "pred reduce subsetsum.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate subsetsum.json --config " + ss_iem_sol.source_config.map(str).join(","), ) @@ -18956,7 +18956,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(ksat_si.source) + " -o ksat.json", - "pred reduce ksat.json --to " + target-spec(ksat_si) + " -o bundle.json", + "pred reduce ksat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate ksat.json --config " + ksat_si_sol.source_config.map(str).join(","), ) @@ -18995,7 +18995,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(n3dm_nmts.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(n3dm_nmts) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + n3dm_nmts_sol.source_config.map(str).join(","), ) @@ -19020,7 +19020,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_stw.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_stw) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_stw_sol.source_config.map(str).join(","), ) @@ -19074,7 +19074,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_oss.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_oss) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_oss_sol.source_config.map(str).join(","), ) @@ -19125,7 +19125,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(nae_mc.source) + " -o naesat.json", - "pred reduce naesat.json --to " + target-spec(nae_mc) + " -o bundle.json", + "pred reduce naesat.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate naesat.json --config " + nae_mc_sol.source_config.map(str).join(","), ) @@ -19171,7 +19171,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tdm_tmi.source) + " -o source.json", - "pred reduce source.json --to " + target-spec(tdm_tmi) + " -o bundle.json", + "pred reduce source.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate source.json --config " + tdm_tmi_sol.source_config.map(str).join(","), ) @@ -19199,7 +19199,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tdm_tp.source) + " -o three-dimensional-matching.json", - "pred reduce three-dimensional-matching.json --to " + target-spec(tdm_tp) + " -o bundle.json", + "pred reduce three-dimensional-matching.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate three-dimensional-matching.json --config " + tdm_tp_sol.source_config.map(str).join(","), ) @@ -19269,7 +19269,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tdm_ilp.source) + " -o three-dimensional-matching.json", - "pred reduce three-dimensional-matching.json --to " + target-spec(tdm_ilp) + " -o bundle.json", + "pred reduce three-dimensional-matching.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate three-dimensional-matching.json --config " + tdm_ilp_sol.source_config.map(str).join(","), ) @@ -19314,7 +19314,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tdm_mwd.source) + " -o three-dimensional-matching.json", - "pred reduce three-dimensional-matching.json --to " + target-spec(tdm_mwd) + " -o bundle.json", + "pred reduce three-dimensional-matching.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate three-dimensional-matching.json --config " + tdm_mwd_sol.source_config.map(str).join(","), ) @@ -19361,7 +19361,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tp_rcs.source) + " -o threepartition.json", - "pred reduce threepartition.json --to " + target-spec(tp_rcs) + " -o bundle.json", + "pred reduce threepartition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate threepartition.json --config " + tp_rcs_sol.source_config.map(str).join(","), ) @@ -19396,7 +19396,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(tp_srd.source) + " -o tp.json", - "pred reduce tp.json --to " + target-spec(tp_srd) + " -o bundle.json", + "pred reduce tp.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate tp.json --config " + tp_srd_sol.source_config.map(str).join(","), ) @@ -19435,7 +19435,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(mc_mcbs.source) + " -o maxcut.json", - "pred reduce maxcut.json --to " + target-spec(mc_mcbs) + " -o bundle.json", + "pred reduce maxcut.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate maxcut.json --config " + mc_mcbs_sol.source_config.map(str).join(","), ) @@ -19474,7 +19474,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(mc_mmc.source) + " -o maxcut.json", - "pred reduce maxcut.json --to " + target-spec(mc_mmc) + " -o bundle.json", + "pred reduce maxcut.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate maxcut.json --config " + mc_mmc_sol.source_config.map(str).join(","), ) @@ -19512,7 +19512,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hp_ist.source) + " -o hampath.json", - "pred reduce hampath.json --to " + target-spec(hp_ist) + " -o bundle.json", + "pred reduce hampath.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hampath.json --config " + hp_ist_sol.source_config.map(str).join(","), ) @@ -19544,7 +19544,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(x3c_gf2.source) + " -o x3c.json", - "pred reduce x3c.json --to " + target-spec(x3c_gf2) + " -o bundle.json", + "pred reduce x3c.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate x3c.json --config " + x3c_gf2_sol.source_config.map(str).join(","), ) @@ -19587,7 +19587,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(part_pp.source) + " -o partition.json", - "pred reduce partition.json --to " + target-spec(part_pp) + " -o bundle.json", + "pred reduce partition.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate partition.json --config " + part_pp_sol.source_config.map(str).join(","), ) @@ -19639,7 +19639,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(hpbtv_lp.source) + " -o hampath2v.json", - "pred reduce hampath2v.json --to " + target-spec(hpbtv_lp) + " -o bundle.json", + "pred reduce hampath2v.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate hampath2v.json --config " + hpbtv_lp_sol.source_config.map(str).join(","), ) @@ -19671,7 +19671,7 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example " + problem-spec(gp_mc.source) + " -o graphpart.json", - "pred reduce graphpart.json --to " + target-spec(gp_mc) + " -o bundle.json", + "pred reduce graphpart.json --via route.json -o bundle.json", "pred solve bundle.json", "pred evaluate graphpart.json --config " + gp_mc_sol.source_config.map(str).join(","), ) @@ -19729,10 +19729,10 @@ The following table shows concrete variable overhead for example instances, take extra: [ #pred-commands( "pred create --example PrizeCollectingSteinerForest -o pcsf.json", - "pred reduce pcsf.json --to " + target-spec(pcsf_st) + " -o bundle.json", + "pred reduce pcsf.json --via route.json -o bundle.json", "pred solve bundle.json", ) - The canonical PCSF source has $beta = #pcsf_st.source.instance.beta$, $omega = #pcsf_st.source.instance.omega$, and prizes $p = (#pcsf_st_prizes.at(0), #pcsf_st_prizes.at(1), #pcsf_st_prizes.at(2))$. The target SteinerTree has $|V_H| = n + k + 1 = #(pcsf_st_n + pcsf_st_k + 1)$ vertices, $|E_H| = m + n + 2 k = #(pcsf_st_m + pcsf_st_n + 2 * pcsf_st_k)$ edges, and $|T_H| = k + 1 = #(pcsf_st_k + 1)$ terminals, matching the registered overhead formulas. + The canonical PCSF source has $beta = #pcsf_st.source.instance.beta$, $omega = #pcsf_st.source.instance.omega$, and prizes $p = (#pcsf_st_prizes.at(0), #pcsf_st_prizes.at(1), #pcsf_st_prizes.at(2))$. The target SteinerTree has $|V_H| = n + k + 1 = #(pcsf_st_n + pcsf_st_k + 1)$ vertices, $|E_H| = m + n + 2 k = #(pcsf_st_m + pcsf_st_n + 2 * pcsf_st_k)$ edges, and $|T_H| = k + 1 = #(pcsf_st_k + 1)$ terminals, matching the registered exact size formulas. ], )[ Bienstock, Goemans, Simchi-Levi, Williamson @BienstockGoemansSimchiLeviWilliamson1993 introduced the prize/penalty framework for prize-collecting network design; Tuncbag and coauthors @TuncbagEtAl2013PCSF @TuncbagEtAl2012RECOMB used the same artificial-root idea to translate PCSF into a rooted prize-collecting Steiner tree on biological networks. The combined construction recorded here adds a per-vertex auxiliary-terminal gadget that compiles the remaining omitted-prize term `beta * p(v)` into ordinary Steiner-tree edge costs, so the target is a plain (unweighted-prize) Steiner Tree instance. diff --git a/docs/src/cli.md b/docs/src/cli.md index e94f4456e..5e94294c7 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -33,15 +33,7 @@ cargo run -p problemreductions-cli --bin pred -- --version ### ILP Backend -The default ILP backend is HiGHS. To use a different backend: - -```bash -cargo install problemreductions-cli --features coin-cbc -cargo install problemreductions-cli --features scip -cargo install problemreductions-cli --no-default-features --features clarabel -``` - -Available backends: `highs` (default), `coin-cbc`, `clarabel`, `scip`, `lpsolve`, `microlp`. +ILP problems are solved with the bundled HiGHS backend. ## Quick Start @@ -88,14 +80,14 @@ pred solve lbdp.json --solver brute-force # Evaluate a specific configuration (shows the aggregate value, e.g. Max(2) or Min(None)) pred evaluate problem.json --config 1,0,1,0 -# Reduce to another problem type and solve via brute-force -pred reduce problem.json --to QUBO -o reduced.json +# Reduce along an explicitly chosen route and solve via brute-force +pred reduce problem.json --via route.json -o reduced.json pred solve reduced.json --solver brute-force # Pipe commands together (use - to read from stdin) pred create MIS --graph 0-1,1-2,2-3 | pred solve - # when an ILP reduction path exists pred create StringToStringCorrection --source-string "0,1,2,3,1,0" --target-string "0,1,3,2,1" --bound 2 | pred solve - --solver brute-force -pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --to QUBO | pred solve - +pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --via route.json | pred solve - ``` > **Note:** When you provide `--weights` with non-unit values (e.g., `3,1,2,1`), the variant is @@ -144,9 +136,9 @@ Explore which problems the given problem can reduce to, starting **from** it: {{#include generated/pred-from-qubo.txt}} ``` -### `pred path` — Find a reduction path +### `pred path` — Find reduction paths -Find the cheapest chain of reductions between two problems: +Enumerate paths between two problems: ```text {{#include generated/pred-path-mis-qubo.txt}} @@ -158,25 +150,21 @@ Multi-step paths are discovered automatically: {{#include generated/pred-path-factoring-spinglass.txt}} ``` -Show all paths or save for later use with `pred reduce --via`: +Inspect reduction paths or save the path set for later route selection: ```bash -pred path MIS QUBO --all # all paths (up to 20) -pred path MIS QUBO --all --max-paths 50 # increase limit -pred path MIS QUBO -o path.json # save path for `pred reduce --via` -pred path MIS QUBO --all -o paths/ # save all paths to a folder +pred path MIS QUBO # paths (up to 20) +pred path MIS QUBO --max-paths 50 # increase the cap +pred path MIS MaximumClique mis.json # execute paths on a complete instance +pred path MIS QUBO -o paths.json # save the path set ``` -When using `--all`, the output is capped at `--max-paths` (default: 20). If more paths exist, the output indicates truncation. - -Use `--cost` to change the optimization strategy: - -```bash -pred path MIS QUBO --cost minimize-steps # default -pred path MIS QUBO --cost minimize:num_variables # minimize a size field -``` - -Use `pred show ` to see which size fields are available. +Without an instance file, each route explains how problem size changes. With a +problem JSON file, every returned path is executed on the complete source instance +and the actual size of each constructed intermediate is reported. Discovery never +ranks or discards routes based on size. Output is capped by `--max-paths` (default: 20); +extract one route from the path-set envelope before passing it to +`pred reduce --via`. ### `pred export-graph` — Export the reduction graph @@ -320,13 +308,7 @@ pred create MIS --graph 0-1,1-2 | pred inspect - ### `pred reduce` — Reduce a problem -Reduce a problem to a target type. Outputs a reduction bundle containing source, target, and path: - -```bash -pred reduce problem.json --to QUBO -o reduced.json -``` - -Use a specific reduction path (from `pred path -o`). The target is inferred from the path file, so `--to` is not needed: +Reduce a problem along a specific route. The target is inferred from the route file: ```bash pred reduce problem.json --via path.json -o reduced.json @@ -335,7 +317,7 @@ pred reduce problem.json --via path.json -o reduced.json Stdin is supported with `-`: ```bash -pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --to QUBO +pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --via route.json ``` The bundle contains everything needed to map solutions back: @@ -429,7 +411,7 @@ This is useful for scripting and piping: ```bash pred list --json | jq '.variants[].name' -pred path MIS QUBO --json | jq '.path' +pred path MIS QUBO --json | jq '.paths[] | {overall_size, path}' ``` ## Problem Name Aliases diff --git a/docs/src/design.md b/docs/src/design.md index 7f709edfc..6b2ad2f7e 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -2,6 +2,9 @@ This guide covers the library internals for contributors. +See [Numeric types and arithmetic](#numeric-types-and-arithmetic) before +choosing numeric fields or implementing arithmetic in a model or reduction. + ## Module Architecture @@ -39,12 +42,101 @@ trait Problem: Clone { } ``` -- **`Problem`** — the base trait. Every problem declares a `NAME` (e.g., `"MaximumIndependentSet"`). The solver explores the configuration space defined by `dims()` and scores each configuration with `evaluate()`. For example, a 4-vertex MIS has `dims() = [2, 2, 2, 2]` (each vertex is selected or not); `evaluate(&[1, 0, 1, 0])` returns `Max(Some(2))` if vertices 0 and 2 form an independent set, or `Max(None)` if they share an edge. Each problem also provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) used by reduction overhead expressions. +- **`Problem`** — the base trait. Every problem declares a `NAME` (e.g., `"MaximumIndependentSet"`). The solver explores the configuration space defined by `dims()` and scores each configuration with `evaluate()`. For example, a 4-vertex MIS has `dims() = [2, 2, 2, 2]` (each vertex is selected or not); `evaluate(&[1, 0, 1, 0])` returns `Max(Some(2))` if vertices 0 and 2 form an independent set, or `Max(None)` if they share an edge. Each problem also provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) used by reduction size expressions. - **Witness-capable objective problems** — typically use `Max`, `Min`, or `Extremum` as `Value`. - **Witness-capable feasibility problems** — typically use `Or`. - **Aggregate-only problems** — use fold values such as `Sum` or `And`; these solve to a value but do not admit representative witness configurations. - **Common aggregate wrappers** — `Max`, `Min`, `Sum`, `Or`, `And`, `Extremum`, `ExtremumSense`. +## Numeric types and arithmetic + +Every numeric field needs a mathematical domain, a supported range, and an +overflow rule. `NumericSize` only lists operations required by aggregate value +types; it does not make those operations overflow-safe. + +| Quantity | Normal Rust type | Supported range and rule | Repository example | +|---|---|---|---| +| Collection index, length, or in-memory configuration dimension | `usize` | Values supported by the current target. Convert external fixed-width values with `usize::try_from`; reject values that do not fit. | `Problem::dims()` and graph vertex indices | +| Individual exact signed weight or cost | `i32` | The `i32` range, narrowed further when the problem requires nonnegative input. | A vertex weight in `MinimumDominatingSet<_, i32>` | +| Total of `i32` weights | `i64` | Accumulate exactly in `i64`; reject a derived value that would exceed `i64`. | `WeightElement for i32` uses `Sum = i64` | +| Unit-weight count | `i64` | Use the same total and bound representation as exact weighted variants. | `WeightElement for One` uses `Sum = i64` | +| Approximate numeric input | `f64` | Only when approximation belongs to the model or solver interface; model constructors reject NaN and infinity. | Floating-point QUBO coefficients | +| Fixed-width serialized nonnegative domain value | `u64` | The same JSON range on every target. Convert to `usize` before indexing and reject failure. | Large integer sizes in arithmetic problems | +| Exact signed objective bound | The objective total type, normally `i64` | A decision bound and the optimization result it compares against use the same type. | `Decision>` has an `i64` bound | +| SAT variable count | `usize`, at most `i32::MAX` | Reject larger formulas at construction because signed literals cannot encode them. | `Satisfiability::try_new` | +| SAT literal | nonzero `i32` | Its magnitude must be in `1..=num_vars`; `0` and `i32::MIN` are invalid. | `CNFClause` literals | + +### Indices and collection sizes + +Use `usize` for values passed to indexing, collection allocation, and +configuration dimensions. A serialized `usize` is intentionally machine-sized: +loading rejects a JSON value that does not fit the target. Use `u64` instead +when the problem definition requires a fixed serialized range, then perform an +explicit checked conversion before using it as an index. + +### Weights, costs, times, capacities, and bounds + +Choose an input type from the mathematical domain, not from the type of a later +index. Exact signed element weights normally use `i32`. A quantity that bounds +or compares with a total uses the total's type. Negative values are accepted +only when the problem definition gives them meaning; otherwise reject them in +the constructor. + +### Totals and derived arithmetic + +Do not assume one input element's type can hold a sum or product of many +elements. `WeightElement` is the source of truth for weight totals: `i32` and +`One` accumulate into `i64`, while `f64` accumulates into `f64`. For other +derived integers, choose a result type from the largest supported value and use +`checked_add`, `checked_sub`, or `checked_mul` when the operation may reach its +boundary. Overflow is an input/construction error, not an infeasible solution. + +### Conversions + +Use `From` for conversions that cannot change the value and `TryFrom` when +range or sign can change. Do not use `as` for a user/model-derived narrowing, +signedness change, SAT variable number, coefficient, or bound. A failed +conversion must report the value, destination range, and model or reduction +that rejected it. + +### JSON, CLI, and MCP boundaries + +The schema field type is the external contract. Rust constructors and serde +deserialization must apply the same validation, and schema-driven CLI/MCP +creation must parse the declared type rather than a smaller intermediate type. +Do not deserialize directly into private validated fields when doing so bypasses +the constructor invariant. + +### SAT and compact signed encodings + +CNF uses one-indexed signed `i32` literals. All CNF-backed models validate the +same range during construction and deserialization. Reductions that create +auxiliary SAT variables allocate them through the checked SAT allocator; they +must stop before constructing a target if the next ID would exceed +`i32::MAX`. Apply the same explicit-range rule to any new compact signed +encoding. + +### Exact integers and floating point + +Keep exact integer calculations in integer types. Do not convert an exact sum, +product, identifier, or comparison bound to `f64` merely to obtain more range. +An integer-to-floating conversion is permitted only at an explicitly +approximate solver boundary, where the exactly representable input range and +out-of-range behavior are documented. + +### Numeric implementation review checklist + +Issue authors describe mathematical objects, domains, and constraints; they are +not expected to choose Rust types. During implementation and review, derive and +record: + +1. every numeric input, its meaning, and its mathematical domain; +2. every computed total/product and its result type; +3. the largest supported input and derived value; +4. every narrowing or signedness-changing conversion; +5. how construction, deserialization, and reduction report overflow; +6. whether arithmetic is exact or approximate, with justification for `f64`. + ## Variant System A single problem name like `MaximumIndependentSet` can have multiple **variants** — carrying weights on vertices, or defined on a restricted topology (e.g., king's subgraph). Variants form a subtype hierarchy: independent sets on king's subgraphs are a subset of independent sets on unit-disk graphs. The reduction from a more specific variant to a less specific one is a **variant cast** — an identity mapping where indices are preserved. @@ -162,16 +254,50 @@ impl ReductionResult for ReductionISToVC { type Target = MinimumVertexCover; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution(&self, target_sol: &[usize]) -> Vec { - target_sol.iter().map(|&x| 1 - x).collect() // complement + fn extract_solution( + &self, + target_sol: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_sol)?; + Ok(target_sol.iter().map(|&x| 1 - x).collect()) } } ``` +### Solution extraction contract + +`ReductionResult::extract_solution` accepts one complete target configuration +and returns the source configuration defined by the reduction. Extraction is a +fallible boundary, not a recovery mechanism: + +1. In every direct extractor, call `validate_target_solution()` once before + indexing or decoding. Composed extractors delegate this check. +2. Validate any structure required by the inverse mapping, such as exactly-one + blocks, permutations, paths, flows, or schedules. +3. Apply the reduction's mathematical inverse once and return a source + configuration with the required length and domains. +4. Return `ExtractionError` when a precondition is not satisfied. + +Do not truncate or pad input, substitute zero for missing data, select the +first of several invalid candidates, retry with another mapping, or panic on +caller-provided configuration data. Empty and singleton instances should flow +through the same mathematical mapping unless the reduction itself has a +genuine mathematical case distinction. + +Zero and sentinel values remain valid when the source model explicitly gives +them meaning. For example, `MaximumCommonEdgeSubgraph` includes an "unmapped" +sentinel in its source dimensions. Missing target data must never be +interpreted as that sentinel. + +Each conditional in an extractor should therefore either reject a named +invariant violation or implement a case in the reduction's mathematics. A +normal extractor has one validation phase followed by one decoding phase; it +does not accumulate compatibility or fallback branches. + The `#[reduction]` attribute on the `ReduceTo` impl registers the reduction in the global registry (via `inventory`): ```rust,ignore -#[reduction(overhead = { +#[reduction(size = exact { num_vertices = "num_vertices", num_edges = "num_edges", })] @@ -195,11 +321,13 @@ inventory::submit! { target_name: "MinimumVertexCover", source_variant_fn: || as Problem>::variant(), target_variant_fn: || as Problem>::variant(), - overhead_fn: || ReductionOverhead { - output_size: vec![ + size_declarations_fn: || ReductionSizeDeclarations { + relation: Some(SizeRelation::Exact), + fields: vec![ ("num_vertices", Expr::Var("num_vertices")), ("num_edges", Expr::Var("num_edges")), ], + unavailable: vec![], }, module_path: module_path!(), reduce_fn: |src: &dyn Any| -> Box { @@ -235,20 +363,14 @@ All path-finding operates on **exact variant nodes**. Use `ReductionGraph::varia | Method | Algorithm | Use case | |--------|-----------|----------| -| `find_cheapest_path(src, src_var, dst, dst_var, input_size, cost_fn)` | Dijkstra | Optimal path under a cost function | | `find_all_paths(src, src_var, dst, dst_var)` | All simple paths | Enumerate every route | +| `compose_path_size_transform(path)` | Symbolic composition | Compose each rule's exact or upper-bound size relation while preserving its promise | -Use `find_cheapest_path` with `MinimizeSteps` for fewest-hops search. - -The `PathCostFn` trait (used by `find_cheapest_path`) computes edge cost from overhead and current problem size: - -| Cost function | Strategy | -|--------------|----------| -| `MinimizeSteps` | Minimize number of hops (unit edge cost) | -| `Minimize("field")` | Minimize a single output field (e.g., `Minimize("num_variables")`) | -| `CustomCost(closure)` | User-defined: `\|overhead: &ReductionOverhead, size: &ProblemSize\| -> f64` | - -`CustomCost` wraps a closure that receives the edge's `ReductionOverhead` (polynomial mapping from input to output size fields) and the current `ProblemSize` (accumulated field values at that point in the path), and returns an `f64` edge cost. Dijkstra minimizes the total cost along the path. +Symbolic path discovery does not rank or prune routes. A rule has one relation for all of +its formulas: either an exact equality or an upper bound. Composition performs only +substitution and relation propagation: exact composed with exact stays exact; every other +combination is an upper bound. Concrete-instance measurement remains a separate execution +API. **Example:** Finding a path from `MIS{KingsSubgraph, i32}` to `VC{SimpleGraph, i32}`: @@ -262,9 +384,12 @@ MIS{KingsSubgraph,i32} -> MIS{UnitDiskGraph,i32} -> MIS{SimpleGraph,i32} -> VC{S Convert a `ReductionPath` into a typed `ExecutablePath` via `make_executable()`, then call `reduce()`: ```rust,ignore -// find_cheapest_path returns a ReductionPath (list of variant node IDs) -let rpath = graph.find_cheapest_path("Factoring", &src_var, - "SpinGlass", &dst_var, &ProblemSize::new(vec![]), &MinimizeSteps).unwrap(); +let paths = graph.find_all_paths_mode( + "Factoring", &src_var, "SpinGlass", &dst_var, ReductionMode::Witness, +); +let rpath = paths.iter() + .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .expect("required route"); // make_executable converts it into a typed, callable chain let path = graph.make_executable::>(&rpath).unwrap(); @@ -280,28 +405,42 @@ let solution: Vec = reduction.extract_solution(&target_solution); For full type control, you can also chain `ReduceTo::reduce_to()` calls manually at each step.

-Overhead evaluation +Size contracts -Each reduction declares how the output problem size relates to the input, expressed as symbolic `Expr` expressions. The `#[reduction]` macro parses overhead strings at compile time: +Each reduction declares one relation for all represented target-size fields and may mark +other fields unavailable with a reason. The `#[reduction]` macro parses every formula into +the canonical `Expr` DAG at compile time: ```rust,ignore -#[reduction(overhead = { +#[reduction( +size = upper_bound { num_vars = "num_vertices + num_edges", num_clauses = "3 * num_edges", +}, +unavailable = { + encoding_bits = "coefficient magnitudes are not tracked", +}, })] impl ReduceTo for Source { ... } ``` -Expressions support: constants, variables, `+`, `*`, `^`, `exp()`, `log()`, `sqrt()`. Each problem type provides inherent getter methods (e.g., `num_vertices()`, `num_edges()`) that the overhead expressions reference. +`SizeTransform` uses exact rational and arbitrary-precision integer arithmetic. Exact +relations must evaluate to non-negative integers. Upper-bound relations accept only +non-negative monotone formulas and round rational results upward. Missing fields, negative +or non-integral exact results, division by zero, and explicit conversion outside `usize` +are errors. -`evaluate_output_size(input)` substitutes input values: +Transforms can be evaluated with an explicit source size: ``` Input: ProblemSize { num_vertices: 10, num_edges: 15 } -Output: ProblemSize { num_vars: 25, num_clauses: 45 } +Output: ProblemSize { num_vars: 25 } ``` -For multi-step paths, overhead composes: the output of step N becomes the input of step N+1. Variant cast edges use `ReductionOverhead::identity()`, passing through all fields unchanged. +For multi-step paths, `compose_path_size_transform` substitutes each step into the next +without expanding the shared expression DAG. An upper bound cannot pass through a +non-monotone downstream formula. Projection to `Growth` is an explicit terminal operation, +and its exact/upper-bound relation is preserved in the result.
@@ -321,7 +460,7 @@ pub trait Solver { | Solver | Description | |--------|-------------| | **BruteForce** | Enumerates all configurations. `solve()` works for any aggregate problem; `find_witness()`, `find_all_witnesses()`, and `solve_with_witnesses()` are available when `P::Value` supports witnesses. Used for testing and verification. | -| **ILPSolver** | Enabled by default. Solves ILP instances directly with HiGHS via `good_lp`. Also provides `solve_reduced()` for witness-capable problems that implement `ReduceTo>`. | +| **ILPSolver** | Solves `ILP` and `ILP` instances directly with HiGHS via `good_lp`. Also provides `solve_reduced::()` for witness-capable problems that implement `ReduceTo>`. | ## JSON Serialization diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5afc10916..d8db70fc6 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -100,10 +100,17 @@ For convenience, `ILPSolver::solve_reduced` combines reduce + solve + extract in a single call: ```rust,ignore -let solution = ILPSolver::new().solve_reduced(&problem).unwrap(); +let solution = ILPSolver::new() + .solve_reduced::(&problem) + .unwrap(); assert!(problem.evaluate(&solution).is_valid()); ``` +The ILP domain is explicit because a source type may provide more than one +direct ILP reduction. Both `bool` and `i32` are supported. `solve` and +`solve_reduced` return `ILPSolveError`, which distinguishes infeasibility, +timeout, unboundedness, unsupported dynamic input, and backend failure. + ### Example 2: Reduction path search — integer factoring to spin glass Real-world problems often require **chaining** multiple reductions. Here we factor the integer 6 by reducing `Factoring` through the reduction graph to `SpinGlass`, through automatic reduction path search. ([full source](https://github.com/CodingThrust/problem-reductions/blob/main/examples/chained_reduction_factoring_to_spinglass.rs)) @@ -112,9 +119,10 @@ Let's walk through each step. #### Step 1 — Discover the reduction path -`ReductionGraph` holds every registered reduction. `find_cheapest_path` -searches for the shortest chain from a source problem variant to a target -variant. +`ReductionGraph` holds every registered reduction. The example enumerates the +witness-capable simple paths and explicitly selects the documented +`Factoring -> CircuitSAT -> SpinGlass` route. Path discovery does not rank or +automatically select a route. ```rust,ignore {{#include ../../examples/chained_reduction_factoring_to_spinglass.rs:step1}} @@ -158,21 +166,6 @@ factors. {{#include generated/factoring-result.txt}} ``` -#### Step 5 — Inspect the overhead - -Each reduction edge carries a polynomial overhead mapping source problem -sizes to target sizes. `path_overheads` returns the per-edge -polynomials, and `compose_path_overhead` composes them symbolically into a -single end-to-end formula. - -```rust,ignore -{{#include ../../examples/chained_reduction_factoring_to_spinglass.rs:overhead}} -``` - -```text -{{#include generated/factoring-overhead.txt}} -``` - ## Solvers Three solvers are available: @@ -180,14 +173,10 @@ Three solvers are available: | Solver | Use Case | Notes | |--------|----------|-------| | [`BruteForce`](api/problemreductions/solvers/struct.BruteForce.html) | Small instances (<20 variables) | Enumerates all configurations | -| [`ILPSolver`](api/problemreductions/solvers/ilp/struct.ILPSolver.html) | Larger instances | Enabled by default (`ilp` feature) | +| [`ILPSolver`](api/problemreductions/solvers/ilp/struct.ILPSolver.html) | Larger instances | Uses the bundled HiGHS backend | | [`CustomizedSolver`](api/problemreductions/solvers/customized/struct.CustomizedSolver.html) | Structure-exploiting | Uses problem-specific exact algorithms | -ILP support is enabled by default. To disable it: - -```bash -cargo add problemreductions --no-default-features -``` +ILP support through HiGHS is part of the library and is always available. ## JSON Resources diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 05913595c..396b3b675 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -79,8 +79,8 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `list_problems` | *(none)* | List all registered problem types with aliases, variant counts, and reduction counts | | `show_problem` | `problem` (string) | Show details for a problem type: variants, size fields, schema, and incoming/outgoing reductions | | `neighbors` | `problem` (string), `hops` (int, default: 1), `direction` ("out"\|"in"\|"both", default: "out") | Find neighboring problems reachable via reduction edges within a given hop distance | -| `find_path` | `source` (string), `target` (string), `cost` (string, default: "minimize-steps"), `all` (bool, default: false) | Find a reduction path between two problems, optionally minimizing a size field or returning all paths | -| `export_graph` | *(none)* | Export the full reduction graph as JSON (nodes, edges, overheads) | +| `find_path` | `source` (string), `target` (string), `max_paths` (int, default: 20), `problem_json` (optional string) | Find reduction paths and explain how size changes. With a complete source instance, execute each returned path and report the actual constructed sizes. | +| `export_graph` | *(none)* | Export the full reduction graph as JSON | ### Instance Tools @@ -89,7 +89,7 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `create_problem` | `problem_type` (string), `params` (JSON object) | Create a problem instance from parameters and return its JSON representation. Supports graph problems, SAT, QUBO, SpinGlass, KColoring, Factoring, and random graph generation | | `inspect_problem` | `problem_json` (string) | Inspect a problem JSON or reduction bundle: returns type, size metrics, available solvers, and reduction targets | | `evaluate` | `problem_json` (string), `config` (array of int) | Evaluate a configuration against a problem instance and return the objective value or feasibility | -| `reduce` | `problem_json` (string), `target` (string) | Reduce a problem instance to a target type, returning a reduction bundle with the transformed instance and path metadata | +| `reduce` | `problem_json` (string), `path_json` (string) | Reduce a problem instance along an explicitly supplied route, returning a bundle with the transformed instance and path metadata | | `solve` | `problem_json` (string), `solver` ("ilp"\|"brute-force", default: "ilp"), `timeout` (int, default: 0) | Solve a problem instance or reduction bundle using ILP or brute-force, with optional timeout | ## Available Prompts @@ -103,5 +103,5 @@ The server provides 7 task-oriented prompt templates: | `compare` | `problem_a` (required), `problem_b` (required) | Compare two problem types | | `reduce` | `source` (required), `target` (required) | Step-by-step reduction walkthrough | | `solve` | `problem_type` (required), `params` (required) | Create and solve a problem instance | -| `find_reduction` | `source` (required), `target` (required) | Find the best reduction path between two problems | +| `find_reduction` | `source` (required), `target` (required) | Find reduction paths between two problems and explain how size changes | | `overview` | *(none)* | Explore the full landscape of NP-hard problems | diff --git a/docs/src/static/reduction-graph.js b/docs/src/static/reduction-graph.js index 3e2c0d0b9..3e382efe9 100644 --- a/docs/src/static/reduction-graph.js +++ b/docs/src/static/reduction-graph.js @@ -176,7 +176,7 @@ if (srcName === dstName) return; var key = srcName + '->' + dstName; if (!nameLevelEdges[key]) { - nameLevelEdges[key] = { count: 0, overhead: e.overhead, doc_path: e.doc_path }; + nameLevelEdges[key] = { count: 0, sizeFields: e.size_fields, doc_path: e.doc_path }; } nameLevelEdges[key].count++; }); @@ -191,7 +191,7 @@ target: problemNodeIds[parts[1]], label: info.count > 1 ? '\u00d7' + info.count : '', edgeLevel: 'collapsed', - overhead: info.overhead, + sizeFields: info.sizeFields, doc_path: info.doc_path } }); @@ -208,7 +208,7 @@ edgeMap[key] = { source: srcId, target: dstId, - overhead: e.overhead || [], + sizeFields: e.size_fields || [], doc_path: e.doc_path || '' }; } @@ -219,16 +219,18 @@ var srcName = e.source.split('/')[0]; var dstName = e.target.split('/')[0]; var isVariantCast = srcName === dstName && - e.overhead && - e.overhead.length > 0 && - e.overhead.every(function(o) { return o.field === o.formula; }); + e.sizeFields && + e.sizeFields.length > 0 && + e.sizeFields.every(function(o) { + return o.contract === 'exact' && o.field === o.formula; + }); return { data: { id: 'variant_' + key, source: e.source, target: e.target, edgeLevel: 'variant', - overhead: e.overhead, + sizeFields: e.sizeFields, doc_path: e.doc_path, isVariantCast: isVariantCast } @@ -531,8 +533,12 @@ cy.on('mouseover', 'edge', function(evt) { var d = evt.target.data(); var html = '' + evt.target.source().data('label') + ' \u2192 ' + evt.target.target().data('label') + ''; - if (d.overhead && d.overhead.length > 0) { - html += '
' + d.overhead.map(function(o) { return '' + o.field + ' = ' + o.formula + ''; }).join('
'); + if (d.sizeFields && d.sizeFields.length > 0) { + html += '
' + d.sizeFields.map(function(o) { + if (o.contract === 'exact') return '' + o.field + ' = ' + o.formula + ' (exact)'; + if (o.contract === 'upper_bound') return '' + o.field + '' + o.formula + ' (upper bound)'; + return '' + o.field + ' unavailable: ' + o.reason; + }).join('
'); } html += '
Click to highlight, double-click for source code'; tooltip.innerHTML = html; @@ -642,8 +648,12 @@ edge.source().addClass('highlighted'); edge.target().addClass('highlighted'); var text = edge.source().data('label') + ' \u2192 ' + edge.target().data('label'); - if (d.overhead && d.overhead.length > 0) { - text += ' | ' + d.overhead.map(function(o) { return o.field + ' = ' + o.formula; }).join(', '); + if (d.sizeFields && d.sizeFields.length > 0) { + text += ' | ' + d.sizeFields.map(function(o) { + if (o.contract === 'exact') return o.field + ' = ' + o.formula + ' (exact)'; + if (o.contract === 'upper_bound') return o.field + ' <= ' + o.formula + ' (upper bound)'; + return o.field + ' unavailable: ' + o.reason; + }).join(', '); } instructions.textContent = text; clearBtn.style.display = 'inline'; diff --git a/scripts/generate_doc_snippets.sh b/scripts/generate_doc_snippets.sh index 4a4663525..52171aa8c 100755 --- a/scripts/generate_doc_snippets.sh +++ b/scripts/generate_doc_snippets.sh @@ -37,9 +37,14 @@ echo "Generating doc snippets with $PRED ..." # 9. pred create + reduce + solve bundle "$PRED" create MIS --graph 0-1,1-2,2-3 -o /tmp/pred_doc_problem.json 2>/dev/null -"$PRED" reduce /tmp/pred_doc_problem.json --to QUBO -o /tmp/pred_doc_reduced.json 2>/dev/null +"$PRED" path MIS QUBO --json 2>/dev/null | python3 -c ' +import json, sys +paths = json.load(sys.stdin)["paths"] +json.dump(paths[0], sys.stdout) +' > /tmp/pred_doc_route.json +"$PRED" reduce /tmp/pred_doc_problem.json --via /tmp/pred_doc_route.json -o /tmp/pred_doc_reduced.json 2>/dev/null "$PRED" solve /tmp/pred_doc_reduced.json --solver brute-force 2>/dev/null > "$OUT/pred-solve-bundle.txt" -rm -f /tmp/pred_doc_problem.json /tmp/pred_doc_reduced.json +rm -f /tmp/pred_doc_problem.json /tmp/pred_doc_route.json /tmp/pred_doc_reduced.json # 10. pred evaluate "$PRED" create MIS --graph 0-1,1-2,2-3 2>/dev/null | "$PRED" evaluate - --config 1,0,1,0 2>/dev/null > "$OUT/pred-evaluate.txt" @@ -67,10 +72,9 @@ for alias, name in rows: print(f'| \`{alias}\` | \`{name}\` |') " > "$OUT/pred-aliases.txt" -# 13. Factoring example output (path discovery line + overhead) +# 13. Factoring example output FACTORING_OUTPUT=$(cargo run --example chained_reduction_factoring_to_spinglass 2>/dev/null) echo "$FACTORING_OUTPUT" | head -1 > "$OUT/factoring-path.txt" echo "$FACTORING_OUTPUT" | sed -n '2p' > "$OUT/factoring-result.txt" -echo "$FACTORING_OUTPUT" | sed -n '3,$p' > "$OUT/factoring-overhead.txt" echo "Done. Generated $(ls "$OUT" | wc -l | tr -d ' ') snippets in $OUT/" diff --git a/scripts/pipeline_checks.py b/scripts/pipeline_checks.py index 9f85addb5..dc661aeba 100644 --- a/scripts/pipeline_checks.py +++ b/scripts/pipeline_checks.py @@ -292,10 +292,14 @@ def rule_completeness( if test_file.exists() else check_entry(status="fail", detail="missing rule unit tests") ), - "overhead_form": ( + "size_contract_form": ( check_entry(status="pass", path=str(rule_file.relative_to(repo_root))) - if rule_file.exists() and "#[reduction(overhead = {" in rule_text - else check_entry(status="fail", detail="missing #[reduction(overhead = {...})] form") + if rule_file.exists() + and any(key in rule_text for key in ("exact = {", "bound = {", "unavailable = {")) + else check_entry( + status="fail", + detail="missing explicit exact, bound, or unavailable size declaration", + ) ), "canonical_example": ( check_entry(status="pass", path=str(rule_file.relative_to(repo_root))) diff --git a/scripts/test_pipeline_checks.py b/scripts/test_pipeline_checks.py index 84b14b3f0..1125ca910 100644 --- a/scripts/test_pipeline_checks.py +++ b/scripts/test_pipeline_checks.py @@ -54,6 +54,7 @@ def test_fetch_existing_prs_falls_back_to_rest_search_on_pr_list_failure( "number": 223, "headRefName": "issue-212-multiprocessor-scheduling", "url": "https://example.test/pull/223", + "body": "", } ], ) @@ -233,7 +234,7 @@ def test_rule_completeness_reports_all_required_components(self) -> None: self._write( repo / "src/rules/binpacking_ilp.rs", """ - #[reduction(overhead = { num_vars = "num_items" })] + #[reduction(exact = { num_vars = "num_items" })] impl ReduceTo for BinPacking {} pub(crate) fn canonical_rule_example_specs() -> Vec { vec![] } """, @@ -261,7 +262,7 @@ def test_rule_completeness_reports_all_required_components(self) -> None: self.assertEqual(report["checks"]["module_registration"]["status"], "pass") self.assertEqual(report["checks"]["paper_rule"]["status"], "pass") - def test_rule_completeness_flags_missing_overhead_and_paper(self) -> None: + def test_rule_completeness_flags_missing_size_contract_and_paper(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo = Path(tmpdir) self._write( @@ -288,7 +289,7 @@ def test_rule_completeness_flags_missing_overhead_and_paper(self) -> None: ) self.assertFalse(report["ok"]) - self.assertIn("overhead_form", report["missing"]) + self.assertIn("size_contract_form", report["missing"]) self.assertIn("paper_rule", report["missing"]) self.assertIn("module_registration", report["missing"]) From 9760c9acdbc68d33fe675cc02ae1a2396b6feed1 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 19:39:51 +0800 Subject: [PATCH 05/15] Harden cross-platform build verification Use the required HiGHS backend, keep benchmark-only dependencies out of ordinary builds, add portable stack handling, and exercise macOS ARM64, Windows x86_64, and RISC-V targets in CI. --- .github/workflows/ci.yml | 88 ++++++++++++++++++++++++++++---------- .github/workflows/docs.yml | 2 +- Makefile | 54 ++++++++++++----------- 3 files changed, 96 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e703c4fcd..3ef1a9fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,16 +49,65 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - name: Run clippy - run: cargo clippy --all-targets --features ilp-highs -- -D warnings + run: cargo clippy --all-targets --features example-db -- -D warnings - # Cross-compile the portable Rust surface to RISC-V Linux, then execute the - # CLI under QEMU user-mode emulation to verify runtime behavior (not just - # that the binary links). + # Build and exercise the HiGHS-backed CLI natively on Apple Silicon. + macos-arm64: + name: macOS ARM64 build & run + runs-on: macos-15 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Verify ARM64 host + run: "rustc -vV | grep 'host: aarch64-apple-darwin'" + - name: Build workspace + run: cargo build --workspace + - name: Run CLI and ILP smoke test + run: | + target/debug/pred list | head -5 + target/debug/pred create MaximumIndependentSet --graph 0-1,1-2,2-3,3-4,4-0 -o mis.json + target/debug/pred solve mis.json --solver ilp | tee solve.out + grep -q '"kind": "ilp"' solve.out + grep -q '"evaluation": "Max(2)"' solve.out + + # Build and exercise the HiGHS-backed CLI natively on 64-bit Windows. + windows-x86_64: + name: Windows x86_64 build & run + runs-on: windows-2025 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Verify x86_64 host + shell: pwsh + run: | + $hostInfo = rustc -vV | Out-String + if ($hostInfo -notmatch 'host: x86_64-pc-windows-msvc') { + throw "Unexpected Rust host:`n$hostInfo" + } + - name: Build workspace + run: cargo build --workspace + - name: Run CLI and ILP smoke test + shell: pwsh + run: | + $pred = 'target/debug/pred.exe' + & $pred list | Select-Object -First 5 + & $pred create MaximumIndependentSet --graph 0-1,1-2,2-3,3-4,4-0 -o mis.json + & $pred solve mis.json --solver ilp | Tee-Object -FilePath solve.out + $solveOutput = Get-Content solve.out -Raw + if ($solveOutput -notmatch '"kind": "ilp"') { + throw 'Expected the ILP solver to run' + } + if ($solveOutput -notmatch '"evaluation": "Max\(2\)"') { + throw 'Expected the maximum independent set value to be 2' + } + + # Cross-compile the full HiGHS-backed CLI to RISC-V Linux, then execute an + # ILP solve under QEMU user-mode emulation (not just a link check). riscv: name: RISC-V build & run runs-on: ubuntu-latest - env: - FEATURES: "ilp-lp-solvers" steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable @@ -68,11 +117,13 @@ jobs: - name: Install RISC-V cross tools and QEMU run: | sudo apt-get update - sudo apt-get install -y gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu qemu-user + sudo apt-get install -y gcc-riscv64-linux-gnu g++-riscv64-linux-gnu binutils-riscv64-linux-gnu qemu-user - name: Build workspace for RISC-V env: CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - run: cargo build --workspace --no-default-features --features "$FEATURES" --target riscv64gc-unknown-linux-gnu + CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc + CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ + run: cargo build --workspace --target riscv64gc-unknown-linux-gnu - name: Verify RISC-V executable run: | riscv64-linux-gnu-readelf -h target/riscv64gc-unknown-linux-gnu/debug/pred \ @@ -83,9 +134,10 @@ jobs: run: | # Registry loads and the catalog renders on RISC-V. $PRED list | head -5 - # End-to-end create + brute-force solve: MIS of a 5-cycle is 2. + # End-to-end reduction and HiGHS solve: MIS of a 5-cycle is 2. $PRED create MaximumIndependentSet --graph 0-1,1-2,2-3,3-4,4-0 -o mis.json - $PRED solve mis.json --solver brute-force | tee solve.out + $PRED solve mis.json --solver ilp | tee solve.out + grep -q '"kind": "ilp"' solve.out grep -q '"evaluation": "Max(2)"' solve.out # Build, test (nextest), doc tests, and paper. @@ -95,7 +147,7 @@ jobs: # Single feature set across compile + test + doctest so artifacts are reused # (no redundant full recompile between steps). env: - FEATURES: "ilp-highs example-db" + FEATURES: "example-db" steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable @@ -109,14 +161,6 @@ jobs: - name: Compile tests run: cargo nextest run --no-run --workspace --features "$FEATURES" - # The subprocess example tests (tests/suites/examples.rs) shell out to - # `cargo run --example … --features ilp-highs`. Pre-build those example - # binaries with that exact feature set so the subprocess reuses artifacts - # instead of recompiling the whole crate mid-test (which otherwise adds - # 60s+ to a single test's wall-clock and would trip the nextest timeout). - - name: Build examples (for subprocess tests) - run: cargo build --examples --features ilp-highs - - name: Run tests run: cargo nextest run --workspace --features "$FEATURES" @@ -127,8 +171,8 @@ jobs: - name: Build paper run: make paper - # Coverage. Feature set intentionally matches the historical coverage gate - # (ilp-highs only) to keep the codecov baseline stable. + # Coverage intentionally excludes the optional example database to keep the + # historical codecov baseline stable. coverage: name: Code Coverage runs-on: ubuntu-latest @@ -142,7 +186,7 @@ jobs: tool: cargo-llvm-cov,nextest - uses: Swatinem/rust-cache@v2 - name: Generate coverage - run: cargo llvm-cov nextest --features ilp-highs --workspace --lcov --output-path lcov.info + run: cargo llvm-cov nextest --workspace --lcov --output-path lcov.info - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7aa4e1bba..109d14db8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -57,7 +57,7 @@ jobs: run: typst compile --root . docs/paper/reductions.typ book/reductions.pdf - name: Build rustdoc - run: RUSTDOCFLAGS="--default-theme=dark" cargo doc --features ilp-highs --no-deps + run: RUSTDOCFLAGS="--default-theme=dark" cargo doc --no-deps - name: Combine documentation run: | diff --git a/Makefile b/Makefile index ce9056ca2..df36f63c8 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ # Makefile for problemreductions -.PHONY: help build test mcp-test fmt clippy doc mdbook paper clean coverage rust-export compare qubo-testdata export-schemas release run-plan run-issue run-pipeline run-pipeline-forever run-review run-review-forever board-next board-claim board-ack board-move issue-context issue-guards pr-context pr-wait-ci worktree-issue worktree-pr diagrams jl-testdata cli cli-demo copilot-review papers papers-lookup papers-download papers-scihub papers-status papers-push papers-pull papers-index +.PHONY: help build test bench mcp-test fmt clippy doc mdbook paper clean coverage rust-export compare qubo-testdata export-schemas release run-plan run-issue run-pipeline run-pipeline-forever run-review run-review-forever board-next board-claim board-ack board-move issue-context issue-guards pr-context pr-wait-ci worktree-issue worktree-pr diagrams jl-testdata cli cli-demo copilot-review papers papers-lookup papers-download papers-scihub papers-status papers-push papers-pull papers-index RUNNER ?= codex CLAUDE_MODEL ?= opus CODEX_MODEL ?= gpt-5.4 +TEST_FEATURES := example-db # Cross-platform sed in-place: macOS needs -i '', Linux needs -i SED_I := sed -i$(shell if [ "$$(uname)" = "Darwin" ]; then echo " ''"; fi) @@ -14,6 +15,7 @@ help: @echo "Available targets:" @echo " build - Build the project" @echo " test - Run all tests" + @echo " bench - Run solver benchmarks" @echo " mcp-test - Run MCP server tests" @echo " fmt - Format code with rustfmt" @echo " fmt-check - Check code formatting" @@ -21,7 +23,7 @@ help: @echo " doc - Build mdBook documentation" @echo " diagrams - Generate SVG diagrams from Typst (light + dark)" @echo " mdbook - Build and serve mdBook (with live reload)" - @echo " paper - Build Typst paper from checked-in fixtures (requires typst)" + @echo " paper - Generate example data and build the Typst paper (requires typst)" @echo " coverage - Generate coverage report (requires cargo-llvm-cov)" @echo " clean - Clean build artifacts" @echo " check - Quick check (fmt + clippy + test)" @@ -62,11 +64,15 @@ help: # Build the project build: - cargo build --features ilp-highs + cargo build # Run all workspace tests (including ignored tests) test: - cargo test --features "ilp-highs example-db" --workspace -- --include-ignored + cargo test --features "$(TEST_FEATURES)" --workspace -- --include-ignored + +# Compile Criterion only when benchmarks are requested +bench: + cargo bench --features benchmarks # Run MCP server tests mcp-test: ## Run MCP server tests @@ -82,7 +88,7 @@ fmt-check: # Run clippy clippy: - cargo clippy --all-targets --features ilp-highs -- -D warnings + cargo clippy --all-targets --features "$(TEST_FEATURES)" -- -D warnings node_modules/elkjs/package.json: package.json package-lock.json npm ci @@ -95,7 +101,7 @@ doc: node_modules/elkjs/package.json cargo run --example export_module_graph bash scripts/generate_doc_snippets.sh target/release/pred mdbook build docs - RUSTDOCFLAGS="--default-theme=dark" cargo doc --features ilp-highs --no-deps + RUSTDOCFLAGS="--default-theme=dark" cargo doc --no-deps rm -rf docs/book/api cp -r target/doc docs/book/api @@ -123,7 +129,7 @@ mdbook: node_modules/elkjs/package.json @echo "Generating CLI doc snippets..." @bash scripts/generate_doc_snippets.sh target/release/pred 2>&1 | tail -1 @echo "Building API docs..." - @RUSTDOCFLAGS="--default-theme=dark" cargo doc --features ilp-highs --no-deps 2>&1 | tail -1 + @RUSTDOCFLAGS="--default-theme=dark" cargo doc --no-deps 2>&1 | tail -1 @echo "Building mdBook..." @mdbook build rm -rf book/api @@ -140,16 +146,16 @@ export-schemas: # Build Typst paper (generates example data on demand) paper: - cargo run --features "example-db" --example export_examples - cargo run --example export_petersen_mapping - cargo run --example export_graph - cargo run --example export_schemas + cargo run --features "$(TEST_FEATURES)" --example export_examples + cargo run --features "$(TEST_FEATURES)" --example export_petersen_mapping + cargo run --features "$(TEST_FEATURES)" --example export_graph + cargo run --features "$(TEST_FEATURES)" --example export_schemas typst compile --root . docs/paper/reductions.typ docs/paper/reductions.pdf # Generate coverage report (requires: cargo install cargo-llvm-cov) coverage: @command -v cargo-llvm-cov >/dev/null 2>&1 || { echo "Installing cargo-llvm-cov..."; cargo install cargo-llvm-cov; } - cargo llvm-cov --features ilp-highs --workspace --html --open + cargo llvm-cov --workspace --html --open # Clean build artifacts clean: @@ -289,16 +295,12 @@ cli-demo: cli $$PRED from QUBO --hops 1; \ \ echo ""; \ - echo "--- 5. path: find reduction paths ---"; \ + echo "--- 5. path: symbolic path enumeration ---"; \ $$PRED path MIS QUBO; \ - $$PRED path MIS QUBO -o $(CLI_DEMO_DIR)/path_mis_qubo.json; \ $$PRED path Factoring SpinGlass; \ - $$PRED path MIS QUBO --cost minimize:num_variables; \ - \ - echo ""; \ - echo "--- 6. path --all: enumerate all paths ---"; \ - $$PRED path MIS QUBO --all; \ - $$PRED path MIS QUBO --all -o $(CLI_DEMO_DIR)/all_paths/; \ + echo "--- 5b. explicitly choose one route from the path set ---"; \ + $$PRED path MIS QUBO -o $(CLI_DEMO_DIR)/paths_mis_qubo.json; \ + jq -e 'first(.paths[] | select(([.path[0].from.name] + [.path[].to.name]) == ["MaximumIndependentSet", "MaximumIndependentSet", "MaximumSetPacking", "MaximumSetPacking", "QUBO"]))' $(CLI_DEMO_DIR)/paths_mis_qubo.json > $(CLI_DEMO_DIR)/path_mis_qubo.json; \ \ echo ""; \ echo "--- 7. export-graph: full reduction graph ---"; \ @@ -307,7 +309,7 @@ cli-demo: cli echo ""; \ echo "--- 8. create: build problem instances ---"; \ $$PRED create MIS --graph 0-1,1-2,2-3,3-4,4-0 -o $(CLI_DEMO_DIR)/mis.json; \ - $$PRED create MIS --graph 0-1,1-2,2-3 --weights 2,1,3,1 -o $(CLI_DEMO_DIR)/mis_weighted.json; \ + $$PRED create MaximumIndependentSet/SimpleGraph/i32 --graph 0-1,1-2,2-3 --weights 2,1,3,1 -o $(CLI_DEMO_DIR)/mis_weighted.json; \ $$PRED create SAT --num-vars 3 --clauses "1,2;-1,3;2,-3" -o $(CLI_DEMO_DIR)/sat.json; \ $$PRED create 3SAT --num-vars 4 --clauses "1,2,3;-1,2,-3;1,-2,3" -o $(CLI_DEMO_DIR)/3sat.json; \ $$PRED create QUBO --matrix "1,-0.5;-0.5,2" -o $(CLI_DEMO_DIR)/qubo.json; \ @@ -340,8 +342,8 @@ cli-demo: cli $$PRED solve $(CLI_DEMO_DIR)/mis_weighted.json; \ \ echo ""; \ - echo "--- 13. reduce: MIS → QUBO (auto-discover path) ---"; \ - $$PRED reduce $(CLI_DEMO_DIR)/mis.json --to QUBO -o $(CLI_DEMO_DIR)/bundle_qubo.json; \ + echo "--- 13. reduce: MIS → QUBO along the explicitly chosen route ---"; \ + $$PRED reduce $(CLI_DEMO_DIR)/mis.json --via $(CLI_DEMO_DIR)/path_mis_qubo.json -o $(CLI_DEMO_DIR)/bundle_qubo.json; \ \ echo ""; \ echo "--- 14. solve bundle: brute-force on reduced QUBO ---"; \ @@ -353,7 +355,9 @@ cli-demo: cli \ echo ""; \ echo "--- 16. solve bundle with ILP: MIS → MVC → ILP ---"; \ - $$PRED reduce $(CLI_DEMO_DIR)/mis.json --to MVC -o $(CLI_DEMO_DIR)/bundle_mvc.json; \ + $$PRED path MIS MVC -o $(CLI_DEMO_DIR)/paths_mis_mvc.json; \ + jq -e 'first(.paths[] | select(([.path[0].from.name] + [.path[].to.name]) == ["MaximumIndependentSet", "MaximumIndependentSet", "MinimumVertexCover"]))' $(CLI_DEMO_DIR)/paths_mis_mvc.json > $(CLI_DEMO_DIR)/path_mis_mvc.json; \ + $$PRED reduce $(CLI_DEMO_DIR)/mis.json --via $(CLI_DEMO_DIR)/path_mis_mvc.json -o $(CLI_DEMO_DIR)/bundle_mvc.json; \ $$PRED solve $(CLI_DEMO_DIR)/bundle_mvc.json --solver ilp; \ \ echo ""; \ @@ -370,7 +374,7 @@ cli-demo: cli echo "Solving with ILP..."; \ $$PRED solve $(CLI_DEMO_DIR)/big.json -o $(CLI_DEMO_DIR)/big_sol.json; \ echo "Reducing to QUBO and solving with brute-force..."; \ - $$PRED reduce $(CLI_DEMO_DIR)/big.json --to QUBO -o $(CLI_DEMO_DIR)/big_qubo.json; \ + $$PRED reduce $(CLI_DEMO_DIR)/big.json --via $(CLI_DEMO_DIR)/path_mis_qubo.json -o $(CLI_DEMO_DIR)/big_qubo.json; \ $$PRED solve $(CLI_DEMO_DIR)/big_qubo.json --solver brute-force -o $(CLI_DEMO_DIR)/big_qubo_sol.json; \ echo "Verifying both solutions have the same evaluation..."; \ ILP_EVAL=$$(jq -r '.evaluation' $(CLI_DEMO_DIR)/big_sol.json); \ From ae2d76358957132f12459909f651f1734382d991 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 14 Aug 2026 20:58:15 +0800 Subject: [PATCH 06/15] Restore customized solver naming --- docs/src/cli.md | 5 +- docs/src/getting-started.md | 2 +- docs/src/mcp.md | 2 +- problemreductions-cli/src/cli.rs | 5 +- problemreductions-cli/src/commands/inspect.rs | 6 +- problemreductions-cli/src/commands/solve.rs | 6 +- problemreductions-cli/src/dispatch.rs | 39 ++++--- problemreductions-cli/src/mcp/tests.rs | 24 ++-- problemreductions-cli/src/mcp/tools.rs | 4 +- problemreductions-cli/tests/cli_tests.rs | 26 ++--- .../fd_subset_search.rs | 0 src/solvers/{native => customized}/mod.rs | 2 +- .../partial_feedback_edge_set.rs | 0 .../rooted_tree_arrangement.rs | 0 src/solvers/{native => customized}/solver.rs | 26 ++--- src/solvers/mod.rs | 4 +- src/solvers/pipelines.rs | 4 +- src/solvers/registry.rs | 61 +++++----- src/solvers/resolver.rs | 31 +++-- .../solvers/{native => customized}/solver.rs | 106 +++++++++--------- src/unit_tests/solvers/registry.rs | 42 ++++--- src/unit_tests/solvers/resolver.rs | 47 ++++++-- 22 files changed, 259 insertions(+), 183 deletions(-) rename src/solvers/{native => customized}/fd_subset_search.rs (100%) rename src/solvers/{native => customized}/mod.rs (87%) rename src/solvers/{native => customized}/partial_feedback_edge_set.rs (100%) rename src/solvers/{native => customized}/rooted_tree_arrangement.rs (100%) rename src/solvers/{native => customized}/solver.rs (93%) rename src/unit_tests/solvers/{native => customized}/solver.rs (78%) diff --git a/docs/src/cli.md b/docs/src/cli.md index 5e94294c7..823be1492 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -335,10 +335,11 @@ The bundle contains everything needed to map solutions back: ### `pred solve` — Solve a problem -Solve a problem instance using ILP (default), brute-force, or the customized solver: +Solve a problem instance using deterministic customized → ILP → brute-force dispatch, +or explicitly require one solver: ```bash -pred solve problem.json # ILP solver (default) +pred solve problem.json # customized, then ILP, then brute-force pred solve problem.json --solver brute-force # brute-force solver pred solve problem.json --solver customized # structure-exploiting exact solver pred solve problem.json --timeout 30 # abort after 30 seconds diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index d8db70fc6..791a656d8 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -174,7 +174,7 @@ Three solvers are available: |--------|----------|-------| | [`BruteForce`](api/problemreductions/solvers/struct.BruteForce.html) | Small instances (<20 variables) | Enumerates all configurations | | [`ILPSolver`](api/problemreductions/solvers/ilp/struct.ILPSolver.html) | Larger instances | Uses the bundled HiGHS backend | -| [`CustomizedSolver`](api/problemreductions/solvers/customized/struct.CustomizedSolver.html) | Structure-exploiting | Uses problem-specific exact algorithms | +| **Customized backend** | Structure-exploiting | Uses problem-specific exact algorithms registered for exact problem variants | ILP support through HiGHS is part of the library and is always available. diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 396b3b675..52ca0461b 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -90,7 +90,7 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `inspect_problem` | `problem_json` (string) | Inspect a problem JSON or reduction bundle: returns type, size metrics, available solvers, and reduction targets | | `evaluate` | `problem_json` (string), `config` (array of int) | Evaluate a configuration against a problem instance and return the objective value or feasibility | | `reduce` | `problem_json` (string), `path_json` (string) | Reduce a problem instance along an explicitly supplied route, returning a bundle with the transformed instance and path metadata | -| `solve` | `problem_json` (string), `solver` ("ilp"\|"brute-force", default: "ilp"), `timeout` (int, default: 0) | Solve a problem instance or reduction bundle using ILP or brute-force, with optional timeout | +| `solve` | `problem_json` (string), optional `solver` ("customized"\|"ilp"\|"brute-force"), `timeout` (int, default: 0) | Solve a problem instance or reduction bundle using deterministic customized → ILP → brute-force dispatch, with optional override and timeout | ## Available Prompts diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 78c5c024e..91f6d844d 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -274,6 +274,7 @@ pub enum ExampleSide { #[command(after_help = "\ Examples: pred solve problem.json # deterministic registered backend or fallback + pred solve problem.json --solver customized # require a problem-specific solver pred solve problem.json --solver brute-force # brute-force (exhaustive search) pred solve problem.json --solver ilp # require the registered fixed ILP pipeline pred solve reduced.json # solve a reduction bundle @@ -294,14 +295,14 @@ Solve via explicit reduction: Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`. When given a bundle, the target is solved and the solution is mapped back to the source. -By default, solve deterministically selects the exact variant's registered native +By default, solve deterministically selects the exact variant's registered customized backend, then its fixed ILP pipeline, and otherwise brute force. `--solver ilp` requires a registered ILP pipeline; it never searches the reduction graph. ILP problems are solved with HiGHS.")] pub struct SolveArgs { /// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin. pub input: PathBuf, - /// Solver override: ilp or brute-force. Omit for deterministic default dispatch. + /// Solver override: customized, ilp, or brute-force. Omit for deterministic default dispatch. #[arg(long)] pub solver: Option, /// Timeout in seconds (0 = no limit) diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index c2d98ca1f..e99f5f337 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -46,10 +46,10 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { let solver_view = solver_capabilities_view(&problem)?; text.push_str(&format!("Default solver: {}\n", solver_view.default_solver)); text.push_str(&format!("Solvers: {}\n", solver_view.solvers.join(", "))); - if let Some(native) = solver_view.capabilities.native.as_ref() { + if let Some(customized) = solver_view.capabilities.customized.as_ref() { text.push_str(&format!( - "Native implementation: {}\n", - native.implementation + "Customized implementation: {}\n", + customized.implementation )); } if let Some(ilp) = solver_view.capabilities.ilp.as_ref() { diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 661959988..44b902cec 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -34,7 +34,7 @@ fn parse_input(path: &Path) -> Result { fn solver_text(solver: &SolverExecution) -> String { match solver { - SolverExecution::Native { implementation } => format!("native ({implementation})"), + SolverExecution::Customized { implementation } => format!("customized ({implementation})"), SolverExecution::Ilp { reduction_path } => { format!("ilp ({})", reduction_path.join(" -> ")) } @@ -148,7 +148,9 @@ fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputCon fn add_solver_hint(err: anyhow::Error) -> anyhow::Error { let message = err.to_string(); - if message.starts_with("No ILP pipeline is registered for ") { + if message.starts_with("No ILP pipeline is registered for ") + || message.starts_with("No customized solver is registered for ") + { anyhow::anyhow!( "{message}\n\nHint: try `--solver brute-force` for direct exhaustive search on small instances." ) diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 0fcfb634c..23ea55fbe 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -48,7 +48,7 @@ impl LoadedProblem { } #[derive(Clone, Debug, serde::Serialize)] -pub struct NativeSolverCapabilityView { +pub struct CustomizedSolverCapabilityView { pub implementation: &'static str, } @@ -59,7 +59,7 @@ pub struct IlpSolverCapabilityView { #[derive(Clone, Debug, serde::Serialize)] pub struct SolverCapabilityDetailsView { - pub native: Option, + pub customized: Option, pub ilp: Option, pub brute_force: bool, } @@ -75,22 +75,24 @@ pub fn solver_capabilities_view(problem: &LoadedProblem) -> Result Result Result) -> Result { match solver_name { None => Ok(SolverRequest::Default), + Some("customized") => Ok(SolverRequest::Customized), Some("ilp") => Ok(SolverRequest::Ilp), Some("brute-force") => Ok(SolverRequest::BruteForce), Some(other) => { - anyhow::bail!("Unknown solver: {other}. Available solver overrides: brute-force, ilp") + anyhow::bail!( + "Unknown solver: {other}. Available solver overrides: customized, ilp, brute-force" + ) } } } @@ -510,12 +515,16 @@ mod tests { #[test] fn solver_request_accepts_only_documented_overrides() { assert_eq!(solver_request(None).unwrap(), SolverRequest::Default); + assert_eq!( + solver_request(Some("customized")).unwrap(), + SolverRequest::Customized + ); assert_eq!(solver_request(Some("ilp")).unwrap(), SolverRequest::Ilp); assert_eq!( solver_request(Some("brute-force")).unwrap(), SolverRequest::BruteForce ); - for rejected in ["auto", "customized", "native", "implementation-id"] { + for rejected in ["auto", "native", "implementation-id"] { let error = solver_request(Some(rejected)).unwrap_err(); assert!(error.to_string().contains(rejected), "{error}"); } @@ -556,9 +565,9 @@ mod tests { .unwrap(); let view = solver_capabilities_view(&loaded).unwrap(); - assert_eq!(view.default_solver, "native"); - assert_eq!(view.solvers, ["native", "ilp", "brute-force"]); - assert!(view.capabilities.native.is_some()); + assert_eq!(view.default_solver, "customized"); + assert_eq!(view.solvers, ["customized", "ilp", "brute-force"]); + assert!(view.capabilities.customized.is_some()); assert!(view.capabilities.ilp.is_some()); assert!(view.capabilities.brute_force); } diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 0e72bd164..b7894203c 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -352,7 +352,7 @@ fn test_solve_ilp() { } #[test] -fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { +fn deterministic_solver_dispatch_defaults_supported_problem_to_customized() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -368,19 +368,25 @@ fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { 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"]["kind"], "customized"); assert_eq!( json["solver"]["implementation"], "fd-minimum-cardinality-key" ); assert!(json["solution"].is_array(), "{json}"); + + let explicit = server + .solve_inner(&problem_json, Some("customized"), None) + .unwrap(); + let explicit_json: serde_json::Value = serde_json::from_str(&explicit).unwrap(); + assert_eq!(explicit_json["solver"]["kind"], "customized"); } #[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"] { + for rejected in ["auto", "native", "fd-minimum-cardinality-key"] { let error = server .solve_inner(&problem_json, Some(rejected), None) .unwrap_err(); @@ -406,7 +412,7 @@ fn deterministic_solver_dispatch_mcp_output_is_repeatable_for_each_solver_class( }) .to_string(); - for solver in [None, Some("ilp"), Some("brute-force")] { + for solver in [None, Some("customized"), 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"); @@ -483,7 +489,7 @@ fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability } #[test] -fn test_solve_bundle_rejects_removed_customized_override() { +fn test_solve_bundle_rejects_unavailable_customized_solver() { let server = McpServer::new(); let problem_json = create_test_mis(&server); let bundle_json = server @@ -506,7 +512,7 @@ fn test_solve_bundle_rejects_removed_customized_override() { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("Unknown solver: customized"), + err.contains("No customized solver is registered"), "unexpected error: {err}" ); } @@ -564,7 +570,7 @@ fn test_inspect_minmaxmulticenter_reports_registered_ilp_pipeline() { } #[test] -fn test_inspect_minimum_cardinality_key_reports_native_solver() { +fn test_inspect_minimum_cardinality_key_reports_customized_solver() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -580,9 +586,9 @@ fn test_inspect_minimum_cardinality_key_reports_native_solver() { 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["default_solver"], "customized"); assert_eq!( - json["solver_capabilities"]["native"]["implementation"], + json["solver_capabilities"]["customized"]["implementation"], "fd-minimum-cardinality-key" ); } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 0f851c95e..0491ff52f 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -89,7 +89,9 @@ pub struct ReduceParams { pub struct SolveParams { #[schemars(description = "Problem JSON string (from create_problem or reduce)")] pub problem_json: String, - #[schemars(description = "Solver override: 'ilp' or 'brute-force'; omit for default dispatch")] + #[schemars( + description = "Solver override: 'customized', 'ilp', or 'brute-force'; omit for default dispatch" + )] pub solver: Option, #[schemars(description = "Timeout in seconds (0 = no limit, default: 0)")] pub timeout: Option, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7593a9896..7b3c46a98 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3055,7 +3055,7 @@ fn test_solve_ilp() { #[test] fn test_solve_ilp_default() { - // MIS has no native solver, so its registered ILP pipeline is the default. + // MIS has no customized solver, so its registered ILP pipeline is the default. let problem_file = std::env::temp_dir().join("pred_test_solve_default.json"); let create_out = pred() .args([ @@ -8992,7 +8992,7 @@ fn deterministic_solver_dispatch_rejects_non_override_solver_names() { .unwrap(); assert!(create_out.status.success()); - for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + for rejected in ["auto", "native", "fd-minimum-cardinality-key"] { let output = pred() .args([ "solve", @@ -9026,7 +9026,7 @@ fn deterministic_solver_dispatch_cli_output_is_repeatable_for_each_solver_class( }); std::fs::write(&problem_file, serde_json::to_vec(&problem).unwrap()).unwrap(); - for solver in [None, Some("ilp"), Some("brute-force")] { + for solver in [None, Some("customized"), Some("ilp"), Some("brute-force")] { let run = || { let mut command = pred(); command.args(["--json", "solve", problem_file.to_str().unwrap()]); @@ -9054,7 +9054,7 @@ fn deterministic_solver_dispatch_cli_output_is_repeatable_for_each_solver_class( } #[test] -fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_native() { +fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_customized() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_mck.json"); let create_out = pred() .args([ @@ -9086,7 +9086,7 @@ fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_native() { ); let stdout = String::from_utf8(output.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(json["solver"]["kind"], "native"); + assert_eq!(json["solver"]["kind"], "customized"); assert_eq!( json["solver"]["implementation"], "fd-minimum-cardinality-key" @@ -9100,7 +9100,7 @@ fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_native() { } #[test] -fn test_solve_bundle_rejects_removed_customized_override_without_panicking() { +fn test_solve_bundle_rejects_unavailable_customized_solver_without_panicking() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_bundle_problem.json"); let bundle_file = std::env::temp_dir().join("pred_test_solve_customized_bundle.json"); @@ -9148,15 +9148,15 @@ fn test_solve_bundle_rejects_removed_customized_override_without_panicking() { let stderr = String::from_utf8_lossy(&solve_out.stderr); assert!( !stderr.contains("panicked at"), - "removed override should fail gracefully, got: {stderr}" + "unavailable customized solver should fail gracefully, got: {stderr}" ); assert!( !solve_out.status.success(), - "removed solver override should not silently succeed" + "unavailable customized solver should not silently succeed" ); assert!( - stderr.contains("Unknown solver: customized"), - "expected removed solver error, got: {stderr}" + stderr.contains("No customized solver is registered"), + "expected missing customized capability error, got: {stderr}" ); std::fs::remove_file(&problem_file).ok(); @@ -9164,7 +9164,7 @@ fn test_solve_bundle_rejects_removed_customized_override_without_panicking() { } #[test] -fn test_inspect_minimum_cardinality_key_reports_native_capability() { +fn test_inspect_minimum_cardinality_key_reports_customized_capability() { let problem_file = std::env::temp_dir().join("pred_test_inspect_customized_mck.json"); let create_out = pred() .args([ @@ -9197,9 +9197,9 @@ fn test_inspect_minimum_cardinality_key_reports_native_capability() { let stdout = String::from_utf8(inspect_out.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(json["default_solver"], "native"); + assert_eq!(json["default_solver"], "customized"); assert_eq!( - json["solver_capabilities"]["native"]["implementation"], + json["solver_capabilities"]["customized"]["implementation"], "fd-minimum-cardinality-key" ); diff --git a/src/solvers/native/fd_subset_search.rs b/src/solvers/customized/fd_subset_search.rs similarity index 100% rename from src/solvers/native/fd_subset_search.rs rename to src/solvers/customized/fd_subset_search.rs diff --git a/src/solvers/native/mod.rs b/src/solvers/customized/mod.rs similarity index 87% rename from src/solvers/native/mod.rs rename to src/solvers/customized/mod.rs index 6625219fa..313410d96 100644 --- a/src/solvers/native/mod.rs +++ b/src/solvers/customized/mod.rs @@ -1,4 +1,4 @@ -//! Dedicated native solver backends. +//! Dedicated customized solver backends. //! //! Each backend is registered for one exact problem variant. Dispatch is //! performed by the solver capability registry rather than a downcast chain. diff --git a/src/solvers/native/partial_feedback_edge_set.rs b/src/solvers/customized/partial_feedback_edge_set.rs similarity index 100% rename from src/solvers/native/partial_feedback_edge_set.rs rename to src/solvers/customized/partial_feedback_edge_set.rs diff --git a/src/solvers/native/rooted_tree_arrangement.rs b/src/solvers/customized/rooted_tree_arrangement.rs similarity index 100% rename from src/solvers/native/rooted_tree_arrangement.rs rename to src/solvers/customized/rooted_tree_arrangement.rs diff --git a/src/solvers/native/solver.rs b/src/solvers/customized/solver.rs similarity index 93% rename from src/solvers/native/solver.rs rename to src/solvers/customized/solver.rs index de5dc46a0..94c2cb54e 100644 --- a/src/solvers/native/solver.rs +++ b/src/solvers/customized/solver.rs @@ -1,4 +1,4 @@ -//! Exact native solvers and their exact-variant registrations. +//! Exact customized solvers and their exact-variant registrations. use super::fd_subset_search::{ self, compute_closure, find_essential_attributes, find_essential_attributes_restricted, @@ -7,21 +7,21 @@ use super::fd_subset_search::{ use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation, TimetableDesign}; use crate::models::set::{MinimumCardinalityKey, PrimeAttributeName}; -use crate::solvers::registry::NativeSolverRegistration; +use crate::solvers::registry::CustomizedSolverRegistration; use crate::topology::SimpleGraph; use crate::traits::Problem; use std::collections::HashSet; -macro_rules! register_native_solver { +macro_rules! register_customized_solver { ($problem:ty, $implementation:literal, $solve:path) => { inventory::submit! { - NativeSolverRegistration { + CustomizedSolverRegistration { source_name: <$problem as Problem>::NAME, source_variant_fn: <$problem as Problem>::variant, implementation: $implementation, solve_fn: |any| { let problem = any.downcast_ref::<$problem>().expect( - "native solver registration received the wrong concrete type", + "customized solver registration received the wrong concrete type", ); $solve(problem) }, @@ -30,33 +30,33 @@ macro_rules! register_native_solver { }; } -register_native_solver!( +register_customized_solver!( MinimumCardinalityKey, "fd-minimum-cardinality-key", solve_minimum_cardinality_key ); -register_native_solver!(AdditionalKey, "fd-additional-key", solve_additional_key); -register_native_solver!( +register_customized_solver!(AdditionalKey, "fd-additional-key", solve_additional_key); +register_customized_solver!( PrimeAttributeName, "fd-prime-attribute-name", solve_prime_attribute_name ); -register_native_solver!( +register_customized_solver!( BoyceCoddNormalFormViolation, "fd-bcnf-violation", solve_bcnf_violation ); -register_native_solver!( +register_customized_solver!( PartialFeedbackEdgeSet, "partial-feedback-edge-set", super::partial_feedback_edge_set::find_witness ); -register_native_solver!( +register_customized_solver!( RootedTreeArrangement, "rooted-tree-arrangement", super::rooted_tree_arrangement::find_witness ); -register_native_solver!( +register_customized_solver!( TimetableDesign, "timetable-required-assignments", TimetableDesign::solve_via_required_assignments @@ -259,5 +259,5 @@ pub(crate) fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Op } #[cfg(test)] -#[path = "../../unit_tests/solvers/native/solver.rs"] +#[path = "../../unit_tests/solvers/customized/solver.rs"] mod tests; diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index c3864765b..4aca2cc7a 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -1,8 +1,8 @@ //! Solvers for computational problems. mod brute_force; +mod customized; pub mod decision_search; -mod native; mod pipelines; mod registry; mod resolver; @@ -11,7 +11,7 @@ pub mod ilp; pub use brute_force::BruteForce; pub use registry::{ - solver_capabilities, ExactProblemKey, IlpSolverCapability, NativeSolverCapability, + solver_capabilities, CustomizedSolverCapability, ExactProblemKey, IlpSolverCapability, RegistryBuildError, SolverCapabilities, }; pub use resolver::{ diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index 57c1b366d..1b280dec4 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -206,8 +206,8 @@ register_ilp_pipeline! { ("ILP", [("variable", "i32")]), } -// This exact variant also has a native backend. Default dispatch selects the -// native registration, while an explicit ILP override executes this pipeline. +// This exact variant also has a customized backend. Default dispatch selects the +// customized registration, while an explicit ILP override executes this pipeline. register_ilp_pipeline! { ("RootedTreeArrangement", [("graph", "SimpleGraph")]), ("RootedTreeStorageAssignment", []), diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index f69aa25de..ad35df39a 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -74,18 +74,18 @@ pub(crate) struct IlpPipelineRegistration { inventory::collect!(IlpPipelineRegistration); -type NativeSolveFn = fn(&dyn Any) -> Option>; +type CustomizedSolveFn = fn(&dyn Any) -> Option>; /// A dedicated solver registered for one exact problem variant. #[derive(Debug)] -pub(crate) struct NativeSolverRegistration { +pub(crate) struct CustomizedSolverRegistration { pub(crate) source_name: &'static str, pub(crate) source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, pub(crate) implementation: &'static str, - pub(crate) solve_fn: NativeSolveFn, + pub(crate) solve_fn: CustomizedSolveFn, } -impl NativeSolverRegistration { +impl CustomizedSolverRegistration { fn source_key(&self) -> ExactProblemKey { ExactProblemKey::new( self.source_name, @@ -97,7 +97,7 @@ impl NativeSolverRegistration { } } -inventory::collect!(NativeSolverRegistration); +inventory::collect!(CustomizedSolverRegistration); #[derive(Debug)] pub(crate) struct CompiledIlpPipeline { @@ -147,22 +147,25 @@ impl CompiledIlpPipeline { #[derive(Clone, Copy)] pub(crate) struct RegisteredSolverCapabilities<'a> { - pub(crate) native: Option<&'static NativeSolverRegistration>, + pub(crate) customized: Option<&'static CustomizedSolverRegistration>, pub(crate) ilp: Option<&'a CompiledIlpPipeline>, } impl std::fmt::Debug for RegisteredSolverCapabilities<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SolverCapabilities") - .field("native", &self.native.map(|entry| entry.implementation)) + .field( + "customized", + &self.customized.map(|entry| entry.implementation), + ) .field("ilp", &self.ilp.map(CompiledIlpPipeline::path)) .finish() } } -/// Read-only metadata for a registered native solver. +/// Read-only metadata for a registered customized solver. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct NativeSolverCapability { +pub struct CustomizedSolverCapability { pub implementation: &'static str, } @@ -185,29 +188,29 @@ impl IlpSolverCapability { /// Read-only solver capabilities for one exact problem variant. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SolverCapabilities { - pub native: Option, + pub customized: Option, pub ilp: Option, } #[derive(Debug, Default)] pub(crate) struct SolverCapabilityRegistry { - native: BTreeMap, + customized: BTreeMap, ilp: BTreeMap, } impl SolverCapabilityRegistry { pub(crate) fn lookup(&self, key: &ExactProblemKey) -> RegisteredSolverCapabilities<'_> { RegisteredSolverCapabilities { - native: self.native.get(key).copied(), + customized: self.customized.get(key).copied(), ilp: self.ilp.get(key), } } #[cfg(test)] - pub(crate) fn native_entries( + pub(crate) fn customized_entries( &self, - ) -> impl Iterator + '_ { - self.native.iter().map(|(key, entry)| (key, *entry)) + ) -> impl Iterator + '_ { + self.customized.iter().map(|(key, entry)| (key, *entry)) } #[cfg(test)] @@ -222,8 +225,8 @@ impl SolverCapabilityRegistry { pub enum RegistryBuildError { #[error("solver registration references unknown exact variant {0}")] UnknownVariant(String), - #[error("duplicate native solver registration for {0}")] - DuplicateNative(String), + #[error("duplicate customized solver registration for {0}")] + DuplicateCustomized(String), #[error("duplicate ILP pipeline registration for {0}")] DuplicateIlp(String), #[error("ILP pipeline must contain at least one node")] @@ -263,7 +266,7 @@ fn edge_key(entry: &ReductionEntry, source: bool) -> ExactProblemKey { fn build_registry( variants: &BTreeSet, - native_entries: impl IntoIterator, + customized_entries: impl IntoIterator, pipeline_entries: impl IntoIterator, reductions: &[&'static ReductionEntry], ) -> Result { @@ -281,13 +284,17 @@ fn build_registry( .push(entry); } - for native in native_entries { - let source = native.source_key(); + for customized in customized_entries { + let source = customized.source_key(); if !variants.contains(&source) { return Err(RegistryBuildError::UnknownVariant(source.label())); } - if registry.native.insert(source.clone(), native).is_some() { - return Err(RegistryBuildError::DuplicateNative(source.label())); + if registry + .customized + .insert(source.clone(), customized) + .is_some() + { + return Err(RegistryBuildError::DuplicateCustomized(source.label())); } } @@ -357,7 +364,7 @@ pub(crate) fn solver_capability_registry( .get_or_init(|| { build_registry( ®istered_variant_keys(), - inventory::iter::(), + inventory::iter::(), inventory::iter::(), &reduction_entries(), ) @@ -371,9 +378,11 @@ pub fn solver_capabilities( ) -> Result { let registered = solver_capability_registry()?.lookup(key); Ok(SolverCapabilities { - native: registered.native.map(|entry| NativeSolverCapability { - implementation: entry.implementation, - }), + customized: registered + .customized + .map(|entry| CustomizedSolverCapability { + implementation: entry.implementation, + }), ilp: registered.ilp.map(|pipeline| IlpSolverCapability { path: pipeline.path.clone(), }), diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs index 58e289001..9bbcdad22 100644 --- a/src/solvers/resolver.rs +++ b/src/solvers/resolver.rs @@ -2,7 +2,7 @@ use super::registry::CompiledIlpPipeline; use super::registry::{ - solver_capability_registry, ExactProblemKey, NativeSolverRegistration, RegistryBuildError, + solver_capability_registry, CustomizedSolverRegistration, ExactProblemKey, RegistryBuildError, }; use crate::registry::LoadedDynProblem; use serde::Serialize; @@ -12,6 +12,7 @@ use serde::Serialize; pub enum SolverRequest { #[default] Default, + Customized, Ilp, BruteForce, } @@ -20,7 +21,7 @@ pub enum SolverRequest { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum SolverExecution { - Native { implementation: &'static str }, + Customized { implementation: &'static str }, Ilp { reduction_path: Vec }, BruteForce, } @@ -39,8 +40,10 @@ pub enum DeterministicSolveError { InvalidRegistry(&'static RegistryBuildError), #[error("No ILP pipeline is registered for {0}")] MissingIlpCapability(String), - #[error("native solver found no solution for {problem}")] - NativeNoSolution { problem: String }, + #[error("No customized solver is registered for {0}")] + MissingCustomizedCapability(String), + #[error("customized solver found no solution for {problem}")] + CustomizedNoSolution { problem: String }, #[error("ILP solver failed for {problem}: {source}")] IlpSolve { problem: String, @@ -53,18 +56,18 @@ fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { ExactProblemKey::new(problem.problem_name(), problem.variant_map()) } -fn solve_native( +fn solve_customized( problem: &LoadedDynProblem, - registration: &'static NativeSolverRegistration, + registration: &'static CustomizedSolverRegistration, ) -> Result { let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { - DeterministicSolveError::NativeNoSolution { + DeterministicSolveError::CustomizedNoSolution { problem: problem_key(problem).label(), } })?; let evaluation = problem.evaluate_dyn(&config); Ok(DeterministicSolveResult { - solver: SolverExecution::Native { + solver: SolverExecution::Customized { implementation: registration.implementation, }, config: Some(config), @@ -109,7 +112,7 @@ fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { /// Solve a loaded problem using deterministic exact-variant dispatch. /// -/// Default dispatch is native, then the registered fixed ILP pipeline, then +/// Default dispatch is customized, then the registered fixed ILP pipeline, then /// brute force. Once selected, backend failure is returned without fallback. pub fn solve_deterministically( problem: &LoadedDynProblem, @@ -126,6 +129,12 @@ pub fn solve_deterministically( match request { SolverRequest::BruteForce => unreachable!("handled before registry initialization"), + SolverRequest::Customized => { + let registration = capabilities + .customized + .ok_or_else(|| DeterministicSolveError::MissingCustomizedCapability(key.label()))?; + solve_customized(problem, registration) + } SolverRequest::Ilp => { let pipeline = capabilities .ilp @@ -133,8 +142,8 @@ pub fn solve_deterministically( solve_ilp(problem, pipeline) } SolverRequest::Default => { - if let Some(native) = capabilities.native { - return solve_native(problem, native); + if let Some(customized) = capabilities.customized { + return solve_customized(problem, customized); } if let Some(pipeline) = capabilities.ilp { return solve_ilp(problem, pipeline); diff --git a/src/unit_tests/solvers/native/solver.rs b/src/unit_tests/solvers/customized/solver.rs similarity index 78% rename from src/unit_tests/solvers/native/solver.rs rename to src/unit_tests/solvers/customized/solver.rs index dabfd8531..1f1869a44 100644 --- a/src/unit_tests/solvers/native/solver.rs +++ b/src/unit_tests/solvers/customized/solver.rs @@ -5,9 +5,9 @@ use crate::solvers::ExactProblemKey; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -struct NativeTestSolver; +struct CustomizedTestSolver; -impl NativeTestSolver { +impl CustomizedTestSolver { fn new() -> Self { Self } @@ -23,7 +23,7 @@ impl NativeTestSolver { solver_capability_registry() .unwrap() .lookup(&key) - .native + .customized .and_then(|registration| (registration.solve_fn)(problem)) } } @@ -61,22 +61,22 @@ fn exact_rooted_tree_arrangement_min_stretch(graph: &SimpleGraph) -> Option Option> { None } -static NATIVE_A: NativeSolverRegistration = NativeSolverRegistration { +static CUSTOMIZED_A: CustomizedSolverRegistration = CustomizedSolverRegistration { source_name: "Source", source_variant_fn: source_variant, - implementation: "native-a", + implementation: "customized-a", solve_fn: no_solution, }; -static NATIVE_B: NativeSolverRegistration = NativeSolverRegistration { +static CUSTOMIZED_B: CustomizedSolverRegistration = CustomizedSolverRegistration { source_name: "Source", source_variant_fn: source_variant, - implementation: "native-b", + implementation: "customized-b", solve_fn: no_solution, }; @@ -103,16 +103,22 @@ fn solver_capability_registry_duplicate_ilp_registration_is_rejected_independent } #[test] -fn solver_capability_registry_duplicate_native_registration_is_rejected() { +fn solver_capability_registry_duplicate_customized_registration_is_rejected() { let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); - let error = - build_registry(&variants, [&NATIVE_A, &NATIVE_B], std::iter::empty(), &[]).unwrap_err(); - assert!(matches!(error, RegistryBuildError::DuplicateNative(_))); + let error = build_registry( + &variants, + [&CUSTOMIZED_A, &CUSTOMIZED_B], + std::iter::empty(), + &[], + ) + .unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateCustomized(_))); } #[test] -fn solver_capability_registry_unknown_native_variant_is_rejected() { - let error = build_registry(&BTreeSet::new(), [&NATIVE_A], std::iter::empty(), &[]).unwrap_err(); +fn solver_capability_registry_unknown_customized_variant_is_rejected() { + let error = + build_registry(&BTreeSet::new(), [&CUSTOMIZED_A], std::iter::empty(), &[]).unwrap_err(); assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); } @@ -177,7 +183,7 @@ fn solver_capability_registry_pipeline_must_stop_at_first_supported_ilp_node() { #[test] fn solver_capability_registry_production_registry_has_expected_exact_capability_counts() { let registry = solver_capability_registry().unwrap(); - assert_eq!(registry.native_entries().count(), 7); + assert_eq!(registry.customized_entries().count(), 7); assert_eq!(registry.ilp_entries().count(), 151); } @@ -193,19 +199,19 @@ fn solver_capability_registry_exposes_representative_capability_classes() { ) }; - let native_only = solver_capabilities(&key("TimetableDesign", &[])).unwrap(); + let customized_only = solver_capabilities(&key("TimetableDesign", &[])).unwrap(); assert_eq!( - native_only.native.unwrap().implementation, + customized_only.customized.unwrap().implementation, "timetable-required-assignments" ); - assert!(native_only.ilp.is_none()); + assert!(customized_only.ilp.is_none()); let direct_ilp = solver_capabilities(&key( "MaximumClique", &[("graph", "SimpleGraph"), ("weight", "i32")], )) .unwrap(); - assert!(direct_ilp.native.is_none()); + assert!(direct_ilp.customized.is_none()); assert_eq!( direct_ilp.ilp.unwrap().path_labels(), ["MaximumClique", "ILP"] @@ -220,7 +226,7 @@ fn solver_capability_registry_exposes_representative_capability_classes() { let both = solver_capabilities(&key("RootedTreeArrangement", &[("graph", "SimpleGraph")])).unwrap(); - assert!(both.native.is_some()); + assert!(both.customized.is_some()); assert!(both.ilp.is_some()); let brute_force_only = solver_capabilities(&key( @@ -228,7 +234,7 @@ fn solver_capability_registry_exposes_representative_capability_classes() { &[("graph", "SimpleGraph"), ("weight", "i32")], )) .unwrap(); - assert!(brute_force_only.native.is_none()); + assert!(brute_force_only.customized.is_none()); assert!(brute_force_only.ilp.is_none()); let ilp_itself = solver_capabilities(&key("ILP", &[("variable", "bool")])).unwrap(); @@ -243,7 +249,7 @@ fn solver_capability_registry_does_not_leak_across_exact_variants() { BTreeMap::from([("unexpected".to_string(), "variant".to_string())]), ); let capabilities = registry.lookup(&key); - assert!(capabilities.native.is_none()); + assert!(capabilities.customized.is_none()); assert!(capabilities.ilp.is_none()); } diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index 7e62066ab..70e6e7739 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -5,7 +5,7 @@ use crate::traits::Problem; use std::collections::BTreeMap; #[test] -fn deterministic_solver_dispatch_native_registration_wins_default_dispatch() { +fn deterministic_solver_dispatch_customized_registration_wins_default_dispatch() { use crate::models::set::MinimumCardinalityKey; let problem = MinimumCardinalityKey::new(3, vec![(vec![0], vec![1, 2])]); @@ -19,10 +19,36 @@ fn deterministic_solver_dispatch_native_registration_wins_default_dispatch() { let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); assert_eq!( result.solver, - SolverExecution::Native { + SolverExecution::Customized { implementation: "fd-minimum-cardinality-key" } ); + + let explicit = solve_deterministically(&loaded, SolverRequest::Customized).unwrap(); + assert_eq!(explicit, result); +} + +#[test] +fn deterministic_solver_dispatch_unregistered_customized_override_is_a_capability_error() { + use crate::models::graph::MaxCut; + use crate::topology::SimpleGraph; + + let problem = MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]); + let loaded = crate::registry::load_dyn( + MaxCut::::NAME, + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Customized).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::MissingCustomizedCapability(_) + )); } #[test] @@ -55,11 +81,11 @@ fn deterministic_solver_dispatch_unregistered_ilp_override_is_a_capability_error } #[test] -fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { +fn deterministic_solver_dispatch_customized_failure_does_not_fall_back() { use crate::models::misc::AdditionalKey; // {0} is the only candidate key and it is already known, so the registered - // native solver has no witness. Brute force can still report the aggregate + // customized solver has no witness. Brute force can still report the aggregate // infeasibility result, which lets this test distinguish fallback from error. let problem = AdditionalKey::new(3, vec![(vec![0], vec![1, 2])], vec![0, 1, 2], vec![vec![0]]); let loaded = load_dyn( @@ -72,7 +98,7 @@ fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); assert!(matches!( error, - crate::solvers::DeterministicSolveError::NativeNoSolution { .. } + crate::solvers::DeterministicSolveError::CustomizedNoSolution { .. } )); let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); assert_eq!(brute_force.solver, SolverExecution::BruteForce); @@ -130,11 +156,11 @@ fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { #[test] fn deterministic_solver_execution_has_stable_tagged_json_contract() { assert_eq!( - serde_json::to_value(SolverExecution::Native { - implementation: "native-id" + serde_json::to_value(SolverExecution::Customized { + implementation: "customized-id" }) .unwrap(), - serde_json::json!({"kind": "native", "implementation": "native-id"}) + serde_json::json!({"kind": "customized", "implementation": "customized-id"}) ); assert_eq!( serde_json::to_value(SolverExecution::Ilp { @@ -190,7 +216,7 @@ fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { } #[test] -fn deterministic_solver_dispatch_native_default_allows_explicit_ilp_override() { +fn deterministic_solver_dispatch_customized_default_allows_explicit_ilp_override() { use crate::models::graph::RootedTreeArrangement; use crate::topology::SimpleGraph; @@ -203,7 +229,7 @@ fn deterministic_solver_dispatch_native_default_allows_explicit_ilp_override() { .unwrap(); let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); - assert!(matches!(default.solver, SolverExecution::Native { .. })); + assert!(matches!(default.solver, SolverExecution::Customized { .. })); let explicit_ilp = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); assert!(matches!(explicit_ilp.solver, SolverExecution::Ilp { .. })); @@ -226,6 +252,7 @@ fn deterministic_solver_dispatch_repeats_each_available_solver_class() { let mut evaluations = Vec::new(); for request in [ SolverRequest::Default, + SolverRequest::Customized, SolverRequest::Ilp, SolverRequest::BruteForce, ] { From a4f68122f7f5dc1ac7929150301e990a12027795 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 17 Aug 2026 03:28:29 +0800 Subject: [PATCH 07/15] Refine path selection and solver outcomes --- docs/paper/reductions.typ | 16 +- docs/src/cli.md | 17 +- docs/src/mcp.md | 4 +- problemreductions-cli/src/cli.rs | 6 +- problemreductions-cli/src/commands/graph.rs | 201 ++++- problemreductions-cli/src/commands/solve.rs | 34 +- problemreductions-cli/src/dispatch.rs | 117 +-- problemreductions-cli/src/main.rs | 10 +- problemreductions-cli/src/mcp/tests.rs | 51 +- problemreductions-cli/src/mcp/tools.rs | 33 +- problemreductions-cli/tests/cli_tests.rs | 85 ++- src/big_o.rs | 8 +- src/growth.rs | 711 ++++++++---------- src/registry/dyn_problem.rs | 6 - ...feedbackarcset_maximumlikelihoodranking.rs | 34 +- src/size.rs | 41 + src/solvers/mod.rs | 4 +- src/solvers/resolver.rs | 72 +- src/unit_tests/example_db.rs | 10 +- src/unit_tests/growth.rs | 299 +++----- ...feedbackarcset_maximumlikelihoodranking.rs | 17 +- src/unit_tests/size.rs | 67 +- src/unit_tests/solvers/ilp/solver.rs | 11 +- src/unit_tests/solvers/resolver.rs | 84 ++- 24 files changed, 1122 insertions(+), 816 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index fe06421ad..e52110438 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -16571,23 +16571,19 @@ The following reductions to Integer Linear Programming are straightforward formu *Multiplicity:* The fixture stores one canonical optimum. Other optimal rankings exist because the DAG obtained after removing the two backward arcs has multiple valid topological orders. ], )[ - This $O(n^2)$ reduction @garey1979 applies to unit-weight feedback arc set instances. It keeps the same vertex set as ranking items and encodes each unordered pair by a skew-symmetric entry in $\{-1, 0, 1\}$ with comparison count $c = 0$. + This $O(n^2)$ reduction @garey1979 keeps the same vertex set as ranking items and encodes nonnegative integer arc weights in a skew-symmetric matrix with comparison count $c = 0$. The classical unit-weight construction is the special case with entries in $\{-1, 0, 1\}$. ][ - _Construction._ Given a unit-weight Minimum Feedback Arc Set instance $(G = (V, A), bold(1))$ with $V = \{0, dots, n - 1\}$, construct the matrix $M in ZZ^(n times n)$ by setting $M_(i i) = 0$ and, for every distinct pair $i, j$, + _Construction._ Given a Minimum Feedback Arc Set instance $(G = (V, A), w)$ with $V = \{0, dots, n - 1\}$, let $w_(i j)$ be the weight of arc $i arrow j$ when it exists and $0$ otherwise. Construct the matrix $M in ZZ^(n times n)$ by setting $M_(i i) = 0$ and, for every distinct pair $i, j$, $ - M_(i j) = cases( - 1 & "if" (i arrow j) in A and (j arrow i) not in A, - -1 & "if" (j arrow i) in A and (i arrow j) not in A, - 0 & "otherwise" - ). + M_(i j) = w_(i j) - w_(j i). $ Then $M_(i j) + M_(j i) = 0$ for all $i != j$, so the target is a valid Maximum Likelihood Ranking instance with $n$ items. - _Correctness._ ($arrow.r.double$) Let $pi$ be any ranking and let $B(pi) = \{(u arrow v) in A : pi(u) > pi(v)\}$ be its backward arcs. Removing $B(pi)$ leaves only forward arcs, hence a DAG, so $B(pi)$ is a feedback arc set. Partition unordered vertex pairs into one-directional pairs $A_1$ and bidirectional pairs $A_2$. Every one-directional backward arc contributes $+1$ to the MLR objective, every one-directional forward arc contributes $-1$, and bidirectional or absent pairs contribute $0$. Therefore + _Correctness._ ($arrow.r.double$) Let $pi$ be any ranking and let $B(pi) = \{(u arrow v) in A : pi(u) > pi(v)\}$ be its backward arcs. Removing $B(pi)$ leaves only forward arcs, hence a DAG, so $B(pi)$ is a feedback arc set. For each unordered pair, the selected matrix entry is the backward-arc weight minus the forward-arc weight. Therefore $ - "cost"(pi) = 2 |B(pi)| - (|A_1| + 2|A_2|) = 2 |B(pi)| - |A|. + "cost"(pi) = 2 w(B(pi)) - sum_((u arrow v) in A) w_(u v). $ - The target objective is thus the source objective shifted by the constant $-|A|$, so minimizing disagreement cost minimizes feedback arc set size. ($arrow.l.double$) Let $F subset.eq A$ be a minimum feedback arc set, and take a topological order $pi$ of the DAG $G - F$. Every arc in $A backslash F$ is forward in $pi$, hence every backward arc under $pi$ lies in $F$, so $B(pi) subset.eq F$. Since $B(pi)$ is itself a feedback arc set by the previous argument, minimality of $F$ forces $|B(pi)| = |F|$. Therefore an optimal source solution yields an optimal target ranking. + The target objective is thus twice the source objective shifted by a constant independent of $pi$, so minimizing disagreement cost minimizes feedback arc weight. ($arrow.l.double$) Let $F subset.eq A$ be a minimum feedback arc set, and take a topological order $pi$ of the DAG $G - F$. Every arc in $A backslash F$ is forward in $pi$, hence every backward arc under $pi$ lies in $F$, so $B(pi) subset.eq F$. Since weights are nonnegative and $B(pi)$ is itself a feedback arc set, minimality of $F$ forces $w(B(pi)) = w(F)$. Therefore an optimal source solution yields an optimal target ranking. _Solution extraction._ Given the target rank vector, output one source bit per source arc $(u arrow v)$ in source-arc order: set the bit to $1$ iff item $u$ is ranked after item $v$, and to $0$ otherwise. ] diff --git a/docs/src/cli.md b/docs/src/cli.md index 823be1492..1d6eb36ac 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -155,15 +155,19 @@ Inspect reduction paths or save the path set for later route selection: ```bash pred path MIS QUBO # paths (up to 20) pred path MIS QUBO --max-paths 50 # increase the cap +pred path MIS QUBO --selection all # return every enumerated candidate pred path MIS MaximumClique mis.json # execute paths on a complete instance pred path MIS QUBO -o paths.json # save the path set ``` Without an instance file, each route explains how problem size changes. With a -problem JSON file, every returned path is executed on the complete source instance -and the actual size of each constructed intermediate is reported. Discovery never -ranks or discards routes based on size. Output is capped by `--max-paths` (default: 20); -extract one route from the path-set envelope before passing it to +problem JSON file, every candidate path is executed on the complete source instance +and the actual size of each constructed intermediate is reported. By default, +`--selection pareto` returns the paths whose target-size vectors are Pareto +nondominated among the candidates. Use `--selection all` to return every candidate. +`--max-paths` (default: 20) caps the selected output after comparison. Pareto +selection considers every simple candidate path and never prunes graph search using +size estimates. Extract one route from the path-set envelope before passing it to `pred reduce --via`. ### `pred export-graph` — Export the reduction graph @@ -366,6 +370,11 @@ Solve a reduction bundle (from `pred reduce`): {{#include generated/pred-solve-bundle.txt}} ``` +Successful exact solves report `"status": "optimal"`; aggregate-only problems omit +`solution`. A proven infeasible instance is also a successful command result and reports +`"status": "infeasible"` without `solution` or `evaluation`. Solver, timeout, registry, +and extraction failures remain command errors. + > **Note:** The ILP solver requires a reduction path from the target problem to ILP. > Some problems do not currently have one. Examples include BoundedComponentSpanningForest, > LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter, and MultiprocessorScheduling. diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 52ca0461b..04ed10060 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -79,7 +79,7 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `list_problems` | *(none)* | List all registered problem types with aliases, variant counts, and reduction counts | | `show_problem` | `problem` (string) | Show details for a problem type: variants, size fields, schema, and incoming/outgoing reductions | | `neighbors` | `problem` (string), `hops` (int, default: 1), `direction` ("out"\|"in"\|"both", default: "out") | Find neighboring problems reachable via reduction edges within a given hop distance | -| `find_path` | `source` (string), `target` (string), `max_paths` (int, default: 20), `problem_json` (optional string) | Find reduction paths and explain how size changes. With a complete source instance, execute each returned path and report the actual constructed sizes. | +| `find_path` | `source` (string), `target` (string), `max_paths` (int, default: 20), `selection` (`pareto` default or `all`), `problem_json` (optional string) | Find reduction paths and explain how size changes. Selection runs before the output limit; with a complete source instance, execute candidate paths and report actual constructed sizes. | | `export_graph` | *(none)* | Export the full reduction graph as JSON | ### Instance Tools @@ -90,7 +90,7 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `inspect_problem` | `problem_json` (string) | Inspect a problem JSON or reduction bundle: returns type, size metrics, available solvers, and reduction targets | | `evaluate` | `problem_json` (string), `config` (array of int) | Evaluate a configuration against a problem instance and return the objective value or feasibility | | `reduce` | `problem_json` (string), `path_json` (string) | Reduce a problem instance along an explicitly supplied route, returning a bundle with the transformed instance and path metadata | -| `solve` | `problem_json` (string), optional `solver` ("customized"\|"ilp"\|"brute-force"), `timeout` (int, default: 0) | Solve a problem instance or reduction bundle using deterministic customized → ILP → brute-force dispatch, with optional override and timeout | +| `solve` | `problem_json` (string), optional `solver` ("customized"\|"ilp"\|"brute-force"), `timeout` (int, default: 0) | Solve a problem instance or reduction bundle using deterministic customized → ILP → brute-force dispatch. Returns `optimal` or `infeasible`; execution failures are tool errors. | ## Available Prompts diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 91f6d844d..97a89316a 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -165,6 +165,7 @@ Examples: pred path MIS QUBO # inspect reduction paths pred path MIS Clique mis.json # execute paths on an instance pred path MIS QUBO --max-paths 50 # increase the output cap + pred path MIS QUBO --selection all # return every enumerated candidate pred path MIS QUBO -o paths.json # save the path set Use `pred list` to see available problems.")] @@ -175,9 +176,12 @@ Use `pred list` to see available problems.")] /// Target problem (e.g., QUBO) #[arg(value_parser = crate::problem_name::ProblemNameParser)] target: String, - /// Maximum paths to return + /// Maximum selected paths to output #[arg(long, default_value_t = 20)] max_paths: usize, + /// Which enumerated paths to return + #[arg(long, value_enum, default_value_t = crate::commands::graph::PathSelection::Pareto)] + selection: crate::commands::graph::PathSelection, /// Source problem instance JSON. When present, execute every returned path and measure each constructed problem. instance: Option, }, diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index b6ae5c2df..8e1f6ef63 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -5,6 +5,7 @@ use anyhow::Result; use problemreductions::registry::collect_schemas; use problemreductions::registry::ProblemCategory; use problemreductions::rules::{ExecutedPath, ReductionGraph, ReductionPath, TraversalFlow}; +use problemreductions::size::{problem_size_dominates, size_growth_dominates}; use problemreductions::{Expr, Growth}; use std::any::Any; use std::collections::BTreeMap; @@ -915,6 +916,7 @@ pub fn path( source: &str, target: &str, max_paths: usize, + selection: PathSelection, instance: Option<&Path>, out: &OutputConfig, ) -> Result<()> { @@ -968,6 +970,7 @@ pub fn path( &dst_ref.name, &dst_ref.variant, max_paths, + selection, loaded.as_any(), out, ) @@ -979,11 +982,13 @@ pub fn path( &dst_ref.name, &dst_ref.variant, max_paths, + selection, out, ) } } +#[allow(clippy::too_many_arguments)] fn path_symbolic( graph: &ReductionGraph, src_name: &str, @@ -991,15 +996,17 @@ fn path_symbolic( dst_name: &str, dst_variant: &BTreeMap, max_paths: usize, + selection: PathSelection, out: &OutputConfig, ) -> Result<()> { - let batch = find_path_batch( + let mut batch = find_path_batch( graph, src_name, src_variant, dst_name, dst_variant, max_paths, + selection, ); if batch.paths.is_empty() && !batch.truncated { @@ -1016,6 +1023,11 @@ fn path_symbolic( dst_name, ); } + if selection == PathSelection::Pareto { + let flags = symbolic_pareto_flags(graph, &batch.paths); + batch.paths = retain_selected(batch.paths, &flags); + } + cap_path_batch(&mut batch); let json_output = out.output.is_some() || out.json; let json = if json_output { @@ -1044,6 +1056,26 @@ pub(crate) struct PathBatch { pub(crate) max_paths: usize, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +pub(crate) enum PathSelection { + Pareto, + All, +} + +impl std::str::FromStr for PathSelection { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "pareto" => Ok(Self::Pareto), + "all" => Ok(Self::All), + _ => Err(format!( + "unknown path selection '{value}'; expected 'pareto' or 'all'" + )), + } + } +} + pub(crate) fn find_path_batch( graph: &ReductionGraph, src_name: &str, @@ -1051,29 +1083,43 @@ pub(crate) fn find_path_batch( dst_name: &str, dst_variant: &BTreeMap, max_paths: usize, + selection: PathSelection, ) -> PathBatch { - // Fetch one extra to detect truncation. The library already returns paths in a - // deterministic length-first, then name+variant-signature order (see - // `find_paths_up_to_mode_bounded`), so no frontend-side sort is needed. - let mut paths = - graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, max_paths + 1); - let truncated = paths.len() > max_paths; - if truncated { - paths.truncate(max_paths); - } + let paths = match selection { + PathSelection::Pareto => { + let mut paths = graph.find_all_paths(src_name, src_variant, dst_name, dst_variant); + paths.sort_by(|left, right| { + left.steps.len().cmp(&right.steps.len()).then_with(|| { + left.steps + .iter() + .map(|step| (&step.name, &step.variant)) + .cmp(right.steps.iter().map(|step| (&step.name, &step.variant))) + }) + }); + paths + } + PathSelection::All => { + graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, max_paths + 1) + } + }; PathBatch { paths, - truncated, + truncated: false, max_paths, } } +pub(crate) fn cap_path_batch(batch: &mut PathBatch) { + batch.truncated = batch.paths.len() > batch.max_paths; + batch.paths.truncate(batch.max_paths); +} + pub(crate) fn path_batch_json( graph: &ReductionGraph, batch: &PathBatch, executed: Option<&[ExecutedPath]>, ) -> Result { - let (analysis, paths) = match executed { + let paths = match executed { Some(executed) => { if executed.len() != batch.paths.len() { anyhow::bail!( @@ -1082,32 +1128,80 @@ pub(crate) fn path_batch_json( batch.paths.len() ); } - ( - "concrete", - executed - .iter() - .map(format_concrete_path_json) - .collect::>(), - ) - } - None => ( - "symbolic", - batch - .paths + executed .iter() - .map(|path| format_path_json(graph, path)) - .collect::>(), - ), + .map(format_concrete_path_json) + .collect::>() + } + None => batch + .paths + .iter() + .map(|path| format_path_json(graph, path)) + .collect::>(), }; Ok(serde_json::json!({ - "analysis": analysis, "paths": paths, "truncated": batch.truncated, - "returned": batch.paths.len(), - "max_paths": batch.max_paths, })) } +fn pareto_flags_by( + candidates: &[T], + cost: impl FnMut(&T) -> Option, + dominates: impl Fn(&C, &C) -> bool, +) -> Vec { + let costs = candidates.iter().map(cost).collect::>(); + costs + .iter() + .enumerate() + .map(|(index, cost)| { + !costs.iter().enumerate().any(|(other_index, other)| { + index != other_index + && cost + .as_ref() + .zip(other.as_ref()) + .is_some_and(|(cost, other)| dominates(other, cost)) + }) + }) + .collect() +} + +pub(crate) fn retain_selected(items: Vec, selected: &[bool]) -> Vec { + items + .into_iter() + .zip(selected) + .filter_map(|(item, selected)| selected.then_some(item)) + .collect() +} + +pub(crate) fn symbolic_pareto_flags(graph: &ReductionGraph, paths: &[ReductionPath]) -> Vec { + pareto_flags_by( + paths, + |path| { + let target = path.steps.last().expect("path has a target node"); + let growth = graph + .compose_path_size_transform(path) + .ok() + .flatten()? + .project_growth(); + graph + .size_field_names(&target.name) + .iter() + .all(|field| growth.get(field).is_some()) + .then_some(growth) + }, + size_growth_dominates, + ) +} + +pub(crate) fn concrete_pareto_flags(executed: &[ExecutedPath]) -> Vec { + pareto_flags_by( + executed, + |path| path.target_sizes().last().cloned(), + problem_size_dominates, + ) +} + /// Render the symbolic path listing (header + per-path chains with normalized /// size contracts). Extracted so it is built only for text output and can be /// exercised in-process by regression tests without spawning the binary. @@ -1131,7 +1225,7 @@ fn render_paths_text( } if truncated { text.push_str(&format!( - "\n(showing {max_paths} of more paths; use --max-paths to increase)\n" + "\n(more selected paths exist; use --max-paths to increase the output limit above {max_paths})\n" )); } text @@ -1202,21 +1296,33 @@ fn path_concrete( dst_name: &str, dst_variant: &BTreeMap, max_paths: usize, + selection: PathSelection, source: &dyn Any, out: &OutputConfig, ) -> Result<()> { - let batch = find_path_batch( + let mut batch = find_path_batch( graph, src_name, src_variant, dst_name, dst_variant, max_paths, + selection, ); if batch.paths.is_empty() && !batch.truncated { anyhow::bail!("No reduction path from {src_name} to {dst_name}"); } - let executed = graph.execute_paths(&batch.paths, source)?; + if selection == PathSelection::All { + cap_path_batch(&mut batch); + } + let mut executed = graph.execute_paths(&batch.paths, source)?; + if selection == PathSelection::Pareto { + let flags = concrete_pareto_flags(&executed); + batch.paths = retain_selected(batch.paths, &flags); + executed = retain_selected(executed, &flags); + cap_path_batch(&mut batch); + executed.truncate(batch.max_paths); + } let json_output = out.output.is_some() || out.json; let json = if json_output { path_batch_json(graph, &batch, Some(&executed))? @@ -1236,7 +1342,7 @@ fn path_concrete( } if batch.truncated { text.push_str(&format!( - "\n(showing {} of more paths; use --max-paths to increase)\n", + "\n(more selected paths exist; use --max-paths to increase the output limit above {})\n", batch.max_paths )); } @@ -1364,7 +1470,10 @@ fn render_tree(graph: &ReductionGraph, nodes: &[NeighborTree], text: &mut String #[cfg(test)] mod tests { - use super::push_alias_part; + use super::{pareto_flags_by, push_alias_part}; + use problemreductions::size::problem_size_dominates; + use problemreductions::ProblemSize; + use std::cell::Cell; #[test] fn push_alias_part_deduplicates_case_insensitively_in_order() { @@ -1377,4 +1486,26 @@ mod tests { assert_eq!(parts, vec!["KSAT", "3SAT", "2SAT"]); } + + #[test] + fn nondominated_flags_keep_tradeoffs_and_remove_larger_vectors() { + let values = vec![ + Some(ProblemSize::new(vec![("x", 2), ("y", 2)])), + Some(ProblemSize::new(vec![("x", 2), ("y", 3)])), + Some(ProblemSize::new(vec![("x", 1), ("y", 4)])), + ]; + + let calls = Cell::new(0); + let flags = pareto_flags_by( + &values, + |size| { + calls.set(calls.get() + 1); + size.clone() + }, + problem_size_dominates, + ); + + assert_eq!(flags, vec![true, false, true]); + assert_eq!(calls.get(), values.len()); + } } diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 44b902cec..5f657527d 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -4,7 +4,9 @@ use crate::dispatch::{ }; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use problemreductions::solvers::{DeterministicSolveResult, SolverExecution, SolverRequest}; +use problemreductions::solvers::{ + DeterministicSolveResult, SolveOutcome, SolverExecution, SolverRequest, +}; use std::path::Path; use std::time::Duration; @@ -48,13 +50,23 @@ fn solve_result_text(problem: &str, result: &DeterministicSolveResult) -> String problem, solver_text(&result.solver) ); - if let Some(config) = &result.config { - text.push_str(&format!("\nSolution: {:?}", config)); - } - text.push_str(&format!("\nEvaluation: {}", result.evaluation)); + append_outcome_text(&mut text, &result.outcome); text } +fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { + match outcome { + SolveOutcome::Optimal { config, evaluation } => { + text.push_str("\nStatus: optimal"); + if let Some(config) = config { + text.push_str(&format!("\nSolution: {:?}", config)); + } + text.push_str(&format!("\nEvaluation: {evaluation}")); + } + SolveOutcome::Infeasible => text.push_str("\nStatus: infeasible"), + } +} + fn plain_problem_output( problem: &str, result: &DeterministicSolveResult, @@ -133,10 +145,7 @@ fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputCon result.target_name ); let mut text = format!("Problem: {}\nSolver: {}", result.source_name, solver_desc); - if let Some(config) = &result.source_config { - text.push_str(&format!("\nSolution: {:?}", config)); - } - text.push_str(&format!("\nEvaluation: {}", result.source_evaluation)); + append_outcome_text(&mut text, &result.source_outcome); let json = result.to_json(); let result = out.emit_with_default_name("", &text, &json); @@ -169,13 +178,16 @@ mod tests { fn test_solve_value_only_problem_omits_solution() { let result = DeterministicSolveResult { solver: SolverExecution::BruteForce, - config: None, - evaluation: "Sum(56)".to_string(), + outcome: SolveOutcome::Optimal { + config: None, + evaluation: "Sum(56)".to_string(), + }, }; let (text, json) = plain_problem_output("CliTestAggregateValueSource", &result); assert!(text.contains("Evaluation: Sum(56)"), "{text}"); assert!(!text.contains("Solution:"), "{text}"); assert!(json.get("solution").is_none(), "{json}"); + assert_eq!(json["status"], "optimal"); } #[test] diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 23ea55fbe..cc344de11 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -3,7 +3,7 @@ use problemreductions::registry::{DynProblem, LoadedDynProblem}; use problemreductions::rules::ReductionGraph; use problemreductions::solvers::{ solve_deterministically, solver_capabilities, DeterministicSolveResult, ExactProblemKey, - SolverRequest, + SolveOutcome, SolverRequest, }; use serde_json::Value; use std::any::Any; @@ -125,40 +125,58 @@ pub fn solver_request(solver_name: Option<&str>) -> Result { } pub fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { - let mut json = serde_json::json!({ - "problem": problem, - "solver": &result.solver, - "evaluation": result.evaluation, - }); - if let Some(config) = &result.config { - json["solution"] = serde_json::json!(config); + #[derive(serde::Serialize)] + struct SolveOutput<'a> { + problem: &'a str, + solver: &'a problemreductions::solvers::SolverExecution, + #[serde(flatten)] + outcome: &'a SolveOutcome, } - json + + serde_json::to_value(SolveOutput { + problem, + solver: &result.solver, + outcome: &result.outcome, + }) + .expect("solve output is serializable") } pub(crate) struct BundleSolveResult { pub(crate) source_name: String, pub(crate) target_name: String, pub(crate) solver: problemreductions::solvers::SolverExecution, - pub(crate) source_config: Option>, - pub(crate) source_evaluation: String, - pub(crate) target_config: Option>, - pub(crate) target_evaluation: String, + pub(crate) source_outcome: SolveOutcome, + pub(crate) target_outcome: SolveOutcome, } impl BundleSolveResult { pub(crate) fn to_json(&self) -> serde_json::Value { - serde_json::json!({ - "problem": self.source_name, - "solver": self.solver, - "solution": self.source_config, - "evaluation": self.source_evaluation, - "intermediate": { - "problem": self.target_name, - "solution": self.target_config, - "evaluation": self.target_evaluation, + #[derive(serde::Serialize)] + struct Intermediate<'a> { + problem: &'a str, + #[serde(flatten)] + outcome: &'a SolveOutcome, + } + + #[derive(serde::Serialize)] + struct BundleOutput<'a> { + problem: &'a str, + solver: &'a problemreductions::solvers::SolverExecution, + #[serde(flatten)] + outcome: &'a SolveOutcome, + intermediate: Intermediate<'a>, + } + + serde_json::to_value(BundleOutput { + problem: &self.source_name, + solver: &self.solver, + outcome: &self.source_outcome, + intermediate: Intermediate { + problem: &self.target_name, + outcome: &self.target_outcome, }, }) + .expect("bundle solve output is serializable") } } @@ -275,34 +293,39 @@ impl BundleReplay { /// Solve the target and map the result back to the source problem. /// - /// A witness-capable aggregate returns its identity when an instance has no - /// witness. Witness preservation therefore makes the source aggregate - /// identity the corresponding result without requiring a configuration. pub(crate) fn solve(&self, request: SolverRequest) -> Result { let target_result = self.target.solve_deterministically(request)?; - - let (source_config, source_evaluation) = match target_result.config.as_deref() { - Some(target_config) => { - let (source_config, source_evaluation) = self.extract(target_config)?; - (Some(source_config), source_evaluation) - } - None if self.target.supports_witnesses_dyn() => { - (None, self.source.aggregate_identity_dyn()) + let solver = target_result.solver; + let (source_outcome, target_outcome) = match target_result.outcome { + SolveOutcome::Optimal { + config: Some(target_config), + evaluation: target_evaluation, + } => { + let (source_config, source_evaluation) = self.extract(&target_config)?; + ( + SolveOutcome::Optimal { + config: Some(source_config), + evaluation: source_evaluation, + }, + SolveOutcome::Optimal { + config: Some(target_config), + evaluation: target_evaluation, + }, + ) } - None => anyhow::bail!( + SolveOutcome::Optimal { config: None, .. } => anyhow::bail!( "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", self.target_name ), + SolveOutcome::Infeasible => (SolveOutcome::Infeasible, SolveOutcome::Infeasible), }; Ok(BundleSolveResult { source_name: self.source_name.clone(), target_name: self.target_name.clone(), - solver: target_result.solver, - source_config, - source_evaluation, - target_config: target_result.config, - target_evaluation: target_result.evaluation, + solver, + source_outcome, + target_outcome, }) } } @@ -472,8 +495,13 @@ mod tests { let result = loaded .solve_deterministically(SolverRequest::BruteForce) .unwrap(); - assert_eq!(result.config, None); - assert_eq!(result.evaluation, "Sum(56)"); + assert_eq!( + result.outcome, + SolveOutcome::Optimal { + config: None, + evaluation: "Sum(56)".to_string(), + } + ); } #[test] @@ -536,13 +564,16 @@ mod tests { solver: problemreductions::solvers::SolverExecution::Ilp { reduction_path: vec!["Source".to_string(), "ILP".to_string()], }, - config: Some(vec![1, 0]), - evaluation: "Max(1)".to_string(), + outcome: SolveOutcome::Optimal { + config: Some(vec![1, 0]), + evaluation: "Max(1)".to_string(), + }, }; let json = solve_result_json("Source", &result); assert_eq!(json["problem"], "Source"); assert_eq!(json["solver"]["kind"], "ilp"); + assert_eq!(json["status"], "optimal"); assert_eq!( json["solver"]["reduction_path"], serde_json::json!(["Source", "ILP"]) diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index c725d659e..96ac1a3d6 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -70,8 +70,16 @@ fn main() -> anyhow::Result<()> { source, target, max_paths, + selection, instance, - } => commands::graph::path(&source, &target, max_paths, instance.as_deref(), &out), + } => commands::graph::path( + &source, + &target, + max_paths, + selection, + instance.as_deref(), + &out, + ), Commands::ExportGraph => commands::graph::export(&out), Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out), Commands::Create(args) => commands::create::create(&args, &out), diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index b7894203c..935a3099d 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -1,9 +1,10 @@ +use crate::commands::graph::PathSelection; 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) + .find_path_inner(source, target, 2000, PathSelection::All, None) .expect("path enumeration"); let json: serde_json::Value = serde_json::from_str(&response).unwrap(); let entry = json["paths"] @@ -48,13 +49,13 @@ fn test_find_path_enumerates_without_a_mode_or_sizes() { "MIS/SimpleGraph/i32", "MaximumClique/SimpleGraph/i32", 20, + PathSelection::Pareto, None, ) .unwrap(), ) .unwrap(); assert!(!result["paths"].as_array().unwrap().is_empty()); - assert_eq!(result["analysis"], "symbolic"); } #[test] @@ -71,6 +72,7 @@ fn test_find_path_executes_complete_instance_and_reports_actual_size() { "MIS/SimpleGraph/i32", "MaximumClique/SimpleGraph/i32", 20, + PathSelection::Pareto, Some(problem_json), ) .unwrap(), @@ -84,7 +86,6 @@ fn test_find_path_executes_complete_instance_and_reports_actual_size() { .find(|field| field["field"] == "num_edges") .unwrap(); assert_eq!(edges["value"], 6); - assert_eq!(result["analysis"], "concrete"); } #[test] @@ -104,13 +105,17 @@ fn test_find_path_schema_accepts_complete_problem_json() { #[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(); + let json: serde_json::Value = serde_json::from_str( + &server + .find_path_inner("MIS", "QUBO", 1, PathSelection::Pareto, None) + .unwrap(), + ) + .unwrap(); assert_eq!(json["paths"].as_array().unwrap().len(), 1); - assert_eq!(json["returned"], 1); - assert_eq!(json["max_paths"], 1); + assert!(json.get("returned").is_none()); + assert!(json.get("max_paths").is_none()); + assert!(json.get("analysis").is_none()); assert_eq!(json["truncated"], true); - assert_eq!(json["analysis"], "symbolic"); } #[test] @@ -451,13 +456,17 @@ fn test_solve_bundle() { fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { let server = McpServer::new(); - for (clauses, evaluation, has_solution) in [ + for (clauses, status, evaluation) in [ ( serde_json::json!([{"literals": [1]}, {"literals": [-1]}]), - "Or(false)", - false, + "infeasible", + None, + ), + ( + serde_json::json!([{"literals": [1]}]), + "optimal", + Some("Or(true)"), ), - (serde_json::json!([{"literals": [1]}]), "Or(true)", true), ] { let problem_json = server .create_problem_inner( @@ -481,10 +490,20 @@ fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability .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); + assert_eq!(json["status"], status); + assert_eq!(json.get("evaluation").and_then(|v| v.as_str()), evaluation); + assert_eq!(json.get("solution").is_some(), evaluation.is_some()); + assert_eq!(json["intermediate"]["status"], status); + assert_eq!( + json["intermediate"] + .get("evaluation") + .and_then(|v| v.as_str()), + evaluation + ); + assert_eq!( + json["intermediate"].get("solution").is_some(), + evaluation.is_some() + ); } } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 0491ff52f..8f845ff86 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -37,8 +37,10 @@ pub struct FindPathParams { pub source: String, #[schemars(description = "Target problem name or alias")] pub target: String, - #[schemars(description = "Maximum paths to return (default: 20)")] + #[schemars(description = "Maximum selected paths to output (default: 20)")] pub max_paths: Option, + #[schemars(description = "Path selection: pareto (default) or all")] + pub selection: Option, #[schemars( description = "Optional complete source problem JSON. When present, execute every returned path and report actual constructed sizes." )] @@ -228,6 +230,7 @@ impl McpServer { source: &str, target: &str, max_paths: usize, + selection: crate::commands::graph::PathSelection, problem_json: Option<&str>, ) -> anyhow::Result { let graph = ReductionGraph::new(); @@ -251,13 +254,14 @@ impl McpServer { } } - let batch = crate::commands::graph::find_path_batch( + let mut batch = crate::commands::graph::find_path_batch( &graph, &src_ref.name, &src_ref.variant, &dst_ref.name, &dst_ref.variant, max_paths, + selection, ); if batch.paths.is_empty() && !batch.truncated { anyhow::bail!( @@ -266,11 +270,27 @@ impl McpServer { dst_ref.name ); } + if selection == crate::commands::graph::PathSelection::All { + crate::commands::graph::cap_path_batch(&mut batch); + } - let executed = loaded + let mut executed = loaded .as_ref() .map(|source| graph.execute_paths(&batch.paths, source.as_any())) .transpose()?; + if selection == crate::commands::graph::PathSelection::Pareto { + let flags = match &executed { + Some(executed) => crate::commands::graph::concrete_pareto_flags(executed), + None => crate::commands::graph::symbolic_pareto_flags(&graph, &batch.paths), + }; + batch.paths = crate::commands::graph::retain_selected(batch.paths, &flags); + executed = + executed.map(|executed| crate::commands::graph::retain_selected(executed, &flags)); + crate::commands::graph::cap_path_batch(&mut batch); + if let Some(executed) = &mut executed { + executed.truncate(batch.max_paths); + } + } let json = crate::commands::graph::path_batch_json(&graph, &batch, executed.as_deref())?; Ok(serde_json::to_string_pretty(&json)?) } @@ -517,10 +537,17 @@ impl McpServer { )] fn find_path(&self, Parameters(params): Parameters) -> Result { let max_paths = params.max_paths.unwrap_or(20); + let selection = params + .selection + .as_deref() + .unwrap_or("pareto") + .parse() + .map_err(|error: String| error)?; self.find_path_inner( ¶ms.source, ¶ms.target, max_paths, + selection, params.problem_json.as_deref(), ) .map_err(|e| e.to_string()) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7b3c46a98..fd3e9d95b 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -413,7 +413,59 @@ fn test_path_concrete_execution_is_deterministic_and_measures_constructed_target let value = |field: &str| &overall.iter().find(|item| item["field"] == field).unwrap()["value"]; assert_eq!(value("num_vertices"), 5); assert_eq!(value("num_edges"), 6); - assert_eq!(json["analysis"], "concrete"); + assert!(json.get("comparison").is_none()); + assert!(json.get("pareto_frontier").is_none()); + assert!(json["paths"][0].get("pareto_nondominated").is_none()); +} + +#[test] +fn test_path_selection_defaults_to_pareto_and_all_returns_every_candidate() { + let instance = std::env::temp_dir().join("pred_path_selection_mis.json"); + std::fs::write( + &instance, + 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]}}"#, + ) + .unwrap(); + let run = |max_paths: &str, selection: Option<&str>| { + let mut args = vec![ + "path", + "MIS/SimpleGraph/i32", + "QUBO", + instance.to_str().unwrap(), + "--max-paths", + max_paths, + "--json", + ]; + if let Some(selection) = selection { + args.extend(["--selection", selection]); + } + let output = pred().args(args).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice::(&output.stdout).unwrap() + }; + + let pareto = run("3", None); + let pareto_capped = run("1", None); + let all = run("3", Some("all")); + std::fs::remove_file(instance).unwrap(); + + assert_eq!(pareto["paths"].as_array().unwrap().len(), 1); + assert_eq!(pareto_capped["paths"].as_array().unwrap().len(), 1); + assert_eq!(all["paths"].as_array().unwrap().len(), 3); + assert_eq!(pareto["truncated"], false); + assert_eq!(pareto_capped["truncated"], false); + assert_eq!(all["truncated"], true); + let pareto_size = &pareto["paths"][0]["actual_target_size"]["fields"]; + assert!(pareto_size + .as_array() + .unwrap() + .iter() + .any(|field| field["field"] == "num_vars" && field["value"] == 5)); + assert_eq!(pareto_capped["paths"], pareto["paths"]); } #[test] @@ -445,15 +497,17 @@ fn test_path_save() { } #[test] -fn test_path_max_paths_caps_without_ranking() { +fn test_path_max_paths_caps_selected_output() { let output = pred() .args(["path", "MIS", "QUBO", "--max-paths", "1", "--json"]) .output() .unwrap(); assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(json["returned"], 1); + assert_eq!(json["paths"].as_array().unwrap().len(), 1); assert_eq!(json["truncated"], true); + assert!(json.get("returned").is_none()); + assert!(json.get("max_paths").is_none()); } #[test] @@ -2707,9 +2761,11 @@ fn test_kth_largest_m_tuple_solve_uses_k_threshold() { let at_threshold = solve(14); let above_threshold = solve(15); + assert_eq!(at_threshold["status"], "optimal"); assert_eq!(at_threshold["evaluation"], "Or(true)"); - assert_eq!(above_threshold["evaluation"], "Or(false)"); - assert_ne!(at_threshold["evaluation"], above_threshold["evaluation"]); + assert_eq!(above_threshold["status"], "infeasible"); + assert!(above_threshold.get("evaluation").is_none()); + assert!(above_threshold.get("solution").is_none()); } #[test] @@ -3293,14 +3349,18 @@ fn solve_sat_to_nae_bundle(case: &str, clauses: &str) -> serde_json::Value { #[test] fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { let infeasible = solve_sat_to_nae_bundle("infeasible", "1;-1"); - assert_eq!(infeasible["evaluation"], "Or(false)"); - assert!(infeasible["solution"].is_null()); - assert_eq!(infeasible["intermediate"]["evaluation"], "Or(false)"); - assert!(infeasible["intermediate"]["solution"].is_null()); + assert_eq!(infeasible["status"], "infeasible"); + assert!(infeasible.get("evaluation").is_none()); + assert!(infeasible.get("solution").is_none()); + assert_eq!(infeasible["intermediate"]["status"], "infeasible"); + assert!(infeasible["intermediate"].get("evaluation").is_none()); + assert!(infeasible["intermediate"].get("solution").is_none()); let feasible = solve_sat_to_nae_bundle("feasible", "1"); + assert_eq!(feasible["status"], "optimal"); assert_eq!(feasible["evaluation"], "Or(true)"); assert!(feasible["solution"].is_array()); + assert_eq!(feasible["intermediate"]["status"], "optimal"); assert_eq!(feasible["intermediate"]["evaluation"], "Or(true)"); assert!(feasible["intermediate"]["solution"].is_array()); } @@ -5223,10 +5283,10 @@ fn test_path_set_has_explicit_strongest_size_information() { ); } // Verify envelope metadata - assert!(envelope["returned"].is_number()); - assert!(envelope["max_paths"].is_number()); + assert!(envelope.get("returned").is_none()); + assert!(envelope.get("max_paths").is_none()); + assert!(envelope.get("analysis").is_none()); assert!(envelope["truncated"].is_boolean()); - assert_eq!(envelope["analysis"], "symbolic"); } #[test] @@ -8141,7 +8201,6 @@ fn test_path_max_paths_truncates() { "should return at most 3 paths, got {}", paths.len() ); - assert_eq!(envelope["max_paths"], 3); // KSat -> QUBO has many paths, so truncation is expected assert_eq!( envelope["truncated"], true, diff --git a/src/big_o.rs b/src/big_o.rs index d88959319..0cdb0ee1c 100644 --- a/src/big_o.rs +++ b/src/big_o.rs @@ -3,8 +3,8 @@ //! Thin wrapper over the [growth domain](crate::growth): compute the growth //! class of an expression bottom-up (without fully distributing the source AST) and //! render it back to a display [`Expr`]. Content the growth domain cannot bound -//! symbolically ([`Growth::Unknown`] — nonlinear exponents, factorials, negative -//! exponents) maps to the [`AsymptoticAnalysisError::Unsupported`] error. +//! symbolically (nonlinear exponents, factorials, negative exponents) maps to +//! the [`AsymptoticAnalysisError::Unsupported`] error. use crate::expr::{AsymptoticAnalysisError, Expr}; use crate::growth::Growth; @@ -12,8 +12,8 @@ use crate::growth::Growth; /// Compute the Big-O normal form of an expression. /// /// Returns an expression representing the asymptotic growth class, or -/// [`AsymptoticAnalysisError::Unsupported`] when the growth domain widens the -/// input to [`Growth::Unknown`]. +/// [`AsymptoticAnalysisError::Unsupported`] when the growth domain cannot +/// represent the input. pub fn big_o_normal_form(expr: &Expr) -> Result { let growth = Growth::from_expr(expr); match growth.to_expr() { diff --git a/src/growth.rs b/src/growth.rs index c24766e45..e22dc2de6 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -5,12 +5,12 @@ //! [`Expr`] to monomial normal form, with exponential cost in nesting depth, the //! growth domain computes an asymptotic upper bound bottom-up without rewriting //! the source AST into a fully distributed polynomial. Work is output-sensitive: -//! exact antichains are never truncated, so genuinely large Pareto fronts remain -//! large and visible to the caller. +//! antichains are retained up to 32 terms; larger fronts are replaced +//! by one sound componentwise upper bound. //! //! # Representation //! -//! A [`GrowthTerm`] is one growth monomial +//! One internal growth term is a monomial //! //! ```text //! ∏_v ∏_f base[f]^(coefficient[f] · v) @@ -18,8 +18,8 @@ //! ``` //! //! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms -//! (each summand of an asymptotic sum), or [`Growth::Unknown`] with explicit -//! reasons for content we cannot bound symbolically. +//! (each summand of an asymptotic sum), a coarsened upper bound, or an unknown +//! result with explicit reasons for content we cannot bound symbolically. //! //! # Semantic foundation (the trust contract) //! @@ -40,7 +40,7 @@ //! authoritative: it is never normalized through a floating-point logarithm //! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, //! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to -//! [`Growth::Unknown`], which preserves its reasons through every operation. +//! an unknown result, which preserves its reasons through every operation. //! - The explicit approximation boundary treats [`Expr::log`] as the natural //! logarithm, but all fixed //! logarithm bases greater than one have the same asymptotic class and are @@ -58,64 +58,32 @@ use crate::expr::{AlgebraicAnalysis, BigInt, Expr, ExprNode, ExprNodeId}; use num_rational::BigRational; use num_traits::{One, Signed, ToPrimitive, Zero}; use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, HashMap}; -/// A base retained exactly as it appeared in the input expression. -#[derive(Clone, Debug, PartialEq, serde::Serialize)] +/// Maximum number of incomparable terms retained before replacing the complete +/// antichain with one sound componentwise upper bound. +const ANTICHAIN_CAP: usize = 32; + +/// An exact fixed exponential base. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] enum ExpBase { - /// A positive, finite constant expression used as the base of `Pow`. - Constant(Expr), + /// A positive rational constant used as the base of `Pow`. + Rational(BigRational), /// The distinguished base of the `exp(...)` AST constructor. Natural, } -impl<'de> serde::Deserialize<'de> for ExpBase { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(serde::Deserialize)] - enum Repr { - Constant(Expr), - Natural, - } - - match Repr::deserialize(deserializer)? { - Repr::Natural => Ok(ExpBase::Natural), - Repr::Constant(base) => match base.node() { - ExprNode::Const(value) if value.is_positive() => Ok(ExpBase::Constant(base)), - _ => Err(serde::de::Error::custom( - "symbolic exponential base must be a positive rational constant", - )), - }, - } - } -} - impl ExpBase { - fn structural_key(&self) -> String { - match self { - ExpBase::Constant(base) => format!("C{base:?}"), - ExpBase::Natural => "N".to_string(), - } - } - fn directly_comparable_value(&self) -> Option<&BigRational> { match self { - ExpBase::Constant(base) => match base.node() { - ExprNode::Const(value) => Some(value), - _ => unreachable!("constant exponential bases are validated when constructed"), - }, + ExpBase::Rational(base) => Some(base), ExpBase::Natural => None, } } fn direction(&self) -> Ordering { match self { - ExpBase::Constant(_) => self - .directly_comparable_value() - .expect("constant base") - .cmp(&BigRational::one()), + ExpBase::Rational(base) => base.cmp(&BigRational::one()), ExpBase::Natural => Ordering::Greater, } } @@ -129,120 +97,118 @@ impl ExpBase { } } -/// One symbolic exponential factor `base^(coefficient * variable)`. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -struct ExpFactor { - base: ExpBase, - coefficient: BigRational, -} - -/// Canonical product of growing exponential factors for one variable. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -struct ExpProduct { - factors: Vec, +/// Exponential, polynomial, and logarithmic growth associated with one size +/// variable. Missing components have exponent zero. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct VariableGrowth { + exp: BTreeMap, + poly: BigRational, + log: u32, } -impl ExpProduct { +impl VariableGrowth { fn empty() -> Self { - ExpProduct { - factors: Vec::new(), + Self { + exp: BTreeMap::new(), + poly: BigRational::zero(), + log: 0, } } - fn single(base: ExpBase, coefficient: BigRational) -> Self { - Self::new(vec![ExpFactor { base, coefficient }]) - } - - /// Canonicalize without translating bases through a common logarithm. - fn new(factors: Vec) -> Self { - let mut combined: Vec = Vec::new(); - for factor in factors { - if factor.coefficient.is_zero() { - continue; - } - if let Some(existing) = combined.iter_mut().find(|f| f.base == factor.base) { - existing.coefficient += factor.coefficient; - } else { - combined.push(factor); - } + fn exponential(base: ExpBase, coefficient: BigRational) -> Self { + Self { + exp: BTreeMap::from([(base, coefficient)]), + poly: BigRational::zero(), + log: 0, } - combined.retain(|factor| !factor.coefficient.is_zero()); - combined.sort_by_cached_key(|factor| factor.base.structural_key()); - ExpProduct { factors: combined } } - fn mul(&self, other: &Self) -> Self { - let mut factors = self.factors.clone(); - factors.extend(other.factors.iter().cloned()); - Self::new(factors) + fn polynomial(degree: BigRational) -> Self { + Self { + exp: BTreeMap::new(), + poly: degree, + log: 0, + } } - fn pow(&self, power: &BigRational) -> Self { - let factors = self - .factors - .iter() - .filter_map(|factor| { - let coefficient = &factor.coefficient * power; - (!coefficient.is_zero()).then(|| ExpFactor { - base: factor.base.clone(), - coefficient, - }) - }) - .collect(); - ExpProduct { factors } + fn logarithmic(power: u32) -> Self { + Self { + exp: BTreeMap::new(), + poly: BigRational::zero(), + log: power, + } } fn is_empty(&self) -> bool { - self.factors.is_empty() + self.exp.is_empty() && self.poly.is_zero() && self.log == 0 } - fn is_valid(&self) -> bool { - self.factors.iter().all(|factor| { - matches!( - ( - factor.base.direction(), - factor.coefficient.cmp(&BigRational::zero()) - ), - (Ordering::Greater, Ordering::Greater) | (Ordering::Less, Ordering::Less) - ) - }) + fn mul(&self, other: &Self) -> Result { + let mut result = self.clone(); + for (base, coefficient) in &other.exp { + *result + .exp + .entry(base.clone()) + .or_insert_with(BigRational::zero) += coefficient; + } + result.exp.retain(|_, coefficient| !coefficient.is_zero()); + result.poly += &other.poly; + result.log = result.log.checked_add(other.log).ok_or_else(|| { + GrowthFailure::RepresentedExponentOutOfRange(format!("{} + {}", self.log, other.log)) + })?; + Ok(result) } - /// Prove an ordering using only structural cancellation and direct constant - /// comparisons. `None` means "not proved", never "equal". - fn cmp_proven(&self, other: &Self) -> Option { - if self == other { - return Some(Ordering::Equal); - } + fn pow(&self, power: &BigRational) -> Result { + let exp = self + .exp + .iter() + .filter_map(|(base, coefficient)| { + let coefficient = coefficient * power; + (!coefficient.is_zero()).then(|| (base.clone(), coefficient)) + }) + .collect(); + let poly = &self.poly * power; + let scaled_log = BigRational::from_integer(BigInt::from(self.log)) * power; + let rounded = + (scaled_log.numer() + scaled_log.denom() - BigInt::one()) / scaled_log.denom(); + let Some(log) = rounded.to_u32() else { + return Err(GrowthFailure::RepresentedExponentOutOfRange( + scaled_log.to_string(), + )); + }; + Ok(Self { exp, poly, log }) + } + fn cmp_exp(&self, other: &Self) -> Option { let mut left_count = 0; let mut right_count = 0; let mut left_single: Option<(&ExpBase, BigRational)> = None; let mut right_single: Option<(&ExpBase, BigRational)> = None; - for a in &self.factors { - if let Some(b) = other.factors.iter().find(|b| a.base == b.base) { - match a.base.coefficient_cmp(&a.coefficient, &b.coefficient) { + for (base, a) in &self.exp { + if let Some(b) = other.exp.get(base) { + match base.coefficient_cmp(a, b) { Ordering::Equal => {} Ordering::Greater => { left_count += 1; - left_single = Some((&a.base, &a.coefficient - &b.coefficient)); + left_single = Some((base, a - b)); } Ordering::Less => { right_count += 1; - right_single = Some((&a.base, &b.coefficient - &a.coefficient)); + right_single = Some((base, b - a)); } } } else { left_count += 1; - left_single = Some((&a.base, a.coefficient.clone())); + left_single = Some((base, a.clone())); } } - for b in &other.factors { - if !self.factors.iter().any(|a| a.base == b.base) { + for (base, coefficient) in &other.exp { + if !self.exp.contains_key(base) { right_count += 1; - right_single = Some((&b.base, b.coefficient.clone())); + right_single = Some((base, coefficient.clone())); } } @@ -259,6 +225,15 @@ impl ExpProduct { } } + fn cmp_growth(&self, other: &Self) -> Option { + let exponential = self.cmp_exp(other)?; + Some(if exponential == Ordering::Equal { + self.poly.cmp(&other.poly).then(self.log.cmp(&other.log)) + } else { + exponential + }) + } + fn cmp_single_factor( a_base: &ExpBase, a_coefficient: &BigRational, @@ -271,7 +246,7 @@ impl ExpProduct { if a_coefficient == b_coefficient { match (a_base, b_base) { - (ExpBase::Natural, ExpBase::Constant(_)) => { + (ExpBase::Natural, ExpBase::Rational(_)) => { let base = b_base.directly_comparable_value()?; if base <= &BigRational::from_integer(2.into()) { return Some(Ordering::Greater); @@ -281,7 +256,7 @@ impl ExpProduct { } return None; } - (ExpBase::Constant(_), ExpBase::Natural) => { + (ExpBase::Rational(_), ExpBase::Natural) => { return Self::cmp_single_factor(b_base, b_coefficient, a_base, a_coefficient) .map(Ordering::reverse); } @@ -327,51 +302,51 @@ impl ExpProduct { } } - fn sort_key(&self) -> String { - self.factors - .iter() - .map(|factor| format!("{}={:?}", factor.base.structural_key(), factor.coefficient)) - .collect::>() - .join(",") + fn upper_envelope(&mut self, other: &Self) { + for (base, coefficient) in &other.exp { + match self.exp.get_mut(base) { + Some(current) + if base.coefficient_cmp(coefficient, current) == Ordering::Greater => + { + *current = coefficient.clone(); + } + Some(_) => {} + None => { + self.exp.insert(base.clone(), coefficient.clone()); + } + } + } + self.poly = self.poly.clone().max(other.poly.clone()); + self.log = self.log.max(other.log); } } /// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)`. /// /// Empty maps represent `O(1)`. -#[derive(Clone, Debug, PartialEq, serde::Serialize)] -pub struct GrowthTerm { - /// Variable → canonical product of symbolic exponential factors. - exp: BTreeMap, ExpProduct>, - /// variable → polynomial degree (`0.5` covers `sqrt`). - poly: BTreeMap, BigRational>, - /// variable → log power. - logs: BTreeMap, u32>, +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct GrowthTerm { + variables: BTreeMap, VariableGrowth>, } /// The asymptotic growth class of an [`Expr`]. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum Growth { - /// Antichain of pairwise-incomparable dominant terms, sorted by a - /// deterministic total order for platform-stable output/serialization. - Terms(Vec), +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Growth(GrowthState); + +#[derive(Clone, Debug, PartialEq, Eq)] +enum GrowthState { + /// Complete antichain of pairwise-incomparable dominant terms. + Antichain(Vec), + /// A single upper-envelope term produced when the complete antichain + /// exceeds the configured cap. + Coarsened(GrowthTerm), /// Content outside the represented growth domain, with every reason that /// contributed to the result. Unknown(Vec), } /// A precise reason why an expression has no represented [`Growth`] value. -#[derive( - Clone, - Debug, - PartialEq, - Eq, - PartialOrd, - Ord, - serde::Serialize, - serde::Deserialize, - thiserror::Error, -)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, thiserror::Error)] pub enum GrowthFailure { #[error("invalid or unproved constant domain for {expression}")] InvalidConstantDomain { expression: String }, @@ -395,8 +370,6 @@ pub enum GrowthFailure { variable: String, coefficient: String, }, - #[error("growth construction produced an invalid term")] - InvalidGrowthTerm, #[error("missing substitution for {0}")] MissingSubstitution(String), } @@ -405,39 +378,14 @@ impl GrowthTerm { /// The `O(1)` term (all maps empty). fn one() -> Self { GrowthTerm { - exp: BTreeMap::new(), - poly: BTreeMap::new(), - logs: BTreeMap::new(), + variables: BTreeMap::new(), } } - /// A deterministic, platform-stable total-order key. - fn sort_key(&self) -> String { - let mut s = String::new(); - for (k, v) in &self.exp { - s.push('E'); - s.push_str(k); - s.push('='); - s.push_str(&v.sort_key()); - s.push(';'); + fn insert(&mut self, variable: Box, growth: VariableGrowth) { + if !growth.is_empty() { + self.variables.insert(variable, growth); } - s.push('|'); - for (k, v) in &self.poly { - s.push('P'); - s.push_str(k); - s.push('='); - s.push_str(&format!("{v:?}")); - s.push(';'); - } - s.push('|'); - for (k, v) in &self.logs { - s.push('L'); - s.push_str(k); - s.push('='); - s.push_str(&v.to_string()); - s.push(';'); - } - s } /// Raise this term to a nonnegative real power `k` (scale every exponent). @@ -445,24 +393,8 @@ impl GrowthTerm { /// upper bound, since `(log v)^p ≤ (log v)^⌈p⌉` for `v ≥ 2`). fn pow(&self, k: &BigRational) -> Result { let mut r = GrowthTerm::one(); - for (v, product) in &self.exp { - let product = product.pow(k); - if !product.is_empty() { - r.exp.insert(v.clone(), product); - } - } - for (v, deg) in &self.poly { - r.poly.insert(v.clone(), deg * k); - } - for (v, p) in &self.logs { - let scaled = BigRational::from_integer(BigInt::from(*p)) * k; - let rounded = (scaled.numer() + scaled.denom() - BigInt::one()) / scaled.denom(); - let Some(rounded) = rounded.to_u32() else { - return Err(GrowthFailure::RepresentedExponentOutOfRange( - scaled.to_string(), - )); - }; - r.logs.insert(v.clone(), rounded); + for (variable, growth) in &self.variables { + r.insert(variable.clone(), growth.pow(k)?); } Ok(r) } @@ -470,29 +402,17 @@ impl GrowthTerm { /// Multiply two monomials (add matching exponents). fn mul(&self, other: &GrowthTerm) -> Result { let mut t = self.clone(); - for (k, product) in &other.exp { - let combined = t - .exp - .get(k) - .map_or_else(|| product.clone(), |current| current.mul(product)); + for (variable, growth) in &other.variables { + let combined = match t.variables.get(variable) { + Some(current) => current.mul(growth)?, + None => growth.clone(), + }; if combined.is_empty() { - t.exp.remove(k); + t.variables.remove(variable); } else { - t.exp.insert(k.clone(), combined); + t.variables.insert(variable.clone(), combined); } } - for (k, v) in &other.poly { - *t.poly.entry(k.clone()).or_insert_with(BigRational::zero) += v; - } - for (k, v) in &other.logs { - let current = t.logs.entry(k.clone()).or_insert(0); - let Some(combined) = current.checked_add(*v) else { - return Err(GrowthFailure::RepresentedExponentOutOfRange(format!( - "{current} + {v}" - ))); - }; - *current = combined; - } Ok(t) } @@ -502,40 +422,25 @@ impl GrowthTerm { /// polynomial degree and log power then break proven exponential ties. /// Returns `None` for incomparable or unproved terms. fn cmp(&self, other: &GrowthTerm) -> Option { - let mut vars: BTreeSet<&str> = BTreeSet::new(); - for m in [&self.exp, &other.exp] { - vars.extend(m.keys().map(Box::as_ref)); - } - for m in [&self.poly, &other.poly] { - vars.extend(m.keys().map(Box::as_ref)); - } - for m in [&self.logs, &other.logs] { - vars.extend(m.keys().map(Box::as_ref)); - } - let mut saw_gt = false; let mut saw_lt = false; - let empty_exp = ExpProduct::empty(); - for v in vars { - let exp_a = self.exp.get(v).unwrap_or(&empty_exp); - let exp_b = other.exp.get(v).unwrap_or(&empty_exp); - let exp_order = exp_a.cmp_proven(exp_b)?; - let order = if exp_order == Ordering::Equal { - self.poly - .get(v) - .cloned() - .unwrap_or_else(BigRational::zero) - .cmp(&other.poly.get(v).cloned().unwrap_or_else(BigRational::zero)) - .then( - self.logs - .get(v) - .copied() - .unwrap_or(0) - .cmp(&other.logs.get(v).copied().unwrap_or(0)), - ) - } else { - exp_order + let empty = VariableGrowth::empty(); + let mut left = self.variables.iter().peekable(); + let mut right = other.variables.iter().peekable(); + loop { + let (a, b) = match (left.peek(), right.peek()) { + (None, None) => break, + (Some((left_variable, _)), Some((right_variable, _))) => { + match left_variable.cmp(right_variable) { + Ordering::Less => (left.next().unwrap().1, &empty), + Ordering::Greater => (&empty, right.next().unwrap().1), + Ordering::Equal => (left.next().unwrap().1, right.next().unwrap().1), + } + } + (Some(_), None) => (left.next().unwrap().1, &empty), + (None, Some(_)) => (&empty, right.next().unwrap().1), }; + let order = a.cmp_growth(b)?; match order { Ordering::Greater => saw_gt = true, Ordering::Less => saw_lt = true, @@ -563,20 +468,41 @@ impl GrowthTerm { Some(Ordering::Greater) | Some(Ordering::Equal) ) } + + fn upper_envelope(terms: &[GrowthTerm]) -> GrowthTerm { + let mut result = GrowthTerm::one(); + for term in terms { + for (variable, growth) in &term.variables { + result + .variables + .entry(variable.clone()) + .or_insert_with(VariableGrowth::empty) + .upper_envelope(growth); + } + } + result + } } impl Growth { pub(crate) fn unknown(failure: GrowthFailure) -> Self { - Self::Unknown(vec![failure]) + Self(GrowthState::Unknown(vec![failure])) } pub fn failures(&self) -> Option<&[GrowthFailure]> { - match self { - Self::Terms(_) => None, - Self::Unknown(failures) => Some(failures), + match &self.0 { + GrowthState::Antichain(_) | GrowthState::Coarsened(_) => None, + GrowthState::Unknown(failures) => Some(failures), } } + /// Whether the complete antichain was replaced by a resource-bounded upper + /// envelope. Coarsened values are safe bounds but not safe evidence for + /// eliminating another path from a Pareto frontier. + pub fn is_coarsened(&self) -> bool { + matches!(self.0, GrowthState::Coarsened(_)) + } + /// Compute the growth class of an expression in a single bottom-up pass. pub fn from_expr(expr: &Expr) -> Growth { let analysis = AlgebraicAnalysis::new(&[expr]); @@ -589,31 +515,39 @@ impl Growth { /// Partial order: `true` iff `self` grows at least as fast as `other`. /// - /// Per the growth-rate reading, [`Growth::Unknown`] is the top element (it + /// Per the growth-rate reading, unknown growth is the top element (it /// may be arbitrarily large, e.g. a factorial), so it dominates everything /// and nothing known dominates it. For two term antichains, `self` /// dominates `other` iff every term of `other` is dominated-or-equal by /// some term of `self` — the standard antichain (Pareto) comparison. pub fn dominates(&self, other: &Growth) -> bool { - match (self, other) { - (Growth::Unknown(_), _) => true, - (Growth::Terms(_), Growth::Unknown(_)) => false, - (Growth::Terms(a), Growth::Terms(b)) => { + match (&self.0, &other.0) { + (GrowthState::Unknown(_), _) => true, + (_, GrowthState::Unknown(_)) => false, + (GrowthState::Antichain(a), GrowthState::Antichain(b)) => { b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) } + (GrowthState::Antichain(a), GrowthState::Coarsened(b)) => { + a.iter().any(|ta| ta.dominates_or_eq(b)) + } + (GrowthState::Coarsened(a), GrowthState::Antichain(b)) => { + b.iter().all(|tb| a.dominates_or_eq(tb)) + } + (GrowthState::Coarsened(a), GrowthState::Coarsened(b)) => a.dominates_or_eq(b), } } /// Render this growth class back to a display [`Expr`] (a sum of monomials), - /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic + /// or `None` for unknown growth. Terms are already in the deterministic /// sort order, so the rendered expression is platform-stable. /// /// Exponential factors are rendered directly from their authoritative /// symbolic bases and coefficients; no base reconstruction is performed. pub fn to_expr(&self) -> Option { - match self { - Growth::Unknown(_) => None, - Growth::Terms(terms) => { + match &self.0 { + GrowthState::Unknown(_) => None, + GrowthState::Coarsened(term) => Some(term_to_expr(term)), + GrowthState::Antichain(terms) => { if terms.is_empty() { return Some(Expr::integer(1)); } @@ -628,7 +562,7 @@ impl Growth { } /// Canonical Big-O string for this growth class: `O()` for a bounded - /// class, or `O(?)` for [`Growth::Unknown`] (no honest asymptotic bound — + /// class, or `O(?)` for unknown growth (no honest asymptotic bound — /// nonlinear exponent or factorial). This is the single source of truth for /// how a growth is displayed as Big-O; presentation layers must call it rather /// than re-deriving the mapping (and the `Unknown` spelling) themselves. @@ -666,9 +600,11 @@ fn growth_from_analysis( ExprNode::Const(_) => unreachable!("constants are handled before node projection"), ExprNode::Var(variable) => { let mut term = GrowthTerm::one(); - term.poly - .insert(variable.as_str().into(), BigRational::one()); - Growth::Terms(vec![term]) + term.insert( + variable.as_str().into(), + VariableGrowth::polynomial(BigRational::one()), + ); + exact_growth(vec![term]) } ExprNode::Add(values) => values .iter() @@ -709,7 +645,7 @@ fn growth_from_analysis( } else if let Some(base_value) = base_facts.exact_rational.as_ref() { if base_value.is_positive() { exponential( - ExpBase::Constant(Expr::constant(base_value.clone())), + ExpBase::Rational(base_value.clone()), growth_linear(exponent_facts.linear.clone()), exponent, ) @@ -726,7 +662,7 @@ fn growth_from_analysis( } ExprNode::Exp(value) => { let value_growth = growth_from_analysis(value, analysis, memo); - if matches!(value_growth, Growth::Unknown(_)) { + if value_growth.failures().is_some() { value_growth } else { exponential( @@ -768,7 +704,7 @@ fn scale_growth_linear( ) } fn constant_growth() -> Growth { - Growth::Terms(vec![GrowthTerm::one()]) + exact_growth(vec![GrowthTerm::one()]) } fn unknown(failure: GrowthFailure) -> Growth { @@ -777,28 +713,33 @@ fn unknown(failure: GrowthFailure) -> Growth { fn merge_unknown(left: Growth, right: Growth) -> Growth { let mut failures = Vec::new(); - if let Growth::Unknown(left) = left { + if let GrowthState::Unknown(left) = left.0 { failures.extend(left); } - if let Growth::Unknown(right) = right { + if let GrowthState::Unknown(right) = right.0 { failures.extend(right); } failures.sort(); failures.dedup(); - Growth::Unknown(failures) + Growth(GrowthState::Unknown(failures)) } /// Render one monomial as a product of its factors (or `Const(1)` when empty). fn term_to_expr(t: &GrowthTerm) -> Expr { let mut factors: Vec = Vec::new(); - for (v, product) in &t.exp { - factors.extend(product.factors.iter().map(|factor| exp_factor(v, factor))); - } - for (v, deg) in &t.poly { - factors.push(poly_factor(v, deg)); - } - for (v, power) in &t.logs { - factors.push(log_factor(v, *power)); + for (variable, growth) in &t.variables { + factors.extend( + growth + .exp + .iter() + .map(|(base, coefficient)| exp_factor(variable, base, coefficient)), + ); + if !growth.poly.is_zero() { + factors.push(poly_factor(variable, &growth.poly)); + } + if growth.log != 0 { + factors.push(log_factor(variable, growth.log)); + } } let mut it = factors.into_iter(); match it.next() { @@ -808,14 +749,14 @@ fn term_to_expr(t: &GrowthTerm) -> Expr { } /// Render a stored exponential factor without changing its base or coefficient. -fn exp_factor(v: &str, factor: &ExpFactor) -> Expr { - let exponent = if factor.coefficient.is_one() { +fn exp_factor(v: &str, base: &ExpBase, coefficient: &BigRational) -> Expr { + let exponent = if coefficient.is_one() { Expr::variable(v) } else { - Expr::constant(factor.coefficient.clone()) * Expr::variable(v) + Expr::constant(coefficient.clone()) * Expr::variable(v) }; - match &factor.base { - ExpBase::Constant(base) => Expr::pow(base.clone(), exponent), + match base { + ExpBase::Rational(base) => Expr::pow(Expr::constant(base.clone()), exponent), ExpBase::Natural => Expr::exp(exponent), } } @@ -846,7 +787,7 @@ fn prune(mut terms: Vec) -> Vec { // Proven-equal terms can retain different symbolic spellings (for example, // `exp(n)` and a literal-e base). Sort first so the representative does not // depend on operand order. - terms.sort_by_cached_key(GrowthTerm::sort_key); + terms.sort(); let mut result: Vec = Vec::new(); for t in terms { if result.iter().any(|r| r.dominates_or_eq(&t)) { @@ -858,68 +799,73 @@ fn prune(mut terms: Vec) -> Vec { result } -fn growth_term_is_valid(term: &GrowthTerm) -> bool { - term.exp - .values() - .all(|product| !product.is_empty() && product.is_valid()) - && term.poly.values().all(|degree| !degree.is_negative()) +fn exact_growth(terms: Vec) -> Growth { + finish_growth(terms, false) } -/// Prune to the exact maximal antichain and sort deterministically. -fn make_growth(terms: Vec) -> Growth { - if !terms.iter().all(growth_term_is_valid) { - return unknown(GrowthFailure::InvalidGrowthTerm); +fn finish_growth(terms: Vec, already_coarsened: bool) -> Growth { + let terms = prune(terms); + if already_coarsened || terms.len() > ANTICHAIN_CAP { + Growth(GrowthState::Coarsened(GrowthTerm::upper_envelope(&terms))) + } else { + Growth(GrowthState::Antichain(terms)) } - let pruned = prune(terms); - debug_assert!(pruned.iter().all(growth_term_is_valid)); - Growth::Terms(pruned) } /// Antichain union (asymptotic `+ ≍ max`). fn add(a: Growth, b: Growth) -> Growth { - match (a, b) { - (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { - merge_unknown(left, right) - } - (Growth::Terms(mut x), Growth::Terms(y)) => { - x.extend(y); - make_growth(x) - } + if a.failures().is_some() || b.failures().is_some() { + return merge_unknown(a, b); } + let already_coarsened = a.is_coarsened() || b.is_coarsened(); + let mut terms = into_terms(a); + terms.extend(into_terms(b)); + finish_growth(terms, already_coarsened) } /// Pairwise product of two antichains. fn mul(a: Growth, b: Growth) -> Growth { - match (a, b) { - (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { - merge_unknown(left, right) - } - (Growth::Terms(x), Growth::Terms(y)) => { - let mut prod = Vec::with_capacity(x.len() * y.len()); - for tx in &x { - for ty in &y { - match tx.mul(ty) { - Ok(term) => prod.push(term), - Err(failure) => return unknown(failure), - } - } + if a.failures().is_some() || b.failures().is_some() { + return merge_unknown(a, b); + } + let already_coarsened = a.is_coarsened() || b.is_coarsened(); + let x = into_terms(a); + let y = into_terms(b); + let mut product = Vec::with_capacity(x.len() * y.len()); + for tx in &x { + for ty in &y { + match tx.mul(ty) { + Ok(term) => product.push(term), + Err(failure) => return unknown(failure), } - make_growth(prod) } } + finish_growth(product, already_coarsened) } /// Raise a whole antichain to a nonnegative real power `k` (raise each term). fn pow_const(g: Growth, k: &BigRational) -> Growth { - match g { - Growth::Unknown(failures) => Growth::Unknown(failures), - Growth::Terms(terms) => match terms.iter().map(|term| term.pow(k)).collect() { - Ok(terms) => make_growth(terms), + match g.0 { + GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), + GrowthState::Antichain(terms) => match terms.iter().map(|term| term.pow(k)).collect() { + Ok(terms) => exact_growth(terms), + Err(failure) => unknown(failure), + }, + GrowthState::Coarsened(term) => match term.pow(k) { + Ok(term) => Growth(GrowthState::Coarsened(term)), Err(failure) => unknown(failure), }, } } +fn into_terms(growth: Growth) -> Vec { + match growth.0 { + GrowthState::Antichain(terms) => terms, + GrowthState::Coarsened(term) => vec![term], + GrowthState::Unknown(_) => unreachable!("unknown growth is handled before term access"), + } +} + /// Transfer function for a symbolic fixed-base exponential. fn exponential( base: ExpBase, @@ -929,7 +875,7 @@ fn exponential( let direction = base.direction(); if direction == Ordering::Equal { // 1^x = 1 for every x: bounded by O(1). - return Growth::Terms(vec![GrowthTerm::one()]); + return exact_growth(vec![GrowthTerm::one()]); } match linear { None => unknown(GrowthFailure::NonlinearExponent(exponent.to_string())), @@ -939,16 +885,19 @@ fn exponential( if (direction == Ordering::Greater && coeff.is_positive()) || (direction == Ordering::Less && coeff.is_negative()) { - term.exp.insert(v, ExpProduct::single(base.clone(), coeff)); + term.insert(v, VariableGrowth::exponential(base.clone(), coeff)); } else if !coeff.is_zero() { return unknown(GrowthFailure::DecayingExponential { - base: base.structural_key(), + base: match &base { + ExpBase::Rational(value) => value.to_string(), + ExpBase::Natural => "e".to_string(), + }, variable: v.to_string(), coefficient: coeff.to_string(), }); } } - make_growth(vec![term]) + exact_growth(vec![term]) } } } @@ -957,9 +906,9 @@ fn exponential( /// dominant term(s), unioned. Uses `log(n^a · m^b) ≍ log n + log m` and /// `log(2^(r·n)) ≍ n`. fn log_growth(g: Growth) -> Growth { - match g { - Growth::Unknown(failures) => Growth::Unknown(failures), - Growth::Terms(terms) => { + match g.0 { + GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), + GrowthState::Antichain(terms) => { let mut out = Vec::new(); for t in &terms { out.extend(log_term(t)); @@ -967,8 +916,11 @@ fn log_growth(g: Growth) -> Growth { if out.is_empty() { out.push(GrowthTerm::one()); // log(O(1)) = O(1) } - make_growth(out) + exact_growth(out) } + GrowthState::Coarsened(term) => Growth(GrowthState::Coarsened(GrowthTerm::upper_envelope( + &log_term(&term), + ))), } } @@ -976,33 +928,24 @@ fn log_growth(g: Growth) -> Growth { /// summands. `log(∏ baseᵢ^(rᵢ·vᵢ) · ∏vⱼ^aⱼ · ∏(log vₖ)^sₖ)` distributes over the /// product into a *sum* of the log of each factor, so every factor class of the /// monomial contributes its own summand — none may be dropped (e.g. `log(2^n·m)` -/// is `n + log m`, not `n`). `make_growth`/`prune` then collapse any dominated +/// is `n + log m`, not `n`). `finish_growth`/`prune` then collapse any dominated /// summands (so `log(2^n·n^2)` reduces back to `n`). fn log_term(t: &GrowthTerm) -> Vec { let mut out = Vec::new(); - // Every stored exponential product grows, so its logarithm is linear. - for v in t.exp.keys().cloned() { - let mut g = GrowthTerm::one(); - g.poly.insert(v, BigRational::one()); - out.push(g); - } - // log(v^a) ≍ log v: each positive-degree polynomial factor becomes a log. - for v in t - .poly - .iter() - .filter(|(_, degree)| degree.is_positive()) - .map(|(variable, _)| variable.clone()) - { - let mut g = GrowthTerm::one(); - g.logs.insert(v, 1); - out.push(g); - } - // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for - // v ≥ 2): each log factor stays a single log. - for v in t.logs.keys().cloned() { - let mut g = GrowthTerm::one(); - g.logs.insert(v, 1); - out.push(g); + for (variable, growth) in &t.variables { + if !growth.exp.is_empty() { + let mut term = GrowthTerm::one(); + term.insert( + variable.clone(), + VariableGrowth::polynomial(BigRational::one()), + ); + out.push(term); + } + if growth.poly.is_positive() || growth.log != 0 { + let mut term = GrowthTerm::one(); + term.insert(variable.clone(), VariableGrowth::logarithmic(1)); + out.push(term); + } } // Empty term: log(O(1)) = O(1). if out.is_empty() { @@ -1011,48 +954,6 @@ fn log_term(t: &GrowthTerm) -> Vec { out } -// --- serde --- -// -// Deserialize through an unchecked representation, then enforce the growth -// domain's invariants before constructing a term. - -impl<'de> serde::Deserialize<'de> for GrowthTerm { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(serde::Deserialize)] - struct Repr { - exp: BTreeMap, - poly: BTreeMap, - logs: BTreeMap, - } - let r = Repr::deserialize(deserializer)?; - let term = GrowthTerm { - exp: r - .exp - .into_iter() - .map(|(key, product)| (key.into_boxed_str(), ExpProduct::new(product.factors))) - .collect(), - poly: r - .poly - .into_iter() - .map(|(key, value)| (key.into_boxed_str(), value)) - .collect(), - logs: r - .logs - .into_iter() - .map(|(key, value)| (key.into_boxed_str(), value)) - .collect(), - }; - if growth_term_is_valid(&term) { - Ok(term) - } else { - Err(serde::de::Error::custom("invalid symbolic growth term")) - } - } -} - #[cfg(test)] #[path = "unit_tests/growth.rs"] mod tests; diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index 037bfc723..4f7e9c7df 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -41,8 +41,6 @@ pub trait DynProblem: Any { fn num_variables_dyn(&self) -> usize; /// Whether the aggregate value admits representative witness configurations. fn supports_witnesses_dyn(&self) -> bool; - /// Return the aggregate identity in the CLI-facing metric format. - fn aggregate_identity_dyn(&self) -> String; } impl DynProblem for T @@ -85,10 +83,6 @@ where fn supports_witnesses_dyn(&self) -> bool { T::Value::supports_witnesses() } - - fn aggregate_identity_dyn(&self) -> String { - format_metric(&T::Value::identity()) - } } /// Function pointer type for brute-force value solve dispatch. diff --git a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index 9deae2c85..635e56bb0 100644 --- a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -1,9 +1,7 @@ //! Reduction from MinimumFeedbackArcSet to MaximumLikelihoodRanking. //! -//! On unit-weight instances, a ranking induces exactly the feedback arc set of -//! backward arcs. The target matrix uses the skew-symmetric `c = 0` encoding: -//! one-way arcs become `+/-1`, while bidirectional pairs and missing pairs map -//! to `0`. +//! A ranking induces the feedback arc set of backward arcs. The target matrix +//! uses the skew-symmetric `c = 0` encoding `a_ij = w_ij - w_ji`. use crate::models::graph::MinimumFeedbackArcSet; use crate::models::misc::MaximumLikelihoodRanking; @@ -15,18 +13,23 @@ fn build_skew_symmetric_matrix(problem: &MinimumFeedbackArcSet) -> Vec>(); for i in 0..n { for j in (i + 1)..n { - let ij = graph.has_arc(i, j); - let ji = graph.has_arc(j, i); - if ij && !ji { - matrix[i][j] = 1; - matrix[j][i] = -1; - } else if ji && !ij { - matrix[i][j] = -1; - matrix[j][i] = 1; - } + let ij = arc_weights.get(&(i, j)).copied().unwrap_or(0); + let ji = arc_weights.get(&(j, i)).copied().unwrap_or(0); + let difference = ij.checked_sub(ji).expect( + "MinimumFeedbackArcSet -> MaximumLikelihoodRanking weight difference overflow", + ); + matrix[i][j] = difference; + matrix[j][i] = difference.checked_neg().expect( + "MinimumFeedbackArcSet -> MaximumLikelihoodRanking negated weight difference overflow", + ); } } @@ -72,11 +75,6 @@ impl ReduceTo for MinimumFeedbackArcSet { type Result = ReductionFASToMLR; fn reduce_to(&self) -> Self::Result { - assert!( - self.weights().iter().all(|&weight| weight == 1), - "MinimumFeedbackArcSet -> MaximumLikelihoodRanking requires unit arc weights" - ); - ReductionFASToMLR { target: MaximumLikelihoodRanking::new(build_skew_symmetric_matrix(self)), source_arcs: self.graph().arcs(), diff --git a/src/size.rs b/src/size.rs index 500e9968f..1f8151724 100644 --- a/src/size.rs +++ b/src/size.rs @@ -116,6 +116,47 @@ impl SizeGrowth { } } +/// Return whether `left` is no larger in every concrete size field and smaller +/// in at least one field. +pub fn problem_size_dominates(left: &ProblemSize, right: &ProblemSize) -> bool { + left.components.len() == right.components.len() + && left + .components + .iter() + .all(|(name, value)| right.get(name).is_some_and(|other| *value <= other)) + && left + .components + .iter() + .any(|(name, value)| right.get(name).is_some_and(|other| *value < other)) +} + +/// Return whether `left` has no faster growth in every symbolic size field and +/// strictly slower growth in at least one field. +pub fn size_growth_dominates(left: &SizeGrowth, right: &SizeGrowth) -> bool { + if left.fields.len() != right.fields.len() { + return false; + } + let mut strictly_smaller = false; + for (name, growth) in left.fields() { + let Some(other) = right.get(name) else { + return false; + }; + if growth.failures().is_some() + || other.failures().is_some() + || growth.is_coarsened() + || other.is_coarsened() + { + return false; + } + let left_at_most = other.dominates(growth); + if !left_at_most { + return false; + } + strictly_smaller |= !growth.dominates(other); + } + strictly_smaller +} + impl EvaluatedSize { pub fn exact(values: SizeValues) -> Self { Self { diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index 4aca2cc7a..42a5efcb0 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -15,8 +15,8 @@ pub use registry::{ RegistryBuildError, SolverCapabilities, }; pub use resolver::{ - solve_deterministically, DeterministicSolveError, DeterministicSolveResult, SolverExecution, - SolverRequest, + solve_deterministically, DeterministicSolveError, DeterministicSolveResult, SolveOutcome, + SolverExecution, SolverRequest, }; pub use ilp::{ILPSolveError, ILPSolver}; diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs index 9bbcdad22..39b78d3e2 100644 --- a/src/solvers/resolver.rs +++ b/src/solvers/resolver.rs @@ -30,8 +30,22 @@ pub enum SolverExecution { #[derive(Clone, Debug, PartialEq, Eq)] pub struct DeterministicSolveResult { pub solver: SolverExecution, - pub config: Option>, - pub evaluation: String, + pub outcome: SolveOutcome, +} + +/// Semantic result of a completed exact solve. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SolveOutcome { + /// The exact optimum was established. Aggregate-only problems do not + /// provide a witness configuration. + Optimal { + #[serde(rename = "solution", skip_serializing_if = "Option::is_none")] + config: Option>, + evaluation: String, + }, + /// The solver proved that the instance has no feasible configuration. + Infeasible, } #[derive(Debug, thiserror::Error)] @@ -42,8 +56,6 @@ pub enum DeterministicSolveError { MissingIlpCapability(String), #[error("No customized solver is registered for {0}")] MissingCustomizedCapability(String), - #[error("customized solver found no solution for {problem}")] - CustomizedNoSolution { problem: String }, #[error("ILP solver failed for {problem}: {source}")] IlpSolve { problem: String, @@ -60,18 +72,18 @@ fn solve_customized( problem: &LoadedDynProblem, registration: &'static CustomizedSolverRegistration, ) -> Result { - let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { - DeterministicSolveError::CustomizedNoSolution { - problem: problem_key(problem).label(), - } - })?; - let evaluation = problem.evaluate_dyn(&config); + let outcome = match (registration.solve_fn)(problem.as_any()) { + Some(config) => SolveOutcome::Optimal { + evaluation: problem.evaluate_dyn(&config), + config: Some(config), + }, + None => SolveOutcome::Infeasible, + }; Ok(DeterministicSolveResult { solver: SolverExecution::Customized { implementation: registration.implementation, }, - config: Some(config), - evaluation, + outcome, }) } @@ -79,34 +91,42 @@ fn solve_ilp( problem: &LoadedDynProblem, pipeline: &CompiledIlpPipeline, ) -> Result { - let config = pipeline - .solve(problem.as_any(), &super::ILPSolver::new()) - .map_err(|source| DeterministicSolveError::IlpSolve { - problem: problem_key(problem).label(), - source, - })?; - let evaluation = problem.evaluate_dyn(&config); + let outcome = match pipeline.solve(problem.as_any(), &super::ILPSolver::new()) { + Ok(config) => SolveOutcome::Optimal { + evaluation: problem.evaluate_dyn(&config), + config: Some(config), + }, + Err(super::ILPSolveError::Infeasible) => SolveOutcome::Infeasible, + Err(source) => { + return Err(DeterministicSolveError::IlpSolve { + problem: problem_key(problem).label(), + source, + }); + } + }; Ok(DeterministicSolveResult { solver: SolverExecution::Ilp { reduction_path: pipeline.path_labels(), }, - config: Some(config), - evaluation, + outcome, }) } fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { - match problem.solve_brute_force_witness() { - Some((config, evaluation)) => DeterministicSolveResult { - solver: SolverExecution::BruteForce, + let outcome = match problem.solve_brute_force_witness() { + Some((config, evaluation)) => SolveOutcome::Optimal { config: Some(config), evaluation, }, - None => DeterministicSolveResult { - solver: SolverExecution::BruteForce, + None if problem.supports_witnesses_dyn() => SolveOutcome::Infeasible, + None => SolveOutcome::Optimal { config: None, evaluation: problem.solve_brute_force_value(), }, + }; + DeterministicSolveResult { + solver: SolverExecution::BruteForce, + outcome, } } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 8094254af..ed2546775 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -500,7 +500,7 @@ fn model_specs_are_self_consistent() { #[test] fn model_specs_are_optimal() { use crate::registry::{find_variant_entry, load_dyn}; - use crate::solvers::{solve_deterministically, SolverRequest}; + use crate::solvers::{solve_deterministically, SolveOutcome, SolverRequest}; let specs = crate::models::graph::canonical_model_example_specs() .into_iter() @@ -517,9 +517,13 @@ fn model_specs_are_optimal() { let log_space: f64 = dims.iter().map(|&d| (d as f64).log2()).sum(); let solve_registered_ilp = || { let loaded = load_dyn(name, &variant, spec.instance.serialize_json()).ok()?; - solve_deterministically(&loaded, SolverRequest::Ilp) + match solve_deterministically(&loaded, SolverRequest::Ilp) .ok()? - .config + .outcome + { + SolveOutcome::Optimal { config, .. } => config, + SolveOutcome::Infeasible => None, + } }; let best_config = if log_space <= 20.0 { find_variant_entry(name, &variant) diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index a03584708..ba41010b7 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -1,7 +1,8 @@ //! Unit tests for the symbolic growth domain (`src/growth.rs`). use super::{ - add, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthFailure, GrowthTerm, + add, exact_growth, mul, ExpBase, Growth, GrowthFailure, GrowthState, GrowthTerm, + VariableGrowth, ANTICHAIN_CAP, }; use crate::expr::{ evaluate_approximate, expression_from_approximation, AlgebraicAnalysis, Expr, ExprNode, @@ -18,31 +19,35 @@ fn rat(value: f64) -> BigRational { /// Build a term from `(exp, poly, logs)` entry lists. fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> GrowthTerm { - GrowthTerm { - exp: exp - .iter() - .map(|(variable, rate)| { - ( - (*variable).into(), - ExpProduct::single(ExpBase::Constant(Expr::integer(2)), rat(*rate)), - ) - }) - .collect(), - poly: poly - .iter() - .map(|(variable, degree)| ((*variable).into(), rat(*degree))) - .collect(), - logs: logs - .iter() - .map(|(variable, power)| ((*variable).into(), *power)) - .collect(), + let mut result = GrowthTerm::one(); + for (variable, rate) in exp { + result.insert( + (*variable).into(), + VariableGrowth::exponential(ExpBase::Rational(rat(2.0)), rat(*rate)), + ); + } + for (variable, degree) in poly { + let growth = result + .variables + .entry((*variable).into()) + .or_insert_with(VariableGrowth::empty); + growth.poly = rat(*degree); + } + for (variable, power) in logs { + let growth = result + .variables + .entry((*variable).into()) + .or_insert_with(VariableGrowth::empty); + growth.log = *power; } + result } fn terms_of(g: &Growth) -> &[GrowthTerm] { - match g { - Growth::Terms(t) => t, - Growth::Unknown(failures) => panic!("expected Terms, got {failures:?}"), + match &g.0 { + GrowthState::Antichain(terms) => terms, + GrowthState::Coarsened(term) => std::slice::from_ref(term), + GrowthState::Unknown(failures) => panic!("expected known growth, got {failures:?}"), } } @@ -97,16 +102,15 @@ fn test_growth_relations_against_sympy_limits() { } } -fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { - ExpProduct::new( - factors +fn exponential_growth(factors: &[(f64, f64)]) -> VariableGrowth { + VariableGrowth { + exp: factors .iter() - .map(|(base, coefficient)| ExpFactor { - base: ExpBase::Constant(Expr::constant(rat(*base))), - coefficient: rat(*coefficient), - }) + .map(|(base, coefficient)| (ExpBase::Rational(rat(*base)), rat(*coefficient))) .collect(), - ) + poly: BigRational::zero(), + log: 0, + } } // --- Core verification cases --- @@ -235,97 +239,79 @@ fn test_growth_unproved_multi_base_comparison_is_retained() { #[test] fn test_exponential_product_proof_rules() { - let empty = ExpProduct::empty(); - let two = exp_product(&[(2.0, 1.0)]); - let two_squared = exp_product(&[(2.0, 2.0)]); - let three = exp_product(&[(3.0, 1.0)]); - - assert_eq!(empty.cmp_proven(&empty), Some(Ordering::Equal)); - assert_eq!(empty.cmp_proven(&two), Some(Ordering::Less)); - assert_eq!(two.cmp_proven(&empty), Some(Ordering::Greater)); - assert_eq!(two_squared.cmp_proven(&two), Some(Ordering::Greater)); - assert_eq!(two.cmp_proven(&two_squared), Some(Ordering::Less)); - assert_eq!(three.cmp_proven(&two), Some(Ordering::Greater)); - assert_eq!(two.cmp_proven(&three), Some(Ordering::Less)); + let empty = VariableGrowth::empty(); + let two = exponential_growth(&[(2.0, 1.0)]); + let two_squared = exponential_growth(&[(2.0, 2.0)]); + let three = exponential_growth(&[(3.0, 1.0)]); + + assert_eq!(empty.cmp_exp(&empty), Some(Ordering::Equal)); + assert_eq!(empty.cmp_exp(&two), Some(Ordering::Less)); + assert_eq!(two.cmp_exp(&empty), Some(Ordering::Greater)); + assert_eq!(two_squared.cmp_exp(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_exp(&two_squared), Some(Ordering::Less)); + assert_eq!(three.cmp_exp(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_exp(&three), Some(Ordering::Less)); assert_eq!( - exp_product(&[(3.0, 2.0)]).cmp_proven(&exp_product(&[(2.0, 1.0)])), + exponential_growth(&[(3.0, 2.0)]).cmp_exp(&exponential_growth(&[(2.0, 1.0)])), Some(Ordering::Greater) ); assert_eq!( - exp_product(&[(2.0, 1.0)]).cmp_proven(&exp_product(&[(3.0, 2.0)])), + exponential_growth(&[(2.0, 1.0)]).cmp_exp(&exponential_growth(&[(3.0, 2.0)])), Some(Ordering::Less) ); assert_eq!( - exp_product(&[(2.0, 3.0)]).cmp_proven(&exp_product(&[(3.0, 1.0)])), + exponential_growth(&[(2.0, 3.0)]).cmp_exp(&exponential_growth(&[(3.0, 1.0)])), None ); assert_eq!( - exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + exponential_growth(&[(0.25, -1.0)]).cmp_exp(&exponential_growth(&[(0.5, -1.0)])), Some(Ordering::Greater) ); assert_eq!( - exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -1.0)])), + exponential_growth(&[(0.5, -1.0)]).cmp_exp(&exponential_growth(&[(0.25, -1.0)])), Some(Ordering::Less) ); assert_eq!( - exp_product(&[(0.25, -2.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + exponential_growth(&[(0.25, -2.0)]).cmp_exp(&exponential_growth(&[(0.5, -1.0)])), Some(Ordering::Greater) ); assert_eq!( - exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -2.0)])), + exponential_growth(&[(0.5, -1.0)]).cmp_exp(&exponential_growth(&[(0.25, -2.0)])), Some(Ordering::Less) ); assert_eq!( - exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -2.0)])), + exponential_growth(&[(0.25, -1.0)]).cmp_exp(&exponential_growth(&[(0.5, -2.0)])), None ); - assert_eq!(two.cmp_proven(&exp_product(&[(0.5, -1.0)])), None); + assert_eq!(two.cmp_exp(&exponential_growth(&[(0.5, -1.0)])), None); - let natural = ExpProduct::single(ExpBase::Natural, BigRational::one()); - assert_eq!(natural.cmp_proven(&two), Some(Ordering::Greater)); - assert_eq!(two.cmp_proven(&natural), Some(Ordering::Less)); + let natural = VariableGrowth::exponential(ExpBase::Natural, BigRational::one()); + assert_eq!(natural.cmp_exp(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_exp(&natural), Some(Ordering::Less)); // Constant subtrees normalize before growth comparison. - let composite = ExpProduct::single(ExpBase::Constant(Expr::parse("1 + 2")), BigRational::one()); - assert_eq!(composite.cmp_proven(&three), Some(Ordering::Equal)); + assert_eq!(g("(1 + 2)^n"), g("3^n")); // Two residual products with no factorwise proof remain incomparable. assert_eq!( - exp_product(&[(2.0, 2.0), (3.0, 1.0)]).cmp_proven(&exp_product(&[(2.0, 1.0), (4.0, 1.0)])), + exponential_growth(&[(2.0, 2.0), (3.0, 1.0)]) + .cmp_exp(&exponential_growth(&[(2.0, 1.0), (4.0, 1.0)])), None ); } #[test] fn test_exponential_product_canonicalization() { - let combined = ExpProduct::new(vec![ - ExpFactor { - base: ExpBase::Constant(Expr::integer(2)), - coefficient: BigRational::one(), - }, - ExpFactor { - base: ExpBase::Constant(Expr::integer(2)), - coefficient: rat(2.0), - }, - ExpFactor { - base: ExpBase::Constant(Expr::integer(3)), - coefficient: BigRational::zero(), - }, - ]); - assert_eq!(combined, exp_product(&[(2.0, 3.0)])); - - let cancelled = ExpProduct::new(vec![ - ExpFactor { - base: ExpBase::Constant(Expr::integer(2)), - coefficient: BigRational::one(), - }, - ExpFactor { - base: ExpBase::Constant(Expr::integer(2)), - coefficient: -BigRational::one(), - }, - ]); + let combined = exponential_growth(&[(2.0, 1.0)]) + .mul(&exponential_growth(&[(2.0, 2.0)])) + .unwrap(); + assert_eq!(combined, exponential_growth(&[(2.0, 3.0)])); + + let cancelled = exponential_growth(&[(2.0, 1.0)]) + .mul(&exponential_growth(&[(2.0, -1.0)])) + .unwrap(); assert!(cancelled.is_empty()); } @@ -334,10 +320,6 @@ fn test_growth_multi_base_product_is_deterministic() { let left = g("2^n * 3^n"); let right = g("3^n * 2^n"); assert_eq!(left, right); - assert_eq!( - serde_json::to_string(&left).unwrap(), - serde_json::to_string(&right).unwrap() - ); } #[test] @@ -436,26 +418,7 @@ fn test_growth_reports_nested_and_numeric_failures() { )); assert_eq!(Growth::from_expr(&Expr::variable("n")).failures(), None); - assert_eq!(Growth::Terms(Vec::new()).to_expr(), Some(Expr::integer(1))); -} - -#[test] -fn test_growth_rejects_invalid_internal_terms_explicitly() { - let mut invalid = GrowthTerm::one(); - invalid.poly.insert("n".into(), -BigRational::one()); - assert_eq!( - make_growth(vec![invalid]).failures(), - Some([GrowthFailure::InvalidGrowthTerm].as_slice()) - ); -} - -#[test] -fn test_exponential_base_deserialization_reports_invalid_constant_domain() { - let invalid = serde_json::json!({ - "Constant": serde_json::to_value(Expr::log(Expr::integer(0))).unwrap() - }); - let error = serde_json::from_value::(invalid).unwrap_err(); - assert!(error.to_string().contains("positive rational constant")); + assert_eq!(exact_growth(Vec::new()).to_expr(), Some(Expr::integer(1))); } // --- Additional coverage --- @@ -571,10 +534,7 @@ fn test_growth_log_levels() { assert_eq!(g("log(3^n)"), g("n")); assert_eq!(g("log(exp(n))"), g("n")); // log(n) is a single log term. - assert_eq!( - g("log(n)"), - Growth::Terms(vec![term(&[], &[], &[("n", 1)])]) - ); + assert_eq!(g("log(n)"), exact_growth(vec![term(&[], &[], &[("n", 1)])])); // log(n*m) ≍ log n + log m (two summands, not a product). assert_eq!(terms_of(&g("log(n*m)")).len(), 2); // log of a constant is O(1). @@ -583,7 +543,7 @@ fn test_growth_log_levels() { // A mixed monomial's log keeps *every* factor class: log(2^n * m) ≍ n + log m. // The exponential factor must not swallow the polynomial one. let mixed = g("log(2^n * m)"); - let expected = make_growth(vec![ + let expected = exact_growth(vec![ term(&[], &[("n", 1.0)], &[]), term(&[], &[], &[("m", 1)]), ]); @@ -607,87 +567,58 @@ fn test_growth_unknown_dominance() { assert!(unknown.dominates(&unknown)); } -/// Large antichains remain exact; growth analysis has no hidden size cap. +/// Complete antichains are retained through the configured boundary, then +/// replaced by one deterministic upper envelope. #[test] -fn test_growth_preserves_large_antichain() { - // 40 distinct single-variable terms are pairwise incomparable. - let vars: Vec = (0..40).map(|index| format!("v{index}")).collect(); - let many: Vec = vars +fn test_growth_antichain_cap_coarsens_at_overflow() { + let vars: Vec = (0..=ANTICHAIN_CAP) + .map(|index| format!("v{index}")) + .collect(); + let terms: Vec = vars .iter() .map(|variable| term(&[], &[(variable, 1.0)], &[])) .collect(); - let growth = make_growth(many.clone()); - assert_eq!(terms_of(&growth).len(), many.len()); - assert!(many.iter().all(|term| terms_of(&growth).contains(term))); + let at_cap = exact_growth(terms[..ANTICHAIN_CAP].to_vec()); + assert!(!at_cap.is_coarsened()); + assert_eq!(terms_of(&at_cap).len(), ANTICHAIN_CAP); + + let overflow = exact_growth(terms.clone()); + assert!(overflow.is_coarsened()); + assert_eq!(terms_of(&overflow).len(), 1); + for original in terms { + assert!(overflow.dominates(&exact_growth(vec![original]))); + } + + let reversed = exact_growth( + vars.iter() + .rev() + .map(|variable| term(&[], &[(variable, 1.0)], &[])) + .collect(), + ); + assert_eq!(overflow, reversed); } -/// Unproved exponential comparisons also remain as a complete antichain. +/// The exponential envelope remains an upper bound when symbolic comparisons +/// leave more than the configured number of terms incomparable. #[test] -fn test_growth_preserves_large_unproved_exponential_antichain() { - let terms = (1..=33) - .map(|i| GrowthTerm { - exp: [( +fn test_growth_coarsens_large_unproved_exponential_antichain() { + let terms = (1..=ANTICHAIN_CAP + 1) + .map(|i| { + let mut term = GrowthTerm::one(); + term.insert( "n".into(), - exp_product(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]), - )] - .into_iter() - .collect(), - poly: BTreeMap::new(), - logs: BTreeMap::new(), + exponential_growth(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]), + ); + term }) .collect::>(); - assert_eq!(terms_of(&make_growth(terms.clone())).len(), terms.len()); -} - -/// Structured serde round-trips with owned variable names, and -/// `Unknown` round-trips. -#[test] -fn test_growth_serde_roundtrip() { - let value = g("2^n * m^2 + n * log(k)"); - let json = serde_json::to_string(&value).unwrap(); - let back: Growth = serde_json::from_str(&json).unwrap(); - assert_eq!(value, back); - - let unknown = g("factorial(n)"); - let unknown_json = serde_json::to_string(&unknown).unwrap(); - assert_eq!( - serde_json::from_str::(&unknown_json).unwrap(), - unknown - ); - - // Every constant Expr form admitted as a symbolic base remains lossless. - for source in [ - "(1 + 1)^n", - "(2 * 2)^n", - "(2^2)^n", - "exp(1)^n", - "log(3)^n", - "sqrt(4)^n", - "factorial(3)^n", - "exp(n)", - ] { - let value = g(source); - let json = serde_json::to_string(&value).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), value); + let growth = exact_growth(terms.clone()); + assert!(growth.is_coarsened()); + for original in terms { + assert!(growth.dominates(&exact_growth(vec![original]))); } - - let variable_base = serde_json::json!({ - "Constant": serde_json::to_value(Expr::variable("n")).unwrap() - }); - let error = serde_json::from_value::(variable_base).unwrap_err(); - assert!(error - .to_string() - .contains("symbolic exponential base must be a positive rational constant")); - - let invalid = Growth::Terms(vec![GrowthTerm { - exp: [("n".into(), ExpProduct::empty())].into_iter().collect(), - poly: BTreeMap::new(), - logs: BTreeMap::new(), - }]); - let invalid_json = serde_json::to_string(&invalid).unwrap(); - assert!(serde_json::from_str::(&invalid_json).is_err()); } // --- Randomized property tests --- @@ -720,7 +651,6 @@ fn test_growth_serde_roundtrip() { use super::{log_growth, pow_const}; use crate::types::ProblemSize; -use std::collections::BTreeMap; /// Fixed master seed. Every contract derives its own stream by offsetting this, /// so the whole suite is deterministic and reproducible on any platform. @@ -1057,13 +987,14 @@ fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { } fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { - match (a, b) { - (Growth::Unknown(_), Growth::Unknown(_)) => true, - (Growth::Terms(ta), Growth::Terms(tb)) => { + match (&a.0, &b.0) { + (GrowthState::Unknown(_), GrowthState::Unknown(_)) => true, + (GrowthState::Antichain(ta), GrowthState::Antichain(tb)) => { ta.len() == tb.len() && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) && tb.iter().all(|u| ta.iter().any(|t| term_approx_eq(t, u))) } + (GrowthState::Coarsened(a), GrowthState::Coarsened(b)) => term_approx_eq(a, b), _ => false, } } diff --git a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index 4a95eeebd..f5e3d0a0f 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -110,12 +110,19 @@ fn test_solution_extraction_marks_backward_arcs() { } #[test] -#[should_panic( - expected = "MinimumFeedbackArcSet -> MaximumLikelihoodRanking requires unit arc weights" -)] -fn test_weighted_instances_are_rejected() { +fn test_weighted_instances_preserve_the_optimum() { let source = weighted_cycle_source(); - let _ = ReduceTo::::reduce_to(&source); + let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.target_problem().matrix(), + &vec![vec![0, 10, -1], vec![-10, 0, 1], vec![1, -1, 0]] + ); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "weighted MinimumFeedbackArcSet -> MaximumLikelihoodRanking closed loop", + ); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/size.rs b/src/unit_tests/size.rs index 11b668ef0..b62f7fdf3 100644 --- a/src/unit_tests/size.rs +++ b/src/unit_tests/size.rs @@ -1,8 +1,73 @@ -use super::{EvaluatedSize, SizeRelation, SizeTransform, SizeTransformError, SizeValues}; +use super::{ + problem_size_dominates, size_growth_dominates, EvaluatedSize, SizeRelation, SizeTransform, + SizeTransformError, SizeValues, +}; use crate::expr::Expr; +use crate::types::ProblemSize; use num_bigint::BigUint; use num_traits::One; +#[test] +fn pareto_order_minimizes_every_concrete_size_field() { + let small = ProblemSize::new(vec![("vertices", 4), ("edges", 6)]); + let large = ProblemSize::new(vec![("edges", 8), ("vertices", 4)]); + let tradeoff = ProblemSize::new(vec![("vertices", 3), ("edges", 9)]); + + assert!(problem_size_dominates(&small, &large)); + assert!(!problem_size_dominates(&small, &tradeoff)); +} + +#[test] +fn pareto_order_minimizes_every_symbolic_growth_field() { + let linear = SizeTransform::new( + "linear", + SizeRelation::UpperBound, + [("vertices", Expr::parse("n")), ("edges", Expr::parse("n"))], + ) + .unwrap() + .project_growth(); + let quadratic = SizeTransform::new( + "quadratic", + SizeRelation::UpperBound, + [ + ("vertices", Expr::parse("n")), + ("edges", Expr::parse("n^2")), + ], + ) + .unwrap() + .project_growth(); + + assert!(size_growth_dominates(&linear, &quadratic)); +} + +#[test] +fn coarsened_growth_cannot_eliminate_a_symbolic_path() { + let wide_expression = Expr::parse( + &(0..33) + .map(|index| format!("v{index}")) + .collect::>() + .join(" + "), + ); + let coarsened = SizeTransform::new( + "coarsened", + SizeRelation::UpperBound, + [("vertices", wide_expression)], + ) + .unwrap() + .project_growth(); + let exact = SizeTransform::new( + "exact", + SizeRelation::UpperBound, + [("vertices", Expr::parse("n^2"))], + ) + .unwrap() + .project_growth(); + + assert!(coarsened.get("vertices").unwrap().is_coarsened()); + assert!(!size_growth_dominates(&coarsened, &exact)); + assert!(!size_growth_dominates(&exact, &coarsened)); +} + #[test] fn exact_transform_evaluates_exactly() { let transform = SizeTransform::new( diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index d83350d7d..1972a580f 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -289,7 +289,7 @@ fn test_ilp_with_time_limit() { fn test_registered_ilp_pipeline_success() { use crate::models::graph::MaximumIndependentSet; use crate::registry::load_dyn; - use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; + use crate::solvers::{solve_deterministically, SolveOutcome, SolverExecution, SolverRequest}; use crate::topology::SimpleGraph; use std::collections::BTreeMap; @@ -306,7 +306,14 @@ fn test_registered_ilp_pipeline_success() { .unwrap(); let result = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); assert!(matches!(result.solver, SolverExecution::Ilp { .. })); - let eval = problem.evaluate(result.config.as_ref().unwrap()); + let SolveOutcome::Optimal { + config: Some(config), + .. + } = result.outcome + else { + panic!("registered ILP pipeline should return an optimal witness"); + }; + let eval = problem.evaluate(&config); assert!(eval.is_valid()); } diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index 70e6e7739..ed4875d30 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -1,6 +1,6 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::registry::load_dyn; -use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; +use crate::solvers::{solve_deterministically, SolveOutcome, SolverExecution, SolverRequest}; use crate::traits::Problem; use std::collections::BTreeMap; @@ -81,7 +81,7 @@ fn deterministic_solver_dispatch_unregistered_ilp_override_is_a_capability_error } #[test] -fn deterministic_solver_dispatch_customized_failure_does_not_fall_back() { +fn deterministic_solver_dispatch_customized_infeasibility_does_not_fall_back() { use crate::models::misc::AdditionalKey; // {0} is the only candidate key and it is already known, so the registered @@ -95,14 +95,11 @@ fn deterministic_solver_dispatch_customized_failure_does_not_fall_back() { ) .unwrap(); - let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); - assert!(matches!( - error, - crate::solvers::DeterministicSolveError::CustomizedNoSolution { .. } - )); + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!(result.outcome, SolveOutcome::Infeasible); let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); assert_eq!(brute_force.solver, SolverExecution::BruteForce); - assert!(brute_force.config.is_none()); + assert_eq!(brute_force.outcome, SolveOutcome::Infeasible); } #[test] @@ -122,11 +119,17 @@ fn deterministic_solver_dispatch_direct_ilp_uses_registered_one_node_pipeline() reduction_path: vec!["ILP".to_string()] } ); - assert_eq!(result.config, Some(vec![])); + assert!(matches!( + result.outcome, + SolveOutcome::Optimal { + config: Some(ref config), + .. + } if config.is_empty() + )); } #[test] -fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { +fn deterministic_solver_dispatch_ilp_infeasibility_does_not_fall_back() { let problem = ILP::::new( 0, vec![LinearConstraint::le(vec![], -1.0)], @@ -140,17 +143,11 @@ fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { ) .unwrap(); - let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); - assert!(matches!( - error, - crate::solvers::DeterministicSolveError::IlpSolve { - source: crate::solvers::ILPSolveError::Infeasible, - .. - } - )); + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!(result.outcome, SolveOutcome::Infeasible); let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); assert_eq!(brute_force.solver, SolverExecution::BruteForce); - assert!(brute_force.config.is_none()); + assert_eq!(brute_force.outcome, SolveOutcome::Infeasible); } #[test] @@ -178,6 +175,34 @@ fn deterministic_solver_execution_has_stable_tagged_json_contract() { ); } +#[test] +fn solve_outcome_has_disjoint_json_states() { + assert_eq!( + serde_json::to_value(SolveOutcome::Optimal { + config: Some(vec![1, 0]), + evaluation: "Max(1)".to_string(), + }) + .unwrap(), + serde_json::json!({ + "status": "optimal", + "solution": [1, 0], + "evaluation": "Max(1)" + }) + ); + assert_eq!( + serde_json::to_value(SolveOutcome::Optimal { + config: None, + evaluation: "Sum(56)".to_string(), + }) + .unwrap(), + serde_json::json!({"status": "optimal", "evaluation": "Sum(56)"}) + ); + assert_eq!( + serde_json::to_value(SolveOutcome::Infeasible).unwrap(), + serde_json::json!({"status": "infeasible"}) + ); +} + #[test] fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { use crate::models::graph::MaximumIndependentSet; @@ -233,7 +258,21 @@ fn deterministic_solver_dispatch_customized_default_allows_explicit_ilp_override let explicit_ilp = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); assert!(matches!(explicit_ilp.solver, SolverExecution::Ilp { .. })); - assert_eq!(default.evaluation, explicit_ilp.evaluation); + let SolveOutcome::Optimal { + evaluation: default_evaluation, + .. + } = default.outcome + else { + panic!("customized solver should find an optimum"); + }; + let SolveOutcome::Optimal { + evaluation: ilp_evaluation, + .. + } = explicit_ilp.outcome + else { + panic!("ILP solver should find an optimum"); + }; + assert_eq!(default_evaluation, ilp_evaluation); } #[test] @@ -259,7 +298,10 @@ fn deterministic_solver_dispatch_repeats_each_available_solver_class() { let first = solve_deterministically(&loaded, request).unwrap(); let second = solve_deterministically(&loaded, request).unwrap(); assert_eq!(first, second, "{request:?} changed its witness"); - evaluations.push(first.evaluation); + let SolveOutcome::Optimal { evaluation, .. } = first.outcome else { + panic!("{request:?} should find an optimum"); + }; + evaluations.push(evaluation); } assert!(evaluations.windows(2).all(|pair| pair[0] == pair[1])); } From 8c922a2faed0097cb5e79daaa77397fe4d63d674 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 17 Aug 2026 03:55:21 +0800 Subject: [PATCH 08/15] Track symbolic growth precision and remove unsound rule --- docs/paper/reductions.typ | 59 ------ src/growth.rs | 186 ++++++++++++------ ...feedbackarcset_maximumlikelihoodranking.rs | 119 ----------- src/rules/mod.rs | 2 - src/size.rs | 23 ++- src/unit_tests/growth.rs | 102 ++++++---- ...feedbackarcset_maximumlikelihoodranking.rs | 168 ---------------- src/unit_tests/size.rs | 38 +++- 8 files changed, 238 insertions(+), 459 deletions(-) delete mode 100644 src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs delete mode 100644 src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e52110438..7b7274ce0 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -16531,65 +16531,6 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Output the first $m$ variables $(f_0, dots, f_(m-1))$ as the flow assignment. ] -#{ - let mfas_mlr = load-example("MinimumFeedbackArcSet", "MaximumLikelihoodRanking") - let mfas_mlr_sol = mfas_mlr.solutions.at(0) - let source-arcs = mfas_mlr.source.instance.graph.arcs - let target-matrix = mfas_mlr.target.instance.matrix - let ranking = mfas_mlr_sol.target_config - let removed-indices = mfas_mlr_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => i) - let removed-arcs = removed-indices.map(i => source-arcs.at(i)) - let target-cost = 0 - for a in range(target-matrix.len()) { - for b in range(target-matrix.len()) { - if a != b and ranking.at(a) > ranking.at(b) { - target-cost += target-matrix.at(a).at(b) - } - } - } - let fmt-mat(m) = m.map(row => row.map(v => str(v)).join(", ")).join("; ") - [ - #reduction-rule("MinimumFeedbackArcSet", "MaximumLikelihoodRanking", - example: true, - example-caption: [5-vertex digraph ($n = #mfas_mlr.source.instance.graph.num_vertices$, $|A| = #source-arcs.len()$, unit weights) mapped to a skew-symmetric ranking matrix], - extra: [ - #pred-commands( - "pred create --example " + problem-spec(mfas_mlr.source) + " -o mfas.json", - "pred reduce mfas.json --via route.json -o bundle.json", - "pred solve bundle.json", - "pred evaluate mfas.json --config " + mfas_mlr_sol.source_config.map(str).join(","), - ) - - *Step 1 -- Source instance.* The source digraph has vertices ${#range(mfas_mlr.source.instance.graph.num_vertices).map(str).join(", ")}$ and arcs #{source-arcs.map(a => $(#(a.at(0)) arrow #(a.at(1)))$).join(", ")}, all with unit weight. The extracted optimal feedback arc set removes #{removed-arcs.map(a => $(#(a.at(0)) arrow #(a.at(1)))$).join(" and ")}, so $|F| = #removed-arcs.len()$. - - *Step 2 -- Build the comparison matrix.* The reduction keeps the same item set and writes $M_(i j) = 1$ when only $i arrow j$ exists, $M_(i j) = -1$ when only $j arrow i$ exists, and $M_(i j) = 0$ otherwise. For this instance, - $ M = mat(#fmt-mat(target-matrix)). $ - Every off-diagonal pair sums to $0$, so the target is a valid Maximum Likelihood Ranking instance with $c = 0$. - - *Step 3 -- Verify a solution.* The stored ranking vector is $(#ranking.map(str).join(", "))$, interpreted as the map from items to ranks. The target disagreement cost is $#target-cost = 2 dot #removed-arcs.len() - #source-arcs.len()$, and the extracted source witness is exactly the backward-arc set #{removed-arcs.map(a => $(#(a.at(0)) arrow #(a.at(1)))$).join(" and ")} #sym.checkmark - - *Multiplicity:* The fixture stores one canonical optimum. Other optimal rankings exist because the DAG obtained after removing the two backward arcs has multiple valid topological orders. - ], - )[ - This $O(n^2)$ reduction @garey1979 keeps the same vertex set as ranking items and encodes nonnegative integer arc weights in a skew-symmetric matrix with comparison count $c = 0$. The classical unit-weight construction is the special case with entries in $\{-1, 0, 1\}$. - ][ - _Construction._ Given a Minimum Feedback Arc Set instance $(G = (V, A), w)$ with $V = \{0, dots, n - 1\}$, let $w_(i j)$ be the weight of arc $i arrow j$ when it exists and $0$ otherwise. Construct the matrix $M in ZZ^(n times n)$ by setting $M_(i i) = 0$ and, for every distinct pair $i, j$, - $ - M_(i j) = w_(i j) - w_(j i). - $ - Then $M_(i j) + M_(j i) = 0$ for all $i != j$, so the target is a valid Maximum Likelihood Ranking instance with $n$ items. - - _Correctness._ ($arrow.r.double$) Let $pi$ be any ranking and let $B(pi) = \{(u arrow v) in A : pi(u) > pi(v)\}$ be its backward arcs. Removing $B(pi)$ leaves only forward arcs, hence a DAG, so $B(pi)$ is a feedback arc set. For each unordered pair, the selected matrix entry is the backward-arc weight minus the forward-arc weight. Therefore - $ - "cost"(pi) = 2 w(B(pi)) - sum_((u arrow v) in A) w_(u v). - $ - The target objective is thus twice the source objective shifted by a constant independent of $pi$, so minimizing disagreement cost minimizes feedback arc weight. ($arrow.l.double$) Let $F subset.eq A$ be a minimum feedback arc set, and take a topological order $pi$ of the DAG $G - F$. Every arc in $A backslash F$ is forward in $pi$, hence every backward arc under $pi$ lies in $F$, so $B(pi) subset.eq F$. Since weights are nonnegative and $B(pi)$ is itself a feedback arc set, minimality of $F$ forces $w(B(pi)) = w(F)$. Therefore an optimal source solution yields an optimal target ranking. - - _Solution extraction._ Given the target rank vector, output one source bit per source arc $(u arrow v)$ in source-arc order: set the bit to $1$ iff item $u$ is ranked after item $v$, and to $0$ otherwise. - ] - ] -} - #{ let mlr_ilp = load-example("MaximumLikelihoodRanking", "ILP") let mlr_ilp_sol = mlr_ilp.solutions.at(0) diff --git a/src/growth.rs b/src/growth.rs index e22dc2de6..579d272e1 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -17,9 +17,10 @@ //! · ∏_v v^(poly[v]) · ∏_v (log v)^(logs[v]) //! ``` //! -//! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms -//! (each summand of an asymptotic sum), a coarsened upper bound, or an unknown -//! result with explicit reasons for content we cannot bound symbolically. +//! and a [`Growth`] is either a known antichain of pairwise-incomparable dominant +//! terms (each summand of an asymptotic sum), together with whether that +//! antichain is tight or only an upper bound, or an unknown result with explicit +//! reasons for content we cannot bound symbolically. //! //! # Semantic foundation (the trust contract) //! @@ -335,16 +336,33 @@ pub struct Growth(GrowthState); #[derive(Clone, Debug, PartialEq, Eq)] enum GrowthState { - /// Complete antichain of pairwise-incomparable dominant terms. - Antichain(Vec), - /// A single upper-envelope term produced when the complete antichain - /// exceeds the configured cap. - Coarsened(GrowthTerm), + Known { + terms: Vec, + precision: GrowthPrecision, + }, /// Content outside the represented growth domain, with every reason that /// contributed to the result. Unknown(Vec), } +/// Whether a represented asymptotic class is certified tight or is only a +/// sound upper bound. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GrowthPrecision { + Tight, + UpperBound, +} + +impl GrowthPrecision { + fn combine(self, other: Self) -> Self { + if self == Self::Tight && other == Self::Tight { + Self::Tight + } else { + Self::UpperBound + } + } +} + /// A precise reason why an expression has no represented [`Growth`] value. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, thiserror::Error)] pub enum GrowthFailure { @@ -399,6 +417,17 @@ impl GrowthTerm { Ok(r) } + fn log_power_rounds_up(&self, power: &BigRational) -> bool { + self.variables.values().any(|growth| { + let scaled = BigRational::from_integer(BigInt::from(growth.log)) * power; + !scaled.is_integer() + }) + } + + fn contains_log_factor(&self) -> bool { + self.variables.values().any(|growth| growth.log != 0) + } + /// Multiply two monomials (add matching exponents). fn mul(&self, other: &GrowthTerm) -> Result { let mut t = self.clone(); @@ -491,16 +520,18 @@ impl Growth { pub fn failures(&self) -> Option<&[GrowthFailure]> { match &self.0 { - GrowthState::Antichain(_) | GrowthState::Coarsened(_) => None, + GrowthState::Known { .. } => None, GrowthState::Unknown(failures) => Some(failures), } } - /// Whether the complete antichain was replaced by a resource-bounded upper - /// envelope. Coarsened values are safe bounds but not safe evidence for - /// eliminating another path from a Pareto frontier. - pub fn is_coarsened(&self) -> bool { - matches!(self.0, GrowthState::Coarsened(_)) + /// Precision of a represented growth class, or `None` when the expression + /// is outside the represented domain. + pub fn precision(&self) -> Option { + match self.0 { + GrowthState::Known { precision, .. } => Some(precision), + GrowthState::Unknown(_) => None, + } } /// Compute the growth class of an expression in a single bottom-up pass. @@ -513,27 +544,23 @@ impl Growth { growth_from_analysis(expr, analysis, &mut HashMap::new()) } - /// Partial order: `true` iff `self` grows at least as fast as `other`. + /// Compare the represented bounds, ignoring how tightly they approximate + /// their source expressions. /// /// Per the growth-rate reading, unknown growth is the top element (it /// may be arbitrarily large, e.g. a factorial), so it dominates everything /// and nothing known dominates it. For two term antichains, `self` /// dominates `other` iff every term of `other` is dominated-or-equal by /// some term of `self` — the standard antichain (Pareto) comparison. - pub fn dominates(&self, other: &Growth) -> bool { + /// Callers deciding whether one source expression can eliminate another + /// must additionally account for [`GrowthPrecision`]. + pub(crate) fn bound_dominates(&self, other: &Growth) -> bool { match (&self.0, &other.0) { (GrowthState::Unknown(_), _) => true, (_, GrowthState::Unknown(_)) => false, - (GrowthState::Antichain(a), GrowthState::Antichain(b)) => { + (GrowthState::Known { terms: a, .. }, GrowthState::Known { terms: b, .. }) => { b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) } - (GrowthState::Antichain(a), GrowthState::Coarsened(b)) => { - a.iter().any(|ta| ta.dominates_or_eq(b)) - } - (GrowthState::Coarsened(a), GrowthState::Antichain(b)) => { - b.iter().all(|tb| a.dominates_or_eq(tb)) - } - (GrowthState::Coarsened(a), GrowthState::Coarsened(b)) => a.dominates_or_eq(b), } } @@ -546,8 +573,7 @@ impl Growth { pub fn to_expr(&self) -> Option { match &self.0 { GrowthState::Unknown(_) => None, - GrowthState::Coarsened(term) => Some(term_to_expr(term)), - GrowthState::Antichain(terms) => { + GrowthState::Known { terms, .. } => { if terms.is_empty() { return Some(Expr::integer(1)); } @@ -586,7 +612,16 @@ fn growth_from_analysis( let facts = analysis.facts(expression); if facts.is_constant { let growth = if facts.constant_domain == Some(true) { - constant_growth() + let growth = constant_growth(); + if facts + .exact_rational + .as_ref() + .is_some_and(|value| value.is_negative()) + { + growth.into_upper_bound() + } else { + growth + } } else { unknown(GrowthFailure::InvalidConstantDomain { expression: expression.to_string(), @@ -611,11 +646,24 @@ fn growth_from_analysis( .map(|value| growth_from_analysis(value, analysis, memo)) .reduce(add) .expect("normalized sum has at least two terms"), - ExprNode::Mul(values) => values - .iter() - .map(|value| growth_from_analysis(value, analysis, memo)) - .reduce(mul) - .expect("normalized product has at least two factors"), + ExprNode::Mul(values) => { + let growth = values + .iter() + .map(|value| growth_from_analysis(value, analysis, memo)) + .reduce(mul) + .expect("normalized product has at least two factors"); + if values.iter().any(|value| { + analysis + .facts(value) + .exact_rational + .as_ref() + .is_some_and(|constant| constant.is_negative()) + }) { + growth.into_upper_bound() + } else { + growth + } + } ExprNode::Pow(base, exponent) => { let base_facts = analysis.facts(base); let exponent_facts = analysis.facts(exponent); @@ -800,15 +848,26 @@ fn prune(mut terms: Vec) -> Vec { } fn exact_growth(terms: Vec) -> Growth { - finish_growth(terms, false) + finish_growth(terms, GrowthPrecision::Tight) } -fn finish_growth(terms: Vec, already_coarsened: bool) -> Growth { +fn finish_growth(terms: Vec, mut precision: GrowthPrecision) -> Growth { let terms = prune(terms); - if already_coarsened || terms.len() > ANTICHAIN_CAP { - Growth(GrowthState::Coarsened(GrowthTerm::upper_envelope(&terms))) + let terms = if terms.len() > ANTICHAIN_CAP { + precision = GrowthPrecision::UpperBound; + vec![GrowthTerm::upper_envelope(&terms)] } else { - Growth(GrowthState::Antichain(terms)) + terms + }; + Growth(GrowthState::Known { terms, precision }) +} + +impl Growth { + fn into_upper_bound(mut self) -> Self { + if let GrowthState::Known { precision, .. } = &mut self.0 { + *precision = GrowthPrecision::UpperBound; + } + self } } @@ -817,10 +876,10 @@ fn add(a: Growth, b: Growth) -> Growth { if a.failures().is_some() || b.failures().is_some() { return merge_unknown(a, b); } - let already_coarsened = a.is_coarsened() || b.is_coarsened(); + let precision = known_precision(&a).combine(known_precision(&b)); let mut terms = into_terms(a); terms.extend(into_terms(b)); - finish_growth(terms, already_coarsened) + finish_growth(terms, precision) } /// Pairwise product of two antichains. @@ -828,7 +887,7 @@ fn mul(a: Growth, b: Growth) -> Growth { if a.failures().is_some() || b.failures().is_some() { return merge_unknown(a, b); } - let already_coarsened = a.is_coarsened() || b.is_coarsened(); + let precision = known_precision(&a).combine(known_precision(&b)); let x = into_terms(a); let y = into_terms(b); let mut product = Vec::with_capacity(x.len() * y.len()); @@ -840,32 +899,44 @@ fn mul(a: Growth, b: Growth) -> Growth { } } } - finish_growth(product, already_coarsened) + finish_growth(product, precision) } /// Raise a whole antichain to a nonnegative real power `k` (raise each term). fn pow_const(g: Growth, k: &BigRational) -> Growth { match g.0 { GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), - GrowthState::Antichain(terms) => match terms.iter().map(|term| term.pow(k)).collect() { - Ok(terms) => exact_growth(terms), - Err(failure) => unknown(failure), - }, - GrowthState::Coarsened(term) => match term.pow(k) { - Ok(term) => Growth(GrowthState::Coarsened(term)), - Err(failure) => unknown(failure), - }, + GrowthState::Known { + terms, + mut precision, + } => { + if terms.iter().any(|term| term.log_power_rounds_up(k)) { + precision = GrowthPrecision::UpperBound; + } + match terms.iter().map(|term| term.pow(k)).collect() { + Ok(terms) => finish_growth(terms, precision), + Err(failure) => unknown(failure), + } + } } } fn into_terms(growth: Growth) -> Vec { match growth.0 { - GrowthState::Antichain(terms) => terms, - GrowthState::Coarsened(term) => vec![term], + GrowthState::Known { terms, .. } => terms, GrowthState::Unknown(_) => unreachable!("unknown growth is handled before term access"), } } +fn known_precision(growth: &Growth) -> GrowthPrecision { + match growth.0 { + GrowthState::Known { precision, .. } => precision, + GrowthState::Unknown(_) => { + unreachable!("unknown growth is handled before precision access") + } + } +} + /// Transfer function for a symbolic fixed-base exponential. fn exponential( base: ExpBase, @@ -908,19 +979,22 @@ fn exponential( fn log_growth(g: Growth) -> Growth { match g.0 { GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), - GrowthState::Antichain(terms) => { + GrowthState::Known { + terms, + mut precision, + } => { let mut out = Vec::new(); for t in &terms { + if t.contains_log_factor() { + precision = GrowthPrecision::UpperBound; + } out.extend(log_term(t)); } if out.is_empty() { out.push(GrowthTerm::one()); // log(O(1)) = O(1) } - exact_growth(out) + finish_growth(out, precision) } - GrowthState::Coarsened(term) => Growth(GrowthState::Coarsened(GrowthTerm::upper_envelope( - &log_term(&term), - ))), } } diff --git a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs deleted file mode 100644 index 635e56bb0..000000000 --- a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Reduction from MinimumFeedbackArcSet to MaximumLikelihoodRanking. -//! -//! A ranking induces the feedback arc set of backward arcs. The target matrix -//! uses the skew-symmetric `c = 0` encoding `a_ij = w_ij - w_ji`. - -use crate::models::graph::MinimumFeedbackArcSet; -use crate::models::misc::MaximumLikelihoodRanking; -use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; - -#[allow(clippy::needless_range_loop)] -fn build_skew_symmetric_matrix(problem: &MinimumFeedbackArcSet) -> Vec> { - let n = problem.num_vertices(); - let graph = problem.graph(); - let mut matrix = vec![vec![0i32; n]; n]; - let arc_weights = graph - .arcs() - .into_iter() - .zip(problem.weights().iter().copied()) - .collect::>(); - - for i in 0..n { - for j in (i + 1)..n { - let ij = arc_weights.get(&(i, j)).copied().unwrap_or(0); - let ji = arc_weights.get(&(j, i)).copied().unwrap_or(0); - let difference = ij.checked_sub(ji).expect( - "MinimumFeedbackArcSet -> MaximumLikelihoodRanking weight difference overflow", - ); - matrix[i][j] = difference; - matrix[j][i] = difference.checked_neg().expect( - "MinimumFeedbackArcSet -> MaximumLikelihoodRanking negated weight difference overflow", - ); - } - } - - matrix -} - -/// Result of reducing MinimumFeedbackArcSet to MaximumLikelihoodRanking. -#[derive(Debug, Clone)] -pub struct ReductionFASToMLR { - target: MaximumLikelihoodRanking, - source_arcs: Vec<(usize, usize)>, -} - -impl ReductionResult for ReductionFASToMLR { - type Source = MinimumFeedbackArcSet; - type Target = MaximumLikelihoodRanking; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_solution( - &self, - target_solution: &[usize], - ) -> crate::rules::ExtractionResult> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - self.source_arcs - .iter() - .map(|&(u, v)| usize::from(target_solution[u] > target_solution[v])) - .collect() - }) - } -} - -#[reduction( - size = exact { - num_items = "num_vertices", - } -)] -impl ReduceTo for MinimumFeedbackArcSet { - type Result = ReductionFASToMLR; - - fn reduce_to(&self) -> Self::Result { - ReductionFASToMLR { - target: MaximumLikelihoodRanking::new(build_skew_symmetric_matrix(self)), - source_arcs: self.graph().arcs(), - } - } -} - -#[cfg(feature = "example-db")] -pub(crate) fn canonical_rule_example_specs() -> Vec { - use crate::export::SolutionPair; - use crate::solvers::BruteForce; - - vec![crate::example_db::specs::RuleExampleSpec { - id: "minimumfeedbackarcset_to_maximumlikelihoodranking", - build: || { - let source = MinimumFeedbackArcSet::new( - crate::topology::DirectedGraph::new( - 5, - vec![(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 2), (0, 4)], - ), - vec![1i32; 7], - ); - let reduction = ReduceTo::::reduce_to(&source); - let target_witness = BruteForce::new() - .find_witness(reduction.target_problem()) - .expect("target should have an optimum"); - let source_witness = reduction.extract_solution(&target_witness).unwrap(); - - crate::example_db::specs::rule_example_with_witness::<_, MaximumLikelihoodRanking>( - source, - SolutionPair { - source_config: source_witness, - target_config: target_witness, - }, - ) - }, - }] -} - -#[cfg(test)] -#[path = "../unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs"] -mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index a1620aa2e..8e76ff2c3 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -88,7 +88,6 @@ pub(crate) mod maximumsetpacking_qubo; pub(crate) mod minimumcostmaximumflow_minimumcostcirculation; pub(crate) mod minimumcoveringbycliques_minimumintersectiongraphbasis; pub(crate) mod minimumdiscreteplanarinversekinematics_qubo; -pub(crate) mod minimumfeedbackarcset_maximumlikelihoodranking; pub(crate) mod minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters; pub(crate) mod minimummaximalmatching_maximumachromaticnumber; pub(crate) mod minimummaximalmatching_minimummatrixdomination; @@ -399,7 +398,6 @@ pub(crate) fn canonical_rule_example_specs() -> Vec bool { .any(|(name, value)| right.get(name).is_some_and(|other| *value < other)) } -/// Return whether `left` has no faster growth in every symbolic size field and -/// strictly slower growth in at least one field. +/// Return whether the available bounds prove that `left` has no faster growth +/// in every symbolic size field and strictly slower growth in at least one. +/// +/// The right-hand path must be exact with tight growth projections because it +/// is the path being removed. The left-hand path may itself be an upper bound: +/// proving that upper bound smaller than the right-hand tight class is enough. pub fn size_growth_dominates(left: &SizeGrowth, right: &SizeGrowth) -> bool { if left.fields.len() != right.fields.len() { return false; } + if right.relation != SizeRelation::Exact { + return false; + } let mut strictly_smaller = false; for (name, growth) in left.fields() { let Some(other) = right.get(name) else { return false; }; - if growth.failures().is_some() - || other.failures().is_some() - || growth.is_coarsened() - || other.is_coarsened() - { + if growth.failures().is_some() || other.precision() != Some(GrowthPrecision::Tight) { return false; } - let left_at_most = other.dominates(growth); + let left_at_most = other.bound_dominates(growth); if !left_at_most { return false; } - strictly_smaller |= !growth.dominates(other); + strictly_smaller |= !growth.bound_dominates(other); } strictly_smaller } diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index ba41010b7..37a889af4 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -1,8 +1,8 @@ //! Unit tests for the symbolic growth domain (`src/growth.rs`). use super::{ - add, exact_growth, mul, ExpBase, Growth, GrowthFailure, GrowthState, GrowthTerm, - VariableGrowth, ANTICHAIN_CAP, + add, exact_growth, mul, ExpBase, Growth, GrowthFailure, GrowthPrecision, GrowthState, + GrowthTerm, VariableGrowth, ANTICHAIN_CAP, }; use crate::expr::{ evaluate_approximate, expression_from_approximation, AlgebraicAnalysis, Expr, ExprNode, @@ -45,8 +45,7 @@ fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> Grow fn terms_of(g: &Growth) -> &[GrowthTerm] { match &g.0 { - GrowthState::Antichain(terms) => terms, - GrowthState::Coarsened(term) => std::slice::from_ref(term), + GrowthState::Known { terms, .. } => terms, GrowthState::Unknown(failures) => panic!("expected known growth, got {failures:?}"), } } @@ -88,7 +87,7 @@ fn test_growth_relations_against_sympy_limits() { for case in fixture.growth_cases { let left = g(&case.left); let right = g(&case.right); - let actual = (left.dominates(&right), right.dominates(&left)); + let actual = (left.bound_dominates(&right), right.bound_dominates(&left)); let expected = match case.relation { SympyGrowthRelation::Equivalent => (true, true), SympyGrowthRelation::LeftDominates => (true, false), @@ -143,8 +142,8 @@ fn test_growth_no_expansion_regression() { fn test_growth_exponential_dominates_polynomial() { let exp = g("1.001^n"); let poly = g("n^100"); - assert!(exp.dominates(&poly)); - assert!(!poly.dominates(&exp)); + assert!(exp.bound_dominates(&poly)); + assert!(!poly.bound_dominates(&exp)); } /// 3. Incomparability is honest: neither `n^2` nor `n*m` dominates the other, @@ -153,8 +152,8 @@ fn test_growth_exponential_dominates_polynomial() { fn test_growth_incomparable_terms_both_kept() { let n2 = g("n^2"); let nm = g("n*m"); - assert!(!n2.dominates(&nm)); - assert!(!nm.dominates(&n2)); + assert!(!n2.bound_dominates(&nm)); + assert!(!nm.bound_dominates(&n2)); let sum = g("n^2 + n*m"); assert_eq!(terms_of(&sum).len(), 2); @@ -166,32 +165,32 @@ fn test_growth_incomparable_terms_both_kept() { fn test_growth_exponent_rates_exact() { let two_2n = g("2^(2*n)"); let two_n = g("2^n"); - assert!(two_2n.dominates(&two_n)); - assert!(!two_n.dominates(&two_2n)); + assert!(two_2n.bound_dominates(&two_n)); + assert!(!two_n.bound_dominates(&two_2n)); let three_n = g("3^n"); - assert!(three_n.dominates(&two_n)); - assert!(!two_n.dominates(&three_n)); + assert!(three_n.bound_dominates(&two_n)); + assert!(!two_n.bound_dominates(&three_n)); let exp_2n = g("exp(2*n)"); let exp_n = g("exp(n)"); - assert!(exp_2n.dominates(&exp_n)); - assert!(!exp_n.dominates(&exp_2n)); + assert!(exp_2n.bound_dominates(&exp_n)); + assert!(!exp_n.bound_dominates(&exp_2n)); - assert!(g("0.5^(-2*n)").dominates(&g("0.5^(-n)"))); - assert!(g("0.25^(-n)").dominates(&g("0.5^(-n)"))); + assert!(g("0.5^(-2*n)").bound_dominates(&g("0.5^(-n)"))); + assert!(g("0.25^(-n)").bound_dominates(&g("0.5^(-n)"))); } #[test] fn test_growth_exact_coefficients_do_not_cross_boundaries() { let polynomial = g("n^1000"); - assert!(g("2^(n/9007199254740992)").dominates(&polynomial)); - assert!(g("(9007199254740993/9007199254740992)^n").dominates(&polynomial)); + assert!(g("2^(n/9007199254740992)").bound_dominates(&polynomial)); + assert!(g("(9007199254740993/9007199254740992)^n").bound_dominates(&polynomial)); let unit_rate = g("2^n"); let larger_rate = g("2^(9007199254740993*n/9007199254740992)"); - assert!(larger_rate.dominates(&unit_rate)); - assert!(!unit_rate.dominates(&larger_rate)); + assert!(larger_rate.bound_dominates(&unit_rate)); + assert!(!unit_rate.bound_dominates(&larger_rate)); } #[test] @@ -232,8 +231,8 @@ fn test_every_registered_complexity_uses_the_shared_analysis() { fn test_growth_unproved_multi_base_comparison_is_retained() { let left = g("2^(2*n) * 3^n"); let right = g("2^n * 4^n"); - assert!(!left.dominates(&right)); - assert!(!right.dominates(&left)); + assert!(!left.bound_dominates(&right)); + assert!(!right.bound_dominates(&left)); assert_eq!(terms_of(&g("2^(2*n) * 3^n + 2^n * 4^n")).len(), 2); } @@ -334,8 +333,25 @@ fn test_proven_equal_exponential_spelling_is_deterministic() { /// absolute-value idiom. #[test] fn test_growth_widening() { - assert_eq!(g("n - m"), g("n + m")); - assert_eq!(g("sqrt((n - m)^2)"), g("n + m")); + let sum = g("n + m"); + for widened in [g("n - m"), g("sqrt((n - m)^2)")] { + assert_eq!(widened.to_expr(), sum.to_expr()); + assert_eq!(widened.precision(), Some(GrowthPrecision::UpperBound)); + } + assert_eq!(sum.precision(), Some(GrowthPrecision::Tight)); +} + +#[test] +fn approximation_operations_record_upper_bound_precision() { + assert_eq!( + g("log(log(n))").precision(), + Some(GrowthPrecision::UpperBound) + ); + assert_eq!( + g("sqrt(log(n))").precision(), + Some(GrowthPrecision::UpperBound) + ); + assert_eq!(g("log(n)").precision(), Some(GrowthPrecision::Tight)); } /// 6. Determinism: the antichain is canonically sorted, so structurally @@ -504,7 +520,7 @@ fn test_growth_exponential_roundtrip_is_exact() { fn test_growth_exponential_variants() { // exp(n) is represented directly as e^n: exponential, dominates any polynomial. let en = g("exp(n)"); - assert!(en.dominates(&g("n^5"))); + assert!(en.bound_dominates(&g("n^5"))); assert!(matches!( g("2^(n - m)").failures(), Some([GrowthFailure::DecayingExponential { variable, .. }]) if variable == "m" @@ -522,7 +538,7 @@ fn test_growth_exponential_variants() { // A fractional base with a negative exponent grows and retains that exact // symbolic base instead of being translated through a common logarithm. assert_eq!(g("0.5^(-n)").to_big_o(), "O(0.5^(-1 * n))"); - assert!(g("0.5^(-n)").dominates(&g("n^100"))); + assert!(g("0.5^(-n)").bound_dominates(&g("n^100"))); } /// `log` lowers each level: log of an exponential is linear, log of a @@ -562,9 +578,9 @@ fn test_growth_log_levels() { fn test_growth_unknown_dominance() { let n2 = g("n^2"); let unknown = g("factorial(n)"); - assert!(unknown.dominates(&n2)); - assert!(!n2.dominates(&unknown)); - assert!(unknown.dominates(&unknown)); + assert!(unknown.bound_dominates(&n2)); + assert!(!n2.bound_dominates(&unknown)); + assert!(unknown.bound_dominates(&unknown)); } /// Complete antichains are retained through the configured boundary, then @@ -580,14 +596,14 @@ fn test_growth_antichain_cap_coarsens_at_overflow() { .collect(); let at_cap = exact_growth(terms[..ANTICHAIN_CAP].to_vec()); - assert!(!at_cap.is_coarsened()); + assert_eq!(at_cap.precision(), Some(GrowthPrecision::Tight)); assert_eq!(terms_of(&at_cap).len(), ANTICHAIN_CAP); let overflow = exact_growth(terms.clone()); - assert!(overflow.is_coarsened()); + assert_eq!(overflow.precision(), Some(GrowthPrecision::UpperBound)); assert_eq!(terms_of(&overflow).len(), 1); for original in terms { - assert!(overflow.dominates(&exact_growth(vec![original]))); + assert!(overflow.bound_dominates(&exact_growth(vec![original]))); } let reversed = exact_growth( @@ -615,9 +631,9 @@ fn test_growth_coarsens_large_unproved_exponential_antichain() { .collect::>(); let growth = exact_growth(terms.clone()); - assert!(growth.is_coarsened()); + assert_eq!(growth.precision(), Some(GrowthPrecision::UpperBound)); for original in terms { - assert!(growth.dominates(&exact_growth(vec![original]))); + assert!(growth.bound_dominates(&exact_growth(vec![original]))); } } @@ -989,12 +1005,20 @@ fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { match (&a.0, &b.0) { (GrowthState::Unknown(_), GrowthState::Unknown(_)) => true, - (GrowthState::Antichain(ta), GrowthState::Antichain(tb)) => { + ( + GrowthState::Known { + terms: ta, + precision: _, + }, + GrowthState::Known { + terms: tb, + precision: _, + }, + ) => { ta.len() == tb.len() && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) && tb.iter().all(|u| ta.iter().any(|t| term_approx_eq(t, u))) } - (GrowthState::Coarsened(a), GrowthState::Coarsened(b)) => term_approx_eq(a, b), _ => false, } } @@ -1045,8 +1069,8 @@ fn test_growth_property_dominance_sound() { let higher_expression = lower_expression.clone() * ratio_expression.clone(); let lower = Growth::from_expr(&lower_expression); let higher = Growth::from_expr(&higher_expression); - assert!(higher.dominates(&lower)); - assert!(!lower.dominates(&higher)); + assert!(higher.bound_dominates(&lower)); + assert!(!lower.bound_dominates(&higher)); let r1 = evaluate_approximate(&ratio_expression, &joint_size(16)).unwrap(); let r2 = evaluate_approximate(&ratio_expression, &joint_size(64)).unwrap(); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs deleted file mode 100644 index f5e3d0a0f..000000000 --- a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ /dev/null @@ -1,168 +0,0 @@ -#[cfg(feature = "example-db")] -use super::canonical_rule_example_specs; -use super::ReductionFASToMLR; -use crate::models::graph::MinimumFeedbackArcSet; -use crate::models::misc::MaximumLikelihoodRanking; -use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; -use crate::rules::traits::ReductionResult; -use crate::rules::ReduceTo; -#[cfg(feature = "example-db")] -use crate::solvers::BruteForce; -use crate::topology::DirectedGraph; -#[cfg(feature = "example-db")] -use crate::traits::Problem; - -fn issue_example_source() -> MinimumFeedbackArcSet { - MinimumFeedbackArcSet::new( - DirectedGraph::new( - 5, - vec![(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 2), (0, 4)], - ), - vec![1i32; 7], - ) -} - -fn dag_source() -> MinimumFeedbackArcSet { - MinimumFeedbackArcSet::new( - DirectedGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), - vec![1i32; 6], - ) -} - -fn bidirectional_source() -> MinimumFeedbackArcSet { - MinimumFeedbackArcSet::new( - DirectedGraph::new(3, vec![(0, 1), (1, 0), (1, 2)]), - vec![1i32; 3], - ) -} - -fn weighted_cycle_source() -> MinimumFeedbackArcSet { - MinimumFeedbackArcSet::new( - DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), - vec![10i32, 1, 1], - ) -} - -#[test] -fn test_minimumfeedbackarcset_to_maximumlikelihoodranking_closed_loop() { - let source = issue_example_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - - assert_optimization_round_trip_from_optimization_target( - &source, - &reduction, - "MinimumFeedbackArcSet -> MaximumLikelihoodRanking closed loop (issue example)", - ); -} - -#[test] -fn test_minimumfeedbackarcset_to_maximumlikelihoodranking_dag_closed_loop() { - let source = dag_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - - assert_optimization_round_trip_from_optimization_target( - &source, - &reduction, - "MinimumFeedbackArcSet -> MaximumLikelihoodRanking closed loop (DAG)", - ); -} - -#[test] -fn test_reduction_matrix_matches_issue_example() { - let source = issue_example_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - let target = reduction.target_problem(); - - assert_eq!(target.num_items(), 5); - assert_eq!(target.comparison_count(), 0); - assert_eq!( - target.matrix(), - &vec![ - vec![0, 1, -1, 0, 1], - vec![-1, 0, 1, 0, 0], - vec![1, -1, 0, 1, -1], - vec![0, 0, -1, 0, 1], - vec![-1, 0, 1, -1, 0], - ] - ); -} - -#[test] -fn test_bidirectional_arcs_map_to_zero_entries() { - let source = bidirectional_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - let target = reduction.target_problem(); - - assert_eq!(target.comparison_count(), 0); - assert_eq!( - target.matrix(), - &vec![vec![0, 0, 0], vec![0, 0, 1], vec![0, -1, 0]] - ); -} - -#[test] -fn test_solution_extraction_marks_backward_arcs() { - let source = issue_example_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - - let source_config = reduction.extract_solution(&[0, 1, 2, 3, 4]).unwrap(); - assert_eq!(source_config, vec![0, 0, 1, 0, 0, 1, 0]); -} - -#[test] -fn test_weighted_instances_preserve_the_optimum() { - let source = weighted_cycle_source(); - let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - - assert_eq!( - reduction.target_problem().matrix(), - &vec![vec![0, 10, -1], vec![-10, 0, 1], vec![1, -1, 0]] - ); - assert_optimization_round_trip_from_optimization_target( - &source, - &reduction, - "weighted MinimumFeedbackArcSet -> MaximumLikelihoodRanking closed loop", - ); -} - -#[cfg(feature = "example-db")] -#[test] -fn test_canonical_rule_example_spec_builds() { - let example = (canonical_rule_example_specs() - .into_iter() - .find(|spec| spec.id == "minimumfeedbackarcset_to_maximumlikelihoodranking") - .expect("example spec should be registered") - .build)(); - - assert_eq!(example.source.problem, "MinimumFeedbackArcSet"); - assert_eq!(example.target.problem, "MaximumLikelihoodRanking"); - assert_eq!(example.solutions.len(), 1); - - let source: MinimumFeedbackArcSet = - serde_json::from_value(example.source.instance.clone()) - .expect("source example deserializes"); - let target: MaximumLikelihoodRanking = serde_json::from_value(example.target.instance.clone()) - .expect("target example deserializes"); - let solution = &example.solutions[0]; - - let source_metric = source.evaluate(&solution.source_config); - let target_metric = target.evaluate(&solution.target_config); - assert!( - source_metric.is_valid(), - "source witness should be feasible" - ); - assert!( - target_metric.is_valid(), - "target witness should be feasible" - ); - - let best_source = BruteForce::new() - .find_witness(&source) - .expect("source example should have an optimum"); - let best_target = BruteForce::new() - .find_witness(&target) - .expect("target example should have an optimum"); - - assert_eq!(source_metric, source.evaluate(&best_source)); - assert_eq!(target_metric, target.evaluate(&best_target)); -} diff --git a/src/unit_tests/size.rs b/src/unit_tests/size.rs index b62f7fdf3..249457a28 100644 --- a/src/unit_tests/size.rs +++ b/src/unit_tests/size.rs @@ -3,6 +3,7 @@ use super::{ SizeTransformError, SizeValues, }; use crate::expr::Expr; +use crate::growth::GrowthPrecision; use crate::types::ProblemSize; use num_bigint::BigUint; use num_traits::One; @@ -18,7 +19,7 @@ fn pareto_order_minimizes_every_concrete_size_field() { } #[test] -fn pareto_order_minimizes_every_symbolic_growth_field() { +fn two_upper_bounds_cannot_eliminate_either_symbolic_path() { let linear = SizeTransform::new( "linear", SizeRelation::UpperBound, @@ -37,11 +38,33 @@ fn pareto_order_minimizes_every_symbolic_growth_field() { .unwrap() .project_growth(); - assert!(size_growth_dominates(&linear, &quadratic)); + assert!(!size_growth_dominates(&linear, &quadratic)); + assert!(!size_growth_dominates(&quadratic, &linear)); } #[test] -fn coarsened_growth_cannot_eliminate_a_symbolic_path() { +fn an_upper_bound_can_eliminate_a_proven_tight_slower_path() { + let linear_bound = SizeTransform::new( + "linear bound", + SizeRelation::UpperBound, + [("vertices", Expr::parse("n"))], + ) + .unwrap() + .project_growth(); + let exact_quadratic = SizeTransform::new( + "exact quadratic", + SizeRelation::Exact, + [("vertices", Expr::parse("n^2"))], + ) + .unwrap() + .project_growth(); + + assert!(size_growth_dominates(&linear_bound, &exact_quadratic)); + assert!(!size_growth_dominates(&exact_quadratic, &linear_bound)); +} + +#[test] +fn antichain_collapse_cannot_eliminate_a_symbolic_path() { let wide_expression = Expr::parse( &(0..33) .map(|index| format!("v{index}")) @@ -50,20 +73,23 @@ fn coarsened_growth_cannot_eliminate_a_symbolic_path() { ); let coarsened = SizeTransform::new( "coarsened", - SizeRelation::UpperBound, + SizeRelation::Exact, [("vertices", wide_expression)], ) .unwrap() .project_growth(); let exact = SizeTransform::new( "exact", - SizeRelation::UpperBound, + SizeRelation::Exact, [("vertices", Expr::parse("n^2"))], ) .unwrap() .project_growth(); - assert!(coarsened.get("vertices").unwrap().is_coarsened()); + assert_eq!( + coarsened.get("vertices").unwrap().precision(), + Some(GrowthPrecision::UpperBound) + ); assert!(!size_growth_dominates(&coarsened, &exact)); assert!(!size_growth_dominates(&exact, &coarsened)); } From 5266e12c89a0c3c4b6c37372344c3e159602d358 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 17 Aug 2026 11:58:50 +0800 Subject: [PATCH 09/15] Simplify symbolic Big-O comparison --- src/growth.rs | 213 +++++++-------------------------------- src/size.rs | 32 ++---- src/unit_tests/growth.rs | 121 +++++++++------------- src/unit_tests/size.rs | 44 ++++---- 4 files changed, 114 insertions(+), 296 deletions(-) diff --git a/src/growth.rs b/src/growth.rs index 579d272e1..5700fdd81 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -3,10 +3,10 @@ //! //! Where full monomial canonicalization answers Big-O questions by expanding an //! [`Expr`] to monomial normal form, with exponential cost in nesting depth, the -//! growth domain computes an asymptotic upper bound bottom-up without rewriting +//! growth domain computes a Big-O normal form bottom-up without rewriting //! the source AST into a fully distributed polynomial. Work is output-sensitive: -//! antichains are retained up to 32 terms; larger fronts are replaced -//! by one sound componentwise upper bound. +//! antichains are retained up to 32 terms; larger fronts are reported as +//! unsupported instead of silently approximated. //! //! # Representation //! @@ -18,9 +18,8 @@ //! ``` //! //! and a [`Growth`] is either a known antichain of pairwise-incomparable dominant -//! terms (each summand of an asymptotic sum), together with whether that -//! antichain is tight or only an upper bound, or an unknown result with explicit -//! reasons for content we cannot bound symbolically. +//! terms (each summand of an asymptotic sum), or an unknown result with explicit +//! reasons for content we cannot represent symbolically. //! //! # Semantic foundation (the trust contract) //! @@ -61,8 +60,7 @@ use num_traits::{One, Signed, ToPrimitive, Zero}; use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap}; -/// Maximum number of incomparable terms retained before replacing the complete -/// antichain with one sound componentwise upper bound. +/// Maximum number of incomparable terms retained in one Big-O normal form. const ANTICHAIN_CAP: usize = 32; /// An exact fixed exponential base. @@ -302,24 +300,6 @@ impl VariableGrowth { None } } - - fn upper_envelope(&mut self, other: &Self) { - for (base, coefficient) in &other.exp { - match self.exp.get_mut(base) { - Some(current) - if base.coefficient_cmp(coefficient, current) == Ordering::Greater => - { - *current = coefficient.clone(); - } - Some(_) => {} - None => { - self.exp.insert(base.clone(), coefficient.clone()); - } - } - } - self.poly = self.poly.clone().max(other.poly.clone()); - self.log = self.log.max(other.log); - } } /// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)`. @@ -336,33 +316,12 @@ pub struct Growth(GrowthState); #[derive(Clone, Debug, PartialEq, Eq)] enum GrowthState { - Known { - terms: Vec, - precision: GrowthPrecision, - }, + Known(Vec), /// Content outside the represented growth domain, with every reason that /// contributed to the result. Unknown(Vec), } -/// Whether a represented asymptotic class is certified tight or is only a -/// sound upper bound. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum GrowthPrecision { - Tight, - UpperBound, -} - -impl GrowthPrecision { - fn combine(self, other: Self) -> Self { - if self == Self::Tight && other == Self::Tight { - Self::Tight - } else { - Self::UpperBound - } - } -} - /// A precise reason why an expression has no represented [`Growth`] value. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, thiserror::Error)] pub enum GrowthFailure { @@ -390,6 +349,8 @@ pub enum GrowthFailure { }, #[error("missing substitution for {0}")] MissingSubstitution(String), + #[error("Big-O antichain has {terms} terms, exceeding the limit of {limit}")] + AntichainLimitExceeded { limit: usize, terms: usize }, } impl GrowthTerm { @@ -417,17 +378,6 @@ impl GrowthTerm { Ok(r) } - fn log_power_rounds_up(&self, power: &BigRational) -> bool { - self.variables.values().any(|growth| { - let scaled = BigRational::from_integer(BigInt::from(growth.log)) * power; - !scaled.is_integer() - }) - } - - fn contains_log_factor(&self) -> bool { - self.variables.values().any(|growth| growth.log != 0) - } - /// Multiply two monomials (add matching exponents). fn mul(&self, other: &GrowthTerm) -> Result { let mut t = self.clone(); @@ -497,20 +447,6 @@ impl GrowthTerm { Some(Ordering::Greater) | Some(Ordering::Equal) ) } - - fn upper_envelope(terms: &[GrowthTerm]) -> GrowthTerm { - let mut result = GrowthTerm::one(); - for term in terms { - for (variable, growth) in &term.variables { - result - .variables - .entry(variable.clone()) - .or_insert_with(VariableGrowth::empty) - .upper_envelope(growth); - } - } - result - } } impl Growth { @@ -520,20 +456,11 @@ impl Growth { pub fn failures(&self) -> Option<&[GrowthFailure]> { match &self.0 { - GrowthState::Known { .. } => None, + GrowthState::Known(_) => None, GrowthState::Unknown(failures) => Some(failures), } } - /// Precision of a represented growth class, or `None` when the expression - /// is outside the represented domain. - pub fn precision(&self) -> Option { - match self.0 { - GrowthState::Known { precision, .. } => Some(precision), - GrowthState::Unknown(_) => None, - } - } - /// Compute the growth class of an expression in a single bottom-up pass. pub fn from_expr(expr: &Expr) -> Growth { let analysis = AlgebraicAnalysis::new(&[expr]); @@ -544,23 +471,17 @@ impl Growth { growth_from_analysis(expr, analysis, &mut HashMap::new()) } - /// Compare the represented bounds, ignoring how tightly they approximate - /// their source expressions. + /// Partial order on represented Big-O normal forms. /// - /// Per the growth-rate reading, unknown growth is the top element (it - /// may be arbitrarily large, e.g. a factorial), so it dominates everything - /// and nothing known dominates it. For two term antichains, `self` + /// Unknown values are incomparable. For two known term antichains, `self` /// dominates `other` iff every term of `other` is dominated-or-equal by /// some term of `self` — the standard antichain (Pareto) comparison. - /// Callers deciding whether one source expression can eliminate another - /// must additionally account for [`GrowthPrecision`]. - pub(crate) fn bound_dominates(&self, other: &Growth) -> bool { + pub fn dominates(&self, other: &Growth) -> bool { match (&self.0, &other.0) { - (GrowthState::Unknown(_), _) => true, - (_, GrowthState::Unknown(_)) => false, - (GrowthState::Known { terms: a, .. }, GrowthState::Known { terms: b, .. }) => { + (GrowthState::Known(a), GrowthState::Known(b)) => { b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) } + _ => false, } } @@ -573,7 +494,7 @@ impl Growth { pub fn to_expr(&self) -> Option { match &self.0 { GrowthState::Unknown(_) => None, - GrowthState::Known { terms, .. } => { + GrowthState::Known(terms) => { if terms.is_empty() { return Some(Expr::integer(1)); } @@ -612,16 +533,7 @@ fn growth_from_analysis( let facts = analysis.facts(expression); if facts.is_constant { let growth = if facts.constant_domain == Some(true) { - let growth = constant_growth(); - if facts - .exact_rational - .as_ref() - .is_some_and(|value| value.is_negative()) - { - growth.into_upper_bound() - } else { - growth - } + constant_growth() } else { unknown(GrowthFailure::InvalidConstantDomain { expression: expression.to_string(), @@ -646,24 +558,11 @@ fn growth_from_analysis( .map(|value| growth_from_analysis(value, analysis, memo)) .reduce(add) .expect("normalized sum has at least two terms"), - ExprNode::Mul(values) => { - let growth = values - .iter() - .map(|value| growth_from_analysis(value, analysis, memo)) - .reduce(mul) - .expect("normalized product has at least two factors"); - if values.iter().any(|value| { - analysis - .facts(value) - .exact_rational - .as_ref() - .is_some_and(|constant| constant.is_negative()) - }) { - growth.into_upper_bound() - } else { - growth - } - } + ExprNode::Mul(values) => values + .iter() + .map(|value| growth_from_analysis(value, analysis, memo)) + .reduce(mul) + .expect("normalized product has at least two factors"), ExprNode::Pow(base, exponent) => { let base_facts = analysis.facts(base); let exponent_facts = analysis.facts(exponent); @@ -848,27 +747,18 @@ fn prune(mut terms: Vec) -> Vec { } fn exact_growth(terms: Vec) -> Growth { - finish_growth(terms, GrowthPrecision::Tight) + finish_growth(terms) } -fn finish_growth(terms: Vec, mut precision: GrowthPrecision) -> Growth { +fn finish_growth(terms: Vec) -> Growth { let terms = prune(terms); - let terms = if terms.len() > ANTICHAIN_CAP { - precision = GrowthPrecision::UpperBound; - vec![GrowthTerm::upper_envelope(&terms)] - } else { - terms - }; - Growth(GrowthState::Known { terms, precision }) -} - -impl Growth { - fn into_upper_bound(mut self) -> Self { - if let GrowthState::Known { precision, .. } = &mut self.0 { - *precision = GrowthPrecision::UpperBound; - } - self + if terms.len() > ANTICHAIN_CAP { + return unknown(GrowthFailure::AntichainLimitExceeded { + limit: ANTICHAIN_CAP, + terms: terms.len(), + }); } + Growth(GrowthState::Known(terms)) } /// Antichain union (asymptotic `+ ≍ max`). @@ -876,10 +766,9 @@ fn add(a: Growth, b: Growth) -> Growth { if a.failures().is_some() || b.failures().is_some() { return merge_unknown(a, b); } - let precision = known_precision(&a).combine(known_precision(&b)); let mut terms = into_terms(a); terms.extend(into_terms(b)); - finish_growth(terms, precision) + finish_growth(terms) } /// Pairwise product of two antichains. @@ -887,7 +776,6 @@ fn mul(a: Growth, b: Growth) -> Growth { if a.failures().is_some() || b.failures().is_some() { return merge_unknown(a, b); } - let precision = known_precision(&a).combine(known_precision(&b)); let x = into_terms(a); let y = into_terms(b); let mut product = Vec::with_capacity(x.len() * y.len()); @@ -899,44 +787,27 @@ fn mul(a: Growth, b: Growth) -> Growth { } } } - finish_growth(product, precision) + finish_growth(product) } /// Raise a whole antichain to a nonnegative real power `k` (raise each term). fn pow_const(g: Growth, k: &BigRational) -> Growth { match g.0 { GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), - GrowthState::Known { - terms, - mut precision, - } => { - if terms.iter().any(|term| term.log_power_rounds_up(k)) { - precision = GrowthPrecision::UpperBound; - } - match terms.iter().map(|term| term.pow(k)).collect() { - Ok(terms) => finish_growth(terms, precision), - Err(failure) => unknown(failure), - } - } + GrowthState::Known(terms) => match terms.iter().map(|term| term.pow(k)).collect() { + Ok(terms) => finish_growth(terms), + Err(failure) => unknown(failure), + }, } } fn into_terms(growth: Growth) -> Vec { match growth.0 { - GrowthState::Known { terms, .. } => terms, + GrowthState::Known(terms) => terms, GrowthState::Unknown(_) => unreachable!("unknown growth is handled before term access"), } } -fn known_precision(growth: &Growth) -> GrowthPrecision { - match growth.0 { - GrowthState::Known { precision, .. } => precision, - GrowthState::Unknown(_) => { - unreachable!("unknown growth is handled before precision access") - } - } -} - /// Transfer function for a symbolic fixed-base exponential. fn exponential( base: ExpBase, @@ -979,21 +850,15 @@ fn exponential( fn log_growth(g: Growth) -> Growth { match g.0 { GrowthState::Unknown(failures) => Growth(GrowthState::Unknown(failures)), - GrowthState::Known { - terms, - mut precision, - } => { + GrowthState::Known(terms) => { let mut out = Vec::new(); for t in &terms { - if t.contains_log_factor() { - precision = GrowthPrecision::UpperBound; - } out.extend(log_term(t)); } if out.is_empty() { out.push(GrowthTerm::one()); // log(O(1)) = O(1) } - finish_growth(out, precision) + finish_growth(out) } } } diff --git a/src/size.rs b/src/size.rs index 8a0426411..d3abbaaf1 100644 --- a/src/size.rs +++ b/src/size.rs @@ -1,7 +1,7 @@ //! Symbolic size transformations carried by reduction rules. use crate::expr::{AlgebraicAnalysis, Expr, ExprNode, ExprNodeId, Symbol}; -use crate::growth::{Growth, GrowthPrecision}; +use crate::growth::Growth; use crate::types::ProblemSize; use num_bigint::{BigInt, BigUint, Sign}; use num_rational::BigRational; @@ -90,18 +90,13 @@ pub struct EvaluatedSize { values: SizeValues, } -/// Asymptotic projection of a size transform with its promise preserved. +/// Big-O projection of the target size fields used by symbolic cost models. #[derive(Clone, Debug, PartialEq)] pub struct SizeGrowth { - relation: SizeRelation, fields: Vec<(Box, Growth)>, } impl SizeGrowth { - pub fn relation(&self) -> SizeRelation { - self.relation - } - pub fn fields(&self) -> impl Iterator { self.fields .iter() @@ -130,32 +125,22 @@ pub fn problem_size_dominates(left: &ProblemSize, right: &ProblemSize) -> bool { .any(|(name, value)| right.get(name).is_some_and(|other| *value < other)) } -/// Return whether the available bounds prove that `left` has no faster growth -/// in every symbolic size field and strictly slower growth in at least one. -/// -/// The right-hand path must be exact with tight growth projections because it -/// is the path being removed. The left-hand path may itself be an upper bound: -/// proving that upper bound smaller than the right-hand tight class is enough. +/// Return whether `left` has no faster Big-O growth in every symbolic size +/// field and strictly slower growth in at least one field. pub fn size_growth_dominates(left: &SizeGrowth, right: &SizeGrowth) -> bool { if left.fields.len() != right.fields.len() { return false; } - if right.relation != SizeRelation::Exact { - return false; - } let mut strictly_smaller = false; for (name, growth) in left.fields() { let Some(other) = right.get(name) else { return false; }; - if growth.failures().is_some() || other.precision() != Some(GrowthPrecision::Tight) { - return false; - } - let left_at_most = other.bound_dominates(growth); + let left_at_most = other.dominates(growth); if !left_at_most { return false; } - strictly_smaller |= !growth.bound_dominates(other); + strictly_smaller |= !growth.dominates(other); } strictly_smaller } @@ -393,10 +378,7 @@ impl SizeTransform { ) }) .collect(); - SizeGrowth { - relation: self.relation, - fields, - } + SizeGrowth { fields } } } diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 37a889af4..395db2223 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -1,8 +1,8 @@ //! Unit tests for the symbolic growth domain (`src/growth.rs`). use super::{ - add, exact_growth, mul, ExpBase, Growth, GrowthFailure, GrowthPrecision, GrowthState, - GrowthTerm, VariableGrowth, ANTICHAIN_CAP, + add, exact_growth, mul, ExpBase, Growth, GrowthFailure, GrowthState, GrowthTerm, + VariableGrowth, ANTICHAIN_CAP, }; use crate::expr::{ evaluate_approximate, expression_from_approximation, AlgebraicAnalysis, Expr, ExprNode, @@ -45,7 +45,7 @@ fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> Grow fn terms_of(g: &Growth) -> &[GrowthTerm] { match &g.0 { - GrowthState::Known { terms, .. } => terms, + GrowthState::Known(terms) => terms, GrowthState::Unknown(failures) => panic!("expected known growth, got {failures:?}"), } } @@ -87,7 +87,7 @@ fn test_growth_relations_against_sympy_limits() { for case in fixture.growth_cases { let left = g(&case.left); let right = g(&case.right); - let actual = (left.bound_dominates(&right), right.bound_dominates(&left)); + let actual = (left.dominates(&right), right.dominates(&left)); let expected = match case.relation { SympyGrowthRelation::Equivalent => (true, true), SympyGrowthRelation::LeftDominates => (true, false), @@ -142,8 +142,8 @@ fn test_growth_no_expansion_regression() { fn test_growth_exponential_dominates_polynomial() { let exp = g("1.001^n"); let poly = g("n^100"); - assert!(exp.bound_dominates(&poly)); - assert!(!poly.bound_dominates(&exp)); + assert!(exp.dominates(&poly)); + assert!(!poly.dominates(&exp)); } /// 3. Incomparability is honest: neither `n^2` nor `n*m` dominates the other, @@ -152,8 +152,8 @@ fn test_growth_exponential_dominates_polynomial() { fn test_growth_incomparable_terms_both_kept() { let n2 = g("n^2"); let nm = g("n*m"); - assert!(!n2.bound_dominates(&nm)); - assert!(!nm.bound_dominates(&n2)); + assert!(!n2.dominates(&nm)); + assert!(!nm.dominates(&n2)); let sum = g("n^2 + n*m"); assert_eq!(terms_of(&sum).len(), 2); @@ -165,32 +165,32 @@ fn test_growth_incomparable_terms_both_kept() { fn test_growth_exponent_rates_exact() { let two_2n = g("2^(2*n)"); let two_n = g("2^n"); - assert!(two_2n.bound_dominates(&two_n)); - assert!(!two_n.bound_dominates(&two_2n)); + assert!(two_2n.dominates(&two_n)); + assert!(!two_n.dominates(&two_2n)); let three_n = g("3^n"); - assert!(three_n.bound_dominates(&two_n)); - assert!(!two_n.bound_dominates(&three_n)); + assert!(three_n.dominates(&two_n)); + assert!(!two_n.dominates(&three_n)); let exp_2n = g("exp(2*n)"); let exp_n = g("exp(n)"); - assert!(exp_2n.bound_dominates(&exp_n)); - assert!(!exp_n.bound_dominates(&exp_2n)); + assert!(exp_2n.dominates(&exp_n)); + assert!(!exp_n.dominates(&exp_2n)); - assert!(g("0.5^(-2*n)").bound_dominates(&g("0.5^(-n)"))); - assert!(g("0.25^(-n)").bound_dominates(&g("0.5^(-n)"))); + assert!(g("0.5^(-2*n)").dominates(&g("0.5^(-n)"))); + assert!(g("0.25^(-n)").dominates(&g("0.5^(-n)"))); } #[test] fn test_growth_exact_coefficients_do_not_cross_boundaries() { let polynomial = g("n^1000"); - assert!(g("2^(n/9007199254740992)").bound_dominates(&polynomial)); - assert!(g("(9007199254740993/9007199254740992)^n").bound_dominates(&polynomial)); + assert!(g("2^(n/9007199254740992)").dominates(&polynomial)); + assert!(g("(9007199254740993/9007199254740992)^n").dominates(&polynomial)); let unit_rate = g("2^n"); let larger_rate = g("2^(9007199254740993*n/9007199254740992)"); - assert!(larger_rate.bound_dominates(&unit_rate)); - assert!(!unit_rate.bound_dominates(&larger_rate)); + assert!(larger_rate.dominates(&unit_rate)); + assert!(!unit_rate.dominates(&larger_rate)); } #[test] @@ -231,8 +231,8 @@ fn test_every_registered_complexity_uses_the_shared_analysis() { fn test_growth_unproved_multi_base_comparison_is_retained() { let left = g("2^(2*n) * 3^n"); let right = g("2^n * 4^n"); - assert!(!left.bound_dominates(&right)); - assert!(!right.bound_dominates(&left)); + assert!(!left.dominates(&right)); + assert!(!right.dominates(&left)); assert_eq!(terms_of(&g("2^(2*n) * 3^n + 2^n * 4^n")).len(), 2); } @@ -333,25 +333,8 @@ fn test_proven_equal_exponential_spelling_is_deterministic() { /// absolute-value idiom. #[test] fn test_growth_widening() { - let sum = g("n + m"); - for widened in [g("n - m"), g("sqrt((n - m)^2)")] { - assert_eq!(widened.to_expr(), sum.to_expr()); - assert_eq!(widened.precision(), Some(GrowthPrecision::UpperBound)); - } - assert_eq!(sum.precision(), Some(GrowthPrecision::Tight)); -} - -#[test] -fn approximation_operations_record_upper_bound_precision() { - assert_eq!( - g("log(log(n))").precision(), - Some(GrowthPrecision::UpperBound) - ); - assert_eq!( - g("sqrt(log(n))").precision(), - Some(GrowthPrecision::UpperBound) - ); - assert_eq!(g("log(n)").precision(), Some(GrowthPrecision::Tight)); + assert_eq!(g("n - m"), g("n + m")); + assert_eq!(g("sqrt((n - m)^2)"), g("n + m")); } /// 6. Determinism: the antichain is canonically sorted, so structurally @@ -520,7 +503,7 @@ fn test_growth_exponential_roundtrip_is_exact() { fn test_growth_exponential_variants() { // exp(n) is represented directly as e^n: exponential, dominates any polynomial. let en = g("exp(n)"); - assert!(en.bound_dominates(&g("n^5"))); + assert!(en.dominates(&g("n^5"))); assert!(matches!( g("2^(n - m)").failures(), Some([GrowthFailure::DecayingExponential { variable, .. }]) if variable == "m" @@ -538,7 +521,7 @@ fn test_growth_exponential_variants() { // A fractional base with a negative exponent grows and retains that exact // symbolic base instead of being translated through a common logarithm. assert_eq!(g("0.5^(-n)").to_big_o(), "O(0.5^(-1 * n))"); - assert!(g("0.5^(-n)").bound_dominates(&g("n^100"))); + assert!(g("0.5^(-n)").dominates(&g("n^100"))); } /// `log` lowers each level: log of an exponential is linear, log of a @@ -578,15 +561,15 @@ fn test_growth_log_levels() { fn test_growth_unknown_dominance() { let n2 = g("n^2"); let unknown = g("factorial(n)"); - assert!(unknown.bound_dominates(&n2)); - assert!(!n2.bound_dominates(&unknown)); - assert!(unknown.bound_dominates(&unknown)); + assert!(!unknown.dominates(&n2)); + assert!(!n2.dominates(&unknown)); + assert!(!unknown.dominates(&unknown)); } -/// Complete antichains are retained through the configured boundary, then -/// replaced by one deterministic upper envelope. +/// Complete antichains are retained through the configured boundary; larger +/// results fail explicitly instead of changing the represented Big-O class. #[test] -fn test_growth_antichain_cap_coarsens_at_overflow() { +fn test_growth_antichain_cap_reports_overflow() { let vars: Vec = (0..=ANTICHAIN_CAP) .map(|index| format!("v{index}")) .collect(); @@ -596,15 +579,14 @@ fn test_growth_antichain_cap_coarsens_at_overflow() { .collect(); let at_cap = exact_growth(terms[..ANTICHAIN_CAP].to_vec()); - assert_eq!(at_cap.precision(), Some(GrowthPrecision::Tight)); assert_eq!(terms_of(&at_cap).len(), ANTICHAIN_CAP); let overflow = exact_growth(terms.clone()); - assert_eq!(overflow.precision(), Some(GrowthPrecision::UpperBound)); - assert_eq!(terms_of(&overflow).len(), 1); - for original in terms { - assert!(overflow.bound_dominates(&exact_growth(vec![original]))); - } + assert!(matches!( + overflow.failures(), + Some([GrowthFailure::AntichainLimitExceeded { limit, terms }]) + if *limit == ANTICHAIN_CAP && *terms == ANTICHAIN_CAP + 1 + )); let reversed = exact_growth( vars.iter() @@ -615,10 +597,9 @@ fn test_growth_antichain_cap_coarsens_at_overflow() { assert_eq!(overflow, reversed); } -/// The exponential envelope remains an upper bound when symbolic comparisons -/// leave more than the configured number of terms incomparable. +/// Unproved exponential comparisons obey the same explicit resource limit. #[test] -fn test_growth_coarsens_large_unproved_exponential_antichain() { +fn test_growth_rejects_large_unproved_exponential_antichain() { let terms = (1..=ANTICHAIN_CAP + 1) .map(|i| { let mut term = GrowthTerm::one(); @@ -631,10 +612,11 @@ fn test_growth_coarsens_large_unproved_exponential_antichain() { .collect::>(); let growth = exact_growth(terms.clone()); - assert_eq!(growth.precision(), Some(GrowthPrecision::UpperBound)); - for original in terms { - assert!(growth.bound_dominates(&exact_growth(vec![original]))); - } + assert!(matches!( + growth.failures(), + Some([GrowthFailure::AntichainLimitExceeded { limit, terms }]) + if *limit == ANTICHAIN_CAP && *terms == ANTICHAIN_CAP + 1 + )); } // --- Randomized property tests --- @@ -1005,16 +987,7 @@ fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { match (&a.0, &b.0) { (GrowthState::Unknown(_), GrowthState::Unknown(_)) => true, - ( - GrowthState::Known { - terms: ta, - precision: _, - }, - GrowthState::Known { - terms: tb, - precision: _, - }, - ) => { + (GrowthState::Known(ta), GrowthState::Known(tb)) => { ta.len() == tb.len() && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) && tb.iter().all(|u| ta.iter().any(|t| term_approx_eq(t, u))) @@ -1069,8 +1042,8 @@ fn test_growth_property_dominance_sound() { let higher_expression = lower_expression.clone() * ratio_expression.clone(); let lower = Growth::from_expr(&lower_expression); let higher = Growth::from_expr(&higher_expression); - assert!(higher.bound_dominates(&lower)); - assert!(!lower.bound_dominates(&higher)); + assert!(higher.dominates(&lower)); + assert!(!lower.dominates(&higher)); let r1 = evaluate_approximate(&ratio_expression, &joint_size(16)).unwrap(); let r2 = evaluate_approximate(&ratio_expression, &joint_size(64)).unwrap(); diff --git a/src/unit_tests/size.rs b/src/unit_tests/size.rs index 249457a28..5f4c8c79e 100644 --- a/src/unit_tests/size.rs +++ b/src/unit_tests/size.rs @@ -3,7 +3,6 @@ use super::{ SizeTransformError, SizeValues, }; use crate::expr::Expr; -use crate::growth::GrowthPrecision; use crate::types::ProblemSize; use num_bigint::BigUint; use num_traits::One; @@ -19,7 +18,7 @@ fn pareto_order_minimizes_every_concrete_size_field() { } #[test] -fn two_upper_bounds_cannot_eliminate_either_symbolic_path() { +fn symbolic_pareto_compares_big_o_regardless_of_size_relation() { let linear = SizeTransform::new( "linear", SizeRelation::UpperBound, @@ -38,12 +37,12 @@ fn two_upper_bounds_cannot_eliminate_either_symbolic_path() { .unwrap() .project_growth(); - assert!(!size_growth_dominates(&linear, &quadratic)); + assert!(size_growth_dominates(&linear, &quadratic)); assert!(!size_growth_dominates(&quadratic, &linear)); } #[test] -fn an_upper_bound_can_eliminate_a_proven_tight_slower_path() { +fn rule_relation_does_not_change_symbolic_big_o_order() { let linear_bound = SizeTransform::new( "linear bound", SizeRelation::UpperBound, @@ -64,15 +63,15 @@ fn an_upper_bound_can_eliminate_a_proven_tight_slower_path() { } #[test] -fn antichain_collapse_cannot_eliminate_a_symbolic_path() { +fn antichain_overflow_cannot_eliminate_a_symbolic_path() { let wide_expression = Expr::parse( &(0..33) .map(|index| format!("v{index}")) .collect::>() .join(" + "), ); - let coarsened = SizeTransform::new( - "coarsened", + let overflow = SizeTransform::new( + "overflow", SizeRelation::Exact, [("vertices", wide_expression)], ) @@ -86,12 +85,12 @@ fn antichain_collapse_cannot_eliminate_a_symbolic_path() { .unwrap() .project_growth(); - assert_eq!( - coarsened.get("vertices").unwrap().precision(), - Some(GrowthPrecision::UpperBound) - ); - assert!(!size_growth_dominates(&coarsened, &exact)); - assert!(!size_growth_dominates(&exact, &coarsened)); + assert!(matches!( + overflow.get("vertices").unwrap().failures(), + Some([crate::growth::GrowthFailure::AntichainLimitExceeded { .. }]) + )); + assert!(!size_growth_dominates(&overflow, &exact)); + assert!(!size_growth_dominates(&exact, &overflow)); } #[test] @@ -182,14 +181,13 @@ fn evaluation_stays_exact_beyond_machine_integer_range() { } #[test] -fn growth_projection_keeps_the_rule_relation() { - let transform = SizeTransform::new( - "A -> B", - SizeRelation::UpperBound, - [("m", Expr::parse("3*n^2"))], - ) - .unwrap(); - let growth = transform.project_growth(); - assert_eq!(growth.relation(), SizeRelation::UpperBound); - assert_eq!(growth.get("m").unwrap().to_big_o(), "O(n^2)"); +fn growth_projection_discards_the_rule_relation() { + for relation in [SizeRelation::Exact, SizeRelation::UpperBound] { + let transform = + SizeTransform::new("A -> B", relation, [("m", Expr::parse("3*n^2"))]).unwrap(); + assert_eq!( + transform.project_growth().get("m").unwrap().to_big_o(), + "O(n^2)" + ); + } } From e36d1890625383d7fc8ce8be585ab63d35b0b28e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 17 Aug 2026 14:44:24 +0800 Subject: [PATCH 10/15] Propagate polynomial size bounds through subtraction --- docs/src/design.md | 24 +++-- src/expr.rs | 2 +- src/size.rs | 227 +++++++++++++++++++++++++++-------------- src/unit_tests/size.rs | 63 +++++++++++- 4 files changed, 226 insertions(+), 90 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index 6b2ad2f7e..f03de8500 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -366,9 +366,8 @@ All path-finding operates on **exact variant nodes**. Use `ReductionGraph::varia | `find_all_paths(src, src_var, dst, dst_var)` | All simple paths | Enumerate every route | | `compose_path_size_transform(path)` | Symbolic composition | Compose each rule's exact or upper-bound size relation while preserving its promise | -Symbolic path discovery does not rank or prune routes. A rule has one relation for all of -its formulas: either an exact equality or an upper bound. Composition performs only -substitution and relation propagation: exact composed with exact stays exact; every other +A rule has one relation for all of its formulas: either an exact equality or an upper +bound. Composition keeps exact formulas exact only when every step is exact; every other combination is an upper bound. Concrete-instance measurement remains a separate execution API. @@ -425,10 +424,9 @@ impl ReduceTo for Source { ... } ``` `SizeTransform` uses exact rational and arbitrary-precision integer arithmetic. Exact -relations must evaluate to non-negative integers. Upper-bound relations accept only -non-negative monotone formulas and round rational results upward. Missing fields, negative -or non-integral exact results, division by zero, and explicit conversion outside `usize` -are errors. +relations must evaluate to non-negative integers, while upper-bound results round rational +values upward. Missing fields, negative or non-integral exact results, division by zero, +and explicit conversion outside `usize` are errors. Transforms can be evaluated with an explicit source size: @@ -437,10 +435,14 @@ Input: ProblemSize { num_vertices: 10, num_edges: 15 } Output: ProblemSize { num_vars: 25 } ``` -For multi-step paths, `compose_path_size_transform` substitutes each step into the next -without expanding the shared expression DAG. An upper bound cannot pass through a -non-monotone downstream formula. Projection to `Growth` is an explicit terminal operation, -and its exact/upper-bound relation is preserved in the result. +For multi-step paths, `compose_path_size_transform` substitutes each step into the next. +When only upper bounds are known for the intermediate fields, a downstream polynomial is +first fully expanded and like monomials are combined; terms with non-positive coefficients +are then removed before substitution. For example, `m <= n^2` followed by `k = 10 - m` +produces the sound bound `k <= 10`, while +`e' = v(v - 1)/2 - e` produces `e' <= v^2/2`. A non-polynomial downstream formula cannot +propagate symbolic upper bounds and reports an error. Projection to `Growth` is a separate +terminal operation used for Big-O path comparison. diff --git a/src/expr.rs b/src/expr.rs index c5b5ef2ad..c01f412c5 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -15,7 +15,7 @@ use std::fmt; use crate::types::ProblemSize; /// Algebraic facts computed once from the shared expression DAG and consumed by -/// exact-size, monotone-bound, and asymptotic-growth projections. +/// exact-size evaluation and asymptotic-growth projection. #[derive(Clone, Debug)] pub(crate) struct AlgebraicAnalysis { facts: HashMap, diff --git a/src/size.rs b/src/size.rs index d3abbaaf1..5cdb971db 100644 --- a/src/size.rs +++ b/src/size.rs @@ -6,7 +6,7 @@ use crate::types::ProblemSize; use num_bigint::{BigInt, BigUint, Sign}; use num_rational::BigRational; use num_traits::{One, Signed, Zero}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; /// What one reduction rule promises about all of its declared size formulas. @@ -180,7 +180,6 @@ struct SizeField { name: Box, expression: Expr, plan: Plan, - monotone: bool, } #[derive(Clone, Debug)] @@ -241,19 +240,10 @@ impl SizeTransform { let plan = compile(&expression, &analysis, &mut plans).map_err(|failure| { validation_error(edge.clone(), name.clone(), expression.to_string(), failure) })?; - let monotone = is_nonnegative_monotone(&expression, &analysis); - if relation == SizeRelation::UpperBound && !monotone { - return Err(SizeTransformError::NonMonotoneUpperBound { - edge: edge.clone(), - field: name, - expression: expression.to_string().into(), - }); - } Ok(SizeField { name, expression, plan, - monotone, }) }) .collect::, _>>()?; @@ -288,24 +278,44 @@ impl SizeTransform { } pub fn evaluate(&self, input: &EvaluatedSize) -> Result { - if input.relation == SizeRelation::UpperBound { - if let Some(field) = self.fields.iter().find(|field| !field.monotone) { - return Err(SizeTransformError::CannotPropagateUpperBound { - edge: self.edge.clone(), - field: field.name.clone(), - expression: field.expression.to_string().into(), - }); - } - } - let relation = input.relation.compose(self.relation); + let upper_plans = if input.relation == SizeRelation::UpperBound { + Some( + self.fields + .iter() + .map(|field| { + let expression = + positive_polynomial_hull(&field.expression).ok_or_else(|| { + SizeTransformError::CannotPropagateUpperBound { + edge: self.edge.clone(), + field: field.name.clone(), + expression: field.expression.to_string().into(), + } + })?; + let analysis = AlgebraicAnalysis::new(&[&expression]); + compile(&expression, &analysis, &mut HashMap::new()).map_err(|failure| { + validation_error( + self.edge.clone(), + field.name.clone(), + expression.to_string(), + failure, + ) + }) + }) + .collect::, _>>()?, + ) + } else { + None + }; let mut memo = HashMap::new(); let mut output = Vec::with_capacity(self.fields.len()); - for field in &self.fields { - let value = - evaluate_plan(&field.plan, &input.values, &mut memo).map_err(|failure| { - evaluation_error(self.edge.clone(), field.name.clone(), failure) - })?; + for (index, field) in self.fields.iter().enumerate() { + let plan = upper_plans + .as_ref() + .map_or(&field.plan, |plans| &plans[index]); + let value = evaluate_plan(plan, &input.values, &mut memo).map_err(|failure| { + evaluation_error(self.edge.clone(), field.name.clone(), failure) + })?; if value.is_negative() { return Err(SizeTransformError::NegativeResult { edge: self.edge.clone(), @@ -338,29 +348,31 @@ impl SizeTransform { next: &SizeTransform, edge: impl Into>, ) -> Result { - if self.relation == SizeRelation::UpperBound { - if let Some(field) = next.fields.iter().find(|field| !field.monotone) { - return Err(SizeTransformError::CannotPropagateUpperBound { - edge: next.edge.clone(), - field: field.name.clone(), - expression: field.expression.to_string().into(), - }); - } - } let edge = edge.into(); let replacements: HashMap<&str, &Expr> = self.expressions().collect(); let fields = next .fields .iter() .map(|field| { - let expression = field - .expression - .substitute_complete(&replacements) - .map_err(|error| SizeTransformError::MissingCompositionInput { - edge: edge.clone(), - field: field.name.clone(), - input_fields: error.missing_variables().map(Box::::from).collect(), - })?; + let expression = if self.relation == SizeRelation::UpperBound { + positive_polynomial_hull(&field.expression).ok_or_else(|| { + SizeTransformError::CannotPropagateUpperBound { + edge: next.edge.clone(), + field: field.name.clone(), + expression: field.expression.to_string().into(), + } + })? + } else { + field.expression.clone() + }; + let expression = + expression + .substitute_complete(&replacements) + .map_err(|error| SizeTransformError::MissingCompositionInput { + edge: edge.clone(), + field: field.name.clone(), + input_fields: error.missing_variables().map(Box::::from).collect(), + })?; Ok((field.name.clone(), expression)) }) .collect::, SizeTransformError>>()?; @@ -382,6 +394,102 @@ impl SizeTransform { } } +type Monomial = BTreeMap; +type Polynomial = BTreeMap; + +fn positive_polynomial_hull(expression: &Expr) -> Option { + let polynomial = polynomial(expression)?; + let terms = polynomial + .into_iter() + .filter(|(_, coefficient)| coefficient.is_positive()) + .map(|(monomial, coefficient)| { + monomial + .into_iter() + .fold(Expr::constant(coefficient), |term, (variable, exponent)| { + term * Expr::pow( + Expr::variable(variable.as_str()), + Expr::integer(BigInt::from(exponent)), + ) + }) + }); + Some(terms.fold(Expr::integer(0), |sum, term| sum + term)) +} + +fn polynomial(expression: &Expr) -> Option { + match expression.node() { + ExprNode::Const(value) => Some(BTreeMap::from([(BTreeMap::new(), value.clone())])), + ExprNode::Var(variable) => Some(BTreeMap::from([( + BTreeMap::from([(variable.clone(), BigUint::one())]), + BigRational::one(), + )])), + ExprNode::Add(values) => values.iter().try_fold(BTreeMap::new(), |sum, value| { + Some(add_polynomials(sum, polynomial(value)?)) + }), + ExprNode::Mul(values) => values.iter().try_fold( + BTreeMap::from([(BTreeMap::new(), BigRational::one())]), + |product, value| Some(multiply_polynomials(product, polynomial(value)?)), + ), + ExprNode::Pow(base, exponent) => { + let ExprNode::Const(exponent) = exponent.node() else { + return None; + }; + if !exponent.is_integer() { + return None; + } + if exponent.is_negative() { + let ExprNode::Const(base) = base.node() else { + return None; + }; + if base.is_zero() { + return None; + } + return Some(BTreeMap::from([( + BTreeMap::new(), + pow_rational(base.clone(), &exponent.to_integer()), + )])); + } + let mut exponent = exponent.to_integer().magnitude().clone(); + let mut base = polynomial(base)?; + let mut result = BTreeMap::from([(BTreeMap::new(), BigRational::one())]); + while !exponent.is_zero() { + if exponent.bit(0) { + result = multiply_polynomials(result, base.clone()); + } + exponent >>= 1usize; + if !exponent.is_zero() { + base = multiply_polynomials(base.clone(), base); + } + } + Some(result) + } + ExprNode::Exp(_) | ExprNode::Log(_) | ExprNode::Factorial(_) => None, + } +} + +fn add_polynomials(mut left: Polynomial, right: Polynomial) -> Polynomial { + for (monomial, right_coefficient) in right { + *left.entry(monomial).or_insert_with(BigRational::zero) += right_coefficient; + } + left.retain(|_, coefficient| !coefficient.is_zero()); + left +} + +fn multiply_polynomials(left: Polynomial, right: Polynomial) -> Polynomial { + let mut product = Polynomial::new(); + for (left_monomial, left_coefficient) in left { + for (right_monomial, right_coefficient) in &right { + let mut monomial = left_monomial.clone(); + for (variable, exponent) in right_monomial { + *monomial.entry(variable.clone()).or_default() += exponent; + } + *product.entry(monomial).or_insert_with(BigRational::zero) += + &left_coefficient * right_coefficient; + } + } + product.retain(|_, coefficient| !coefficient.is_zero()); + product +} + fn compile( expression: &Expr, analysis: &AlgebraicAnalysis, @@ -431,33 +539,6 @@ fn compile( Ok(plan) } -fn is_nonnegative_monotone(expression: &Expr, analysis: &AlgebraicAnalysis) -> bool { - if analysis - .facts(expression) - .exact_rational - .as_ref() - .is_some_and(|value| !value.is_negative()) - { - return true; - } - match expression.node() { - ExprNode::Const(value) => !value.is_negative(), - ExprNode::Var(_) => true, - ExprNode::Add(values) | ExprNode::Mul(values) => values - .iter() - .all(|value| is_nonnegative_monotone(value, analysis)), - ExprNode::Pow(base, exponent) => { - analysis - .facts(exponent) - .exact_rational - .as_ref() - .is_some_and(|exponent| exponent.is_integer() && !exponent.is_negative()) - && is_nonnegative_monotone(base, analysis) - } - ExprNode::Exp(_) | ExprNode::Log(_) | ExprNode::Factorial(_) => false, - } -} - fn evaluate_plan( plan: &Plan, input: &SizeValues, @@ -600,12 +681,6 @@ pub enum SizeTransformError { expression: Box, operator: &'static str, }, - #[error("reduction `{edge}` target field `{field}` has a non-monotone upper-bound formula `{expression}`")] - NonMonotoneUpperBound { - edge: Box, - field: Box, - expression: Box, - }, #[error("reduction `{edge}` target field `{field}` cannot propagate an upper bound through `{expression}`")] CannotPropagateUpperBound { edge: Box, diff --git a/src/unit_tests/size.rs b/src/unit_tests/size.rs index 5f4c8c79e..fa0994aef 100644 --- a/src/unit_tests/size.rs +++ b/src/unit_tests/size.rs @@ -132,7 +132,7 @@ fn upper_bound_relation_survives_evaluation_and_composition() { } #[test] -fn upper_bound_cannot_cross_a_non_monotone_exact_transform() { +fn upper_bound_crosses_subtraction_via_positive_polynomial_hull() { let first = SizeTransform::new( "A -> B", SizeRelation::UpperBound, @@ -145,8 +145,67 @@ fn upper_bound_cannot_cross_a_non_monotone_exact_transform() { [("k", Expr::parse("10 - m"))], ) .unwrap(); + let exact_result = second + .evaluate(&EvaluatedSize::exact(SizeValues::new([("m", 4u8)]))) + .unwrap(); + assert_eq!(exact_result.relation(), SizeRelation::Exact); + assert_eq!(exact_result.values().get("k"), Some(&BigUint::from(6u8))); + + let composed = first.compose(&second, "A -> C").unwrap(); + assert_eq!(composed.get("k").unwrap().to_string(), "10"); + + let intermediate = first + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", 4u8)]))) + .unwrap(); + let result = second.evaluate(&intermediate).unwrap(); + assert_eq!(result.relation(), SizeRelation::UpperBound); + assert_eq!(result.values().get("k"), Some(&BigUint::from(10u8))); +} + +#[test] +fn polynomial_hull_expands_and_combines_terms_before_dropping_negative_coefficients() { + let first = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [ + ("vertices", Expr::parse("q")), + ("edges", Expr::parse("q^2")), + ], + ) + .unwrap(); + let complement = SizeTransform::new( + "B -> C", + SizeRelation::Exact, + [( + "edges", + Expr::parse("vertices * (vertices - 1) / 2 - edges"), + )], + ) + .unwrap(); + + let composed = first.compose(&complement, "A -> C").unwrap(); + assert_eq!(composed.get("edges").unwrap().to_string(), "0.5 * q^2"); + let result = composed + .evaluate(&EvaluatedSize::exact(SizeValues::new([("q", 5u8)]))) + .unwrap(); + assert_eq!(result.values().get("edges"), Some(&BigUint::from(13u8))); +} + +#[test] +fn upper_bound_propagation_rejects_non_polynomial_formulas() { + let reciprocal = + SizeTransform::new("B -> C", SizeRelation::Exact, [("k", Expr::parse("1 / m"))]).unwrap(); + let bounded_input = SizeTransform::new( + "A -> B", + SizeRelation::UpperBound, + [("m", Expr::parse("n"))], + ) + .unwrap() + .evaluate(&EvaluatedSize::exact(SizeValues::new([("n", 4u8)]))) + .unwrap(); + assert!(matches!( - first.compose(&second, "A -> C"), + reciprocal.evaluate(&bounded_input), Err(SizeTransformError::CannotPropagateUpperBound { .. }) )); } From 183b87653fbfca2d5f68eee85470a1740ad102ab Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 17 Aug 2026 15:20:54 +0800 Subject: [PATCH 11/15] Limit path output requests to 999 --- docs/src/cli.md | 2 +- docs/src/mcp.md | 2 +- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/graph.rs | 11 ++++++----- problemreductions-cli/src/mcp/tests.rs | 10 +++++++++- problemreductions-cli/src/mcp/tools.rs | 4 ++-- problemreductions-cli/tests/cli_tests.rs | 22 +++++++++++++++++++-- 7 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/src/cli.md b/docs/src/cli.md index 1d6eb36ac..d0799798a 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -165,7 +165,7 @@ problem JSON file, every candidate path is executed on the complete source insta and the actual size of each constructed intermediate is reported. By default, `--selection pareto` returns the paths whose target-size vectors are Pareto nondominated among the candidates. Use `--selection all` to return every candidate. -`--max-paths` (default: 20) caps the selected output after comparison. Pareto +`--max-paths` (default: 20, maximum: 999) caps the selected output after comparison. Pareto selection considers every simple candidate path and never prunes graph search using size estimates. Extract one route from the path-set envelope before passing it to `pred reduce --via`. diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 04ed10060..57f8457fd 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -79,7 +79,7 @@ The MCP server provides 10 tools organized into two categories: **graph query to | `list_problems` | *(none)* | List all registered problem types with aliases, variant counts, and reduction counts | | `show_problem` | `problem` (string) | Show details for a problem type: variants, size fields, schema, and incoming/outgoing reductions | | `neighbors` | `problem` (string), `hops` (int, default: 1), `direction` ("out"\|"in"\|"both", default: "out") | Find neighboring problems reachable via reduction edges within a given hop distance | -| `find_path` | `source` (string), `target` (string), `max_paths` (int, default: 20), `selection` (`pareto` default or `all`), `problem_json` (optional string) | Find reduction paths and explain how size changes. Selection runs before the output limit; with a complete source instance, execute candidate paths and report actual constructed sizes. | +| `find_path` | `source` (string), `target` (string), `max_paths` (int, default: 20, maximum: 999), `selection` (`pareto` default or `all`), `problem_json` (optional string) | Find reduction paths and explain how size changes. Selection runs before the output limit; with a complete source instance, execute candidate paths and report actual constructed sizes. | | `export_graph` | *(none)* | Export the full reduction graph as JSON | ### Instance Tools diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 97a89316a..ac7826eb0 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -176,7 +176,7 @@ Use `pred list` to see available problems.")] /// Target problem (e.g., QUBO) #[arg(value_parser = crate::problem_name::ProblemNameParser)] target: String, - /// Maximum selected paths to output + /// Maximum selected paths to output (at most 999) #[arg(long, default_value_t = 20)] max_paths: usize, /// Which enumerated paths to return diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 8e1f6ef63..068d4c277 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -1007,7 +1007,7 @@ fn path_symbolic( dst_variant, max_paths, selection, - ); + )?; if batch.paths.is_empty() && !batch.truncated { let variant_hint = variant_hint_for(graph, dst_name); @@ -1084,7 +1084,8 @@ pub(crate) fn find_path_batch( dst_variant: &BTreeMap, max_paths: usize, selection: PathSelection, -) -> PathBatch { +) -> Result { + anyhow::ensure!(max_paths <= 999, "max_paths must not exceed 999"); let paths = match selection { PathSelection::Pareto => { let mut paths = graph.find_all_paths(src_name, src_variant, dst_name, dst_variant); @@ -1102,11 +1103,11 @@ pub(crate) fn find_path_batch( graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, max_paths + 1) } }; - PathBatch { + Ok(PathBatch { paths, truncated: false, max_paths, - } + }) } pub(crate) fn cap_path_batch(batch: &mut PathBatch) { @@ -1308,7 +1309,7 @@ fn path_concrete( dst_variant, max_paths, selection, - ); + )?; if batch.paths.is_empty() && !batch.truncated { anyhow::bail!("No reduction path from {src_name} to {dst_name}"); } diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 935a3099d..8f117cb5e 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -4,7 +4,7 @@ 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, PathSelection::All, None) + .find_path_inner(source, target, 999, PathSelection::All, None) .expect("path enumeration"); let json: serde_json::Value = serde_json::from_str(&response).unwrap(); let entry = json["paths"] @@ -118,6 +118,14 @@ fn test_find_path_is_capped_explicitly() { assert_eq!(json["truncated"], true); } +#[test] +fn test_find_path_rejects_max_paths_above_output_limit() { + let error = McpServer::new() + .find_path_inner("MIS", "QUBO", 1000, PathSelection::All, None) + .unwrap_err(); + assert_eq!(error.to_string(), "max_paths must not exceed 999"); +} + #[test] fn test_neighbors_and_export_graph() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 8f845ff86..4cb4652bb 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -37,7 +37,7 @@ pub struct FindPathParams { pub source: String, #[schemars(description = "Target problem name or alias")] pub target: String, - #[schemars(description = "Maximum selected paths to output (default: 20)")] + #[schemars(description = "Maximum selected paths to output (default: 20, maximum: 999)")] pub max_paths: Option, #[schemars(description = "Path selection: pareto (default) or all")] pub selection: Option, @@ -262,7 +262,7 @@ impl McpServer { &dst_ref.variant, max_paths, selection, - ); + )?; if batch.paths.is_empty() && !batch.truncated { anyhow::bail!( "No reduction path from {} to {}", diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index fd3e9d95b..b5b5ee33c 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -6,7 +6,7 @@ fn pred() -> Command { fn write_named_route(source: &str, target: &str, names: &[&str], output: &std::path::Path) { let command = pred() - .args(["path", source, target, "--max-paths", "2000", "--json"]) + .args(["path", source, target, "--max-paths", "999", "--json"]) .output() .unwrap(); assert!( @@ -59,7 +59,7 @@ fn reduce_named_to_file( fn write_direct_route(source: &str, target: &str, output: &std::path::Path) { let command = pred() - .args(["path", source, target, "--max-paths", "2000", "--json"]) + .args(["path", source, target, "--max-paths", "999", "--json"]) .output() .unwrap(); assert!(command.status.success()); @@ -510,6 +510,24 @@ fn test_path_max_paths_caps_selected_output() { assert!(json.get("max_paths").is_none()); } +#[test] +fn test_path_rejects_max_paths_above_output_limit() { + let output = pred() + .args([ + "path", + "MIS", + "QUBO", + "--selection", + "all", + "--max-paths", + "1000", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("max_paths must not exceed 999")); +} + #[test] fn test_path_set_save() { let file = std::env::temp_dir().join("pred_test_paths.json"); From 7f2e743e00de9c01d5ad8b7d774210ac17a5f446 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 18 Aug 2026 16:33:34 +0800 Subject: [PATCH 12/15] Bound Pareto path search --- docs/src/cli.md | 19 +- problemreductions-cli/src/cli.rs | 21 ++- problemreductions-cli/src/commands/graph.rs | 156 +++++++--------- problemreductions-cli/src/main.rs | 8 +- problemreductions-cli/src/mcp/tests.rs | 37 +++- problemreductions-cli/src/mcp/tools.rs | 57 +++--- problemreductions-cli/tests/cli_tests.rs | 178 ++++++++++++++----- src/rules/graph.rs | 187 ++++++++++++++++---- src/unit_tests/reduction_graph.rs | 46 +++++ 9 files changed, 471 insertions(+), 238 deletions(-) diff --git a/docs/src/cli.md b/docs/src/cli.md index d0799798a..87e544822 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -154,21 +154,22 @@ Inspect reduction paths or save the path set for later route selection: ```bash pred path MIS QUBO # paths (up to 20) -pred path MIS QUBO --max-paths 50 # increase the cap -pred path MIS QUBO --selection all # return every enumerated candidate +pred path MIS QUBO --limit 50 # inspect the first 50 paths +pred path MIS QUBO --unfiltered # skip Pareto filtering +pred path MIS QUBO --limit all # inspect up to 999 paths pred path MIS MaximumClique mis.json # execute paths on a complete instance pred path MIS QUBO -o paths.json # save the path set ``` Without an instance file, each route explains how problem size changes. With a problem JSON file, every candidate path is executed on the complete source instance -and the actual size of each constructed intermediate is reported. By default, -`--selection pareto` returns the paths whose target-size vectors are Pareto -nondominated among the candidates. Use `--selection all` to return every candidate. -`--max-paths` (default: 20, maximum: 999) caps the selected output after comparison. Pareto -selection considers every simple candidate path and never prunes graph search using -size estimates. Extract one route from the path-set envelope before passing it to -`pred reduce --via`. +and the actual size of each constructed intermediate is reported. By default, the +command enumerates the first 20 witness-capable paths and returns those whose +target-size vectors are Pareto nondominated within that set. `--limit` accepts +1 through 999; `all` is an alias for 999. Use `--unfiltered` to return the +enumerated paths without Pareto filtering. The JSON envelope remains +`{"paths": [...], "truncated": bool}`. Extract one route from the path-set +envelope before passing it to `pred reduce --via`. ### `pred export-graph` — Export the reduction graph diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index ac7826eb0..ae68684cf 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -164,8 +164,9 @@ Use `pred to ` for incoming neighbors (what reduces to this).")] Examples: pred path MIS QUBO # inspect reduction paths pred path MIS Clique mis.json # execute paths on an instance - pred path MIS QUBO --max-paths 50 # increase the output cap - pred path MIS QUBO --selection all # return every enumerated candidate + pred path MIS QUBO --limit 50 # inspect the first 50 paths + pred path MIS QUBO --unfiltered # skip Pareto filtering + pred path MIS QUBO --limit all # inspect up to 999 paths pred path MIS QUBO -o paths.json # save the path set Use `pred list` to see available problems.")] @@ -176,12 +177,16 @@ Use `pred list` to see available problems.")] /// Target problem (e.g., QUBO) #[arg(value_parser = crate::problem_name::ProblemNameParser)] target: String, - /// Maximum selected paths to output (at most 999) - #[arg(long, default_value_t = 20)] - max_paths: usize, - /// Which enumerated paths to return - #[arg(long, value_enum, default_value_t = crate::commands::graph::PathSelection::Pareto)] - selection: crate::commands::graph::PathSelection, + /// Number of paths to inspect (1-999, or "all" as an alias for 999) + #[arg( + long, + default_value = "20", + value_parser = crate::commands::graph::parse_path_limit + )] + limit: usize, + /// Return enumerated paths without Pareto filtering + #[arg(long)] + unfiltered: bool, /// Source problem instance JSON. When present, execute every returned path and measure each constructed problem. instance: Option, }, diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 068d4c277..373643c39 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -915,8 +915,8 @@ pub(crate) fn format_path_json( pub fn path( source: &str, target: &str, - max_paths: usize, - selection: PathSelection, + limit: usize, + unfiltered: bool, instance: Option<&Path>, out: &OutputConfig, ) -> Result<()> { @@ -969,8 +969,8 @@ pub fn path( &src_ref.variant, &dst_ref.name, &dst_ref.variant, - max_paths, - selection, + limit, + unfiltered, loaded.as_any(), out, ) @@ -981,8 +981,8 @@ pub fn path( &src_ref.variant, &dst_ref.name, &dst_ref.variant, - max_paths, - selection, + limit, + unfiltered, out, ) } @@ -995,19 +995,11 @@ fn path_symbolic( src_variant: &BTreeMap, dst_name: &str, dst_variant: &BTreeMap, - max_paths: usize, - selection: PathSelection, + limit: usize, + unfiltered: bool, out: &OutputConfig, ) -> Result<()> { - let mut batch = find_path_batch( - graph, - src_name, - src_variant, - dst_name, - dst_variant, - max_paths, - selection, - )?; + let mut batch = find_path_batch(graph, src_name, src_variant, dst_name, dst_variant, limit)?; if batch.paths.is_empty() && !batch.truncated { let variant_hint = variant_hint_for(graph, dst_name); @@ -1023,11 +1015,10 @@ fn path_symbolic( dst_name, ); } - if selection == PathSelection::Pareto { + if !unfiltered { let flags = symbolic_pareto_flags(graph, &batch.paths); batch.paths = retain_selected(batch.paths, &flags); } - cap_path_batch(&mut batch); let json_output = out.output.is_some() || out.json; let json = if json_output { @@ -1044,7 +1035,7 @@ fn path_symbolic( src_name, dst_name, batch.truncated, - batch.max_paths, + limit, ) }; out.emit_with_default_name("", &text, &json) @@ -1053,27 +1044,26 @@ fn path_symbolic( pub(crate) struct PathBatch { pub(crate) paths: Vec, pub(crate) truncated: bool, - pub(crate) max_paths: usize, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] -pub(crate) enum PathSelection { - Pareto, - All, -} +pub(crate) const MAX_PATHS: usize = 999; +pub(crate) const PATH_LIMIT_ERROR: &str = "limit must be an integer from 1 to 999 or 'all'"; -impl std::str::FromStr for PathSelection { - type Err = String; +pub(crate) fn validate_path_limit(limit: usize) -> std::result::Result { + (1..=MAX_PATHS) + .contains(&limit) + .then_some(limit) + .ok_or_else(|| PATH_LIMIT_ERROR.to_string()) +} - fn from_str(value: &str) -> Result { - match value { - "pareto" => Ok(Self::Pareto), - "all" => Ok(Self::All), - _ => Err(format!( - "unknown path selection '{value}'; expected 'pareto' or 'all'" - )), - } +pub(crate) fn parse_path_limit(value: &str) -> std::result::Result { + if value == "all" { + return Ok(MAX_PATHS); } + let limit = value + .parse::() + .map_err(|_| PATH_LIMIT_ERROR.to_string())?; + validate_path_limit(limit) } pub(crate) fn find_path_batch( @@ -1082,37 +1072,13 @@ pub(crate) fn find_path_batch( src_variant: &BTreeMap, dst_name: &str, dst_variant: &BTreeMap, - max_paths: usize, - selection: PathSelection, + limit: usize, ) -> Result { - anyhow::ensure!(max_paths <= 999, "max_paths must not exceed 999"); - let paths = match selection { - PathSelection::Pareto => { - let mut paths = graph.find_all_paths(src_name, src_variant, dst_name, dst_variant); - paths.sort_by(|left, right| { - left.steps.len().cmp(&right.steps.len()).then_with(|| { - left.steps - .iter() - .map(|step| (&step.name, &step.variant)) - .cmp(right.steps.iter().map(|step| (&step.name, &step.variant))) - }) - }); - paths - } - PathSelection::All => { - graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, max_paths + 1) - } - }; - Ok(PathBatch { - paths, - truncated: false, - max_paths, - }) -} - -pub(crate) fn cap_path_batch(batch: &mut PathBatch) { - batch.truncated = batch.paths.len() > batch.max_paths; - batch.paths.truncate(batch.max_paths); + validate_path_limit(limit).map_err(anyhow::Error::msg)?; + let mut paths = graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, limit + 1); + let truncated = paths.len() > limit; + paths.truncate(limit); + Ok(PathBatch { paths, truncated }) } pub(crate) fn path_batch_json( @@ -1176,17 +1142,20 @@ pub(crate) fn retain_selected(items: Vec, selected: &[bool]) -> Vec { } pub(crate) fn symbolic_pareto_flags(graph: &ReductionGraph, paths: &[ReductionPath]) -> Vec { + let target_fields = paths + .first() + .and_then(|path| path.steps.last()) + .map(|target| graph.size_field_names(&target.name)) + .unwrap_or_default(); pareto_flags_by( paths, |path| { - let target = path.steps.last().expect("path has a target node"); let growth = graph .compose_path_size_transform(path) .ok() .flatten()? .project_growth(); - graph - .size_field_names(&target.name) + target_fields .iter() .all(|field| growth.get(field).is_some()) .then_some(growth) @@ -1198,11 +1167,26 @@ pub(crate) fn symbolic_pareto_flags(graph: &ReductionGraph, paths: &[ReductionPa pub(crate) fn concrete_pareto_flags(executed: &[ExecutedPath]) -> Vec { pareto_flags_by( executed, - |path| path.target_sizes().last().cloned(), + |path| { + let target = path.path.steps.last().expect("path has a target node"); + Some(ReductionGraph::compute_problem_size( + &target.name, + &target.variant, + path.target_problem_any(), + )) + }, problem_size_dominates, ) } +fn path_truncation_note(limit: usize) -> &'static str { + if limit == MAX_PATHS { + "\n(more paths exist; path search is capped at 999)\n" + } else { + "\n(more paths exist; increase --limit, maximum: 999)\n" + } +} + /// Render the symbolic path listing (header + per-path chains with normalized /// size contracts). Extracted so it is built only for text output and can be /// exercised in-process by regression tests without spawning the binary. @@ -1212,7 +1196,7 @@ fn render_paths_text( src_name: &str, dst_name: &str, truncated: bool, - max_paths: usize, + limit: usize, ) -> String { let mut text = format!( "Found {} paths from {} to {}:\n", @@ -1225,9 +1209,7 @@ fn render_paths_text( text.push_str(&format_path_text(graph, p)); } if truncated { - text.push_str(&format!( - "\n(more selected paths exist; use --max-paths to increase the output limit above {max_paths})\n" - )); + text.push_str(path_truncation_note(limit)); } text } @@ -1296,33 +1278,20 @@ fn path_concrete( src_variant: &BTreeMap, dst_name: &str, dst_variant: &BTreeMap, - max_paths: usize, - selection: PathSelection, + limit: usize, + unfiltered: bool, source: &dyn Any, out: &OutputConfig, ) -> Result<()> { - let mut batch = find_path_batch( - graph, - src_name, - src_variant, - dst_name, - dst_variant, - max_paths, - selection, - )?; + let mut batch = find_path_batch(graph, src_name, src_variant, dst_name, dst_variant, limit)?; if batch.paths.is_empty() && !batch.truncated { anyhow::bail!("No reduction path from {src_name} to {dst_name}"); } - if selection == PathSelection::All { - cap_path_batch(&mut batch); - } let mut executed = graph.execute_paths(&batch.paths, source)?; - if selection == PathSelection::Pareto { + if !unfiltered { let flags = concrete_pareto_flags(&executed); batch.paths = retain_selected(batch.paths, &flags); executed = retain_selected(executed, &flags); - cap_path_batch(&mut batch); - executed.truncate(batch.max_paths); } let json_output = out.output.is_some() || out.json; let json = if json_output { @@ -1342,10 +1311,7 @@ fn path_concrete( text.push_str(&format_concrete_path_text(graph, path)); } if batch.truncated { - text.push_str(&format!( - "\n(more selected paths exist; use --max-paths to increase the output limit above {})\n", - batch.max_paths - )); + text.push_str(path_truncation_note(limit)); } text }; diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 96ac1a3d6..c7a6f9c42 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -69,14 +69,14 @@ fn main() -> anyhow::Result<()> { Commands::Path { source, target, - max_paths, - selection, + limit, + unfiltered, instance, } => commands::graph::path( &source, &target, - max_paths, - selection, + limit, + unfiltered, instance.as_deref(), &out, ), diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 8f117cb5e..cc50f1298 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -1,10 +1,9 @@ -use crate::commands::graph::PathSelection; -use crate::mcp::tools::{FindPathParams, McpServer}; +use crate::mcp::tools::{FindPathParams, McpServer, PathLimitParam}; 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, 999, PathSelection::All, None) + .find_path_inner(source, target, 999, true, None) .expect("path enumeration"); let json: serde_json::Value = serde_json::from_str(&response).unwrap(); let entry = json["paths"] @@ -49,7 +48,7 @@ fn test_find_path_enumerates_without_a_mode_or_sizes() { "MIS/SimpleGraph/i32", "MaximumClique/SimpleGraph/i32", 20, - PathSelection::Pareto, + false, None, ) .unwrap(), @@ -72,7 +71,7 @@ fn test_find_path_executes_complete_instance_and_reports_actual_size() { "MIS/SimpleGraph/i32", "MaximumClique/SimpleGraph/i32", 20, - PathSelection::Pareto, + false, Some(problem_json), ) .unwrap(), @@ -102,12 +101,29 @@ fn test_find_path_schema_accepts_complete_problem_json() { .contains("MaximumIndependentSet")); } +#[test] +fn test_find_path_limit_all_resolves_to_999() { + let params: FindPathParams = serde_json::from_value(serde_json::json!({ + "source": "MIS", + "target": "QUBO", + "limit": "all" + })) + .unwrap(); + assert_eq!(params.limit.as_ref().unwrap().resolve().unwrap(), 999); + + let numeric: PathLimitParam = serde_json::from_value(serde_json::json!(999)).unwrap(); + assert_eq!(numeric.resolve().unwrap(), 999); + + let numeric_string: PathLimitParam = serde_json::from_value(serde_json::json!("20")).unwrap(); + assert!(numeric_string.resolve().is_err()); +} + #[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, PathSelection::Pareto, None) + .find_path_inner("MIS", "QUBO", 1, false, None) .unwrap(), ) .unwrap(); @@ -119,11 +135,14 @@ fn test_find_path_is_capped_explicitly() { } #[test] -fn test_find_path_rejects_max_paths_above_output_limit() { +fn test_find_path_rejects_limit_above_maximum() { let error = McpServer::new() - .find_path_inner("MIS", "QUBO", 1000, PathSelection::All, None) + .find_path_inner("MIS", "QUBO", 1000, true, None) .unwrap_err(); - assert_eq!(error.to_string(), "max_paths must not exceed 999"); + assert_eq!( + error.to_string(), + "limit must be an integer from 1 to 999 or 'all'" + ); } #[test] diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 4cb4652bb..6da848dd4 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -31,16 +31,33 @@ pub struct NeighborsParams { pub direction: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +#[serde(untagged)] +pub enum PathLimitParam { + Number(usize), + Name(String), +} + +impl PathLimitParam { + pub(crate) fn resolve(&self) -> Result { + match self { + Self::Number(limit) => crate::commands::graph::validate_path_limit(*limit), + Self::Name(limit) if limit == "all" => crate::commands::graph::parse_path_limit(limit), + Self::Name(_) => Err(crate::commands::graph::PATH_LIMIT_ERROR.to_string()), + } + } +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct FindPathParams { #[schemars(description = "Source problem name or alias")] pub source: String, #[schemars(description = "Target problem name or alias")] pub target: String, - #[schemars(description = "Maximum selected paths to output (default: 20, maximum: 999)")] - pub max_paths: Option, - #[schemars(description = "Path selection: pareto (default) or all")] - pub selection: Option, + #[schemars(description = "Number of paths to inspect: 1-999, or 'all' for 999 (default: 20)")] + pub limit: Option, + #[schemars(description = "Return enumerated paths without Pareto filtering (default: false)")] + pub unfiltered: Option, #[schemars( description = "Optional complete source problem JSON. When present, execute every returned path and report actual constructed sizes." )] @@ -229,8 +246,8 @@ impl McpServer { &self, source: &str, target: &str, - max_paths: usize, - selection: crate::commands::graph::PathSelection, + limit: usize, + unfiltered: bool, problem_json: Option<&str>, ) -> anyhow::Result { let graph = ReductionGraph::new(); @@ -260,8 +277,7 @@ impl McpServer { &src_ref.variant, &dst_ref.name, &dst_ref.variant, - max_paths, - selection, + limit, )?; if batch.paths.is_empty() && !batch.truncated { anyhow::bail!( @@ -270,15 +286,11 @@ impl McpServer { dst_ref.name ); } - if selection == crate::commands::graph::PathSelection::All { - crate::commands::graph::cap_path_batch(&mut batch); - } - let mut executed = loaded .as_ref() .map(|source| graph.execute_paths(&batch.paths, source.as_any())) .transpose()?; - if selection == crate::commands::graph::PathSelection::Pareto { + if !unfiltered { let flags = match &executed { Some(executed) => crate::commands::graph::concrete_pareto_flags(executed), None => crate::commands::graph::symbolic_pareto_flags(&graph, &batch.paths), @@ -286,10 +298,6 @@ impl McpServer { batch.paths = crate::commands::graph::retain_selected(batch.paths, &flags); executed = executed.map(|executed| crate::commands::graph::retain_selected(executed, &flags)); - crate::commands::graph::cap_path_batch(&mut batch); - if let Some(executed) = &mut executed { - executed.truncate(batch.max_paths); - } } let json = crate::commands::graph::path_batch_json(&graph, &batch, executed.as_deref())?; Ok(serde_json::to_string_pretty(&json)?) @@ -536,18 +544,15 @@ impl McpServer { annotations(read_only_hint = true, open_world_hint = false) )] fn find_path(&self, Parameters(params): Parameters) -> Result { - let max_paths = params.max_paths.unwrap_or(20); - let selection = params - .selection - .as_deref() - .unwrap_or("pareto") - .parse() - .map_err(|error: String| error)?; + let limit = params + .limit + .as_ref() + .map_or(Ok(20), PathLimitParam::resolve)?; self.find_path_inner( ¶ms.source, ¶ms.target, - max_paths, - selection, + limit, + params.unfiltered.unwrap_or(false), params.problem_json.as_deref(), ) .map_err(|e| e.to_string()) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index b5b5ee33c..a9471c94d 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -6,7 +6,15 @@ fn pred() -> Command { fn write_named_route(source: &str, target: &str, names: &[&str], output: &std::path::Path) { let command = pred() - .args(["path", source, target, "--max-paths", "999", "--json"]) + .args([ + "path", + source, + target, + "--limit", + "all", + "--unfiltered", + "--json", + ]) .output() .unwrap(); assert!( @@ -59,7 +67,15 @@ fn reduce_named_to_file( fn write_direct_route(source: &str, target: &str, output: &std::path::Path) { let command = pred() - .args(["path", source, target, "--max-paths", "999", "--json"]) + .args([ + "path", + source, + target, + "--limit", + "all", + "--unfiltered", + "--json", + ]) .output() .unwrap(); assert!(command.status.success()); @@ -376,6 +392,36 @@ fn test_path_enumerates_without_mode_or_sizes() { assert!(stdout.contains("paths from")); } +#[test] +fn test_path_rejects_zero_edge_route_for_symbolic_and_concrete_queries() { + let symbolic = pred() + .args(["path", "MIS", "MIS", "--json"]) + .output() + .unwrap(); + assert!(!symbolic.status.success()); + assert!(String::from_utf8_lossy(&symbolic.stderr).contains("No reduction path")); + + let instance = std::env::temp_dir().join("pred_path_same_source_mis.json"); + std::fs::write( + &instance, + r#"{"type":"MaximumIndependentSet","variant":{"graph":"SimpleGraph","weight":"i32"},"data":{"graph":{"num_vertices":2,"edges":[[0,1]]},"weights":[1,1]}}"#, + ) + .unwrap(); + let concrete = pred() + .args([ + "path", + "MIS/SimpleGraph/i32", + "MIS/SimpleGraph/i32", + instance.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + std::fs::remove_file(instance).ok(); + assert!(!concrete.status.success()); + assert!(String::from_utf8_lossy(&concrete.stderr).contains("No reduction path")); +} + #[test] fn test_path_concrete_execution_is_deterministic_and_measures_constructed_target() { let instance = std::env::temp_dir().join("pred_path_concrete_mis.json"); @@ -419,25 +465,25 @@ fn test_path_concrete_execution_is_deterministic_and_measures_constructed_target } #[test] -fn test_path_selection_defaults_to_pareto_and_all_returns_every_candidate() { +fn test_path_defaults_to_pareto_and_unfiltered_returns_every_candidate() { let instance = std::env::temp_dir().join("pred_path_selection_mis.json"); std::fs::write( &instance, 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]}}"#, ) .unwrap(); - let run = |max_paths: &str, selection: Option<&str>| { + let run = |limit: &str, unfiltered: bool| { let mut args = vec![ "path", "MIS/SimpleGraph/i32", "QUBO", instance.to_str().unwrap(), - "--max-paths", - max_paths, + "--limit", + limit, "--json", ]; - if let Some(selection) = selection { - args.extend(["--selection", selection]); + if unfiltered { + args.push("--unfiltered"); } let output = pred().args(args).output().unwrap(); assert!( @@ -448,24 +494,27 @@ fn test_path_selection_defaults_to_pareto_and_all_returns_every_candidate() { serde_json::from_slice::(&output.stdout).unwrap() }; - let pareto = run("3", None); - let pareto_capped = run("1", None); - let all = run("3", Some("all")); + let pareto = run("3", false); + let pareto_limited = run("1", false); + let unfiltered = run("3", true); std::fs::remove_file(instance).unwrap(); assert_eq!(pareto["paths"].as_array().unwrap().len(), 1); - assert_eq!(pareto_capped["paths"].as_array().unwrap().len(), 1); - assert_eq!(all["paths"].as_array().unwrap().len(), 3); - assert_eq!(pareto["truncated"], false); - assert_eq!(pareto_capped["truncated"], false); - assert_eq!(all["truncated"], true); + assert_eq!(pareto_limited["paths"].as_array().unwrap().len(), 1); + assert_eq!(unfiltered["paths"].as_array().unwrap().len(), 3); + assert_eq!(pareto["truncated"], true); + assert_eq!(pareto_limited["truncated"], true); + assert_eq!(unfiltered["truncated"], true); let pareto_size = &pareto["paths"][0]["actual_target_size"]["fields"]; assert!(pareto_size .as_array() .unwrap() .iter() .any(|field| field["field"] == "num_vars" && field["value"] == 5)); - assert_eq!(pareto_capped["paths"], pareto["paths"]); + assert_ne!( + pareto_limited["paths"], pareto["paths"], + "a larger candidate set may expose a path that dominates the first candidate" + ); } #[test] @@ -497,9 +546,9 @@ fn test_path_save() { } #[test] -fn test_path_max_paths_caps_selected_output() { +fn test_path_limit_bounds_enumeration() { let output = pred() - .args(["path", "MIS", "QUBO", "--max-paths", "1", "--json"]) + .args(["path", "MIS", "QUBO", "--limit", "1", "--json"]) .output() .unwrap(); assert!(output.status.success()); @@ -511,21 +560,51 @@ fn test_path_max_paths_caps_selected_output() { } #[test] -fn test_path_rejects_max_paths_above_output_limit() { +fn test_path_rejects_limit_above_maximum() { let output = pred() - .args([ - "path", - "MIS", - "QUBO", - "--selection", - "all", - "--max-paths", - "1000", - ]) + .args(["path", "MIS", "QUBO", "--limit", "1000"]) .output() .unwrap(); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("max_paths must not exceed 999")); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("limit must be an integer from 1 to 999 or 'all'")); +} + +#[test] +fn test_path_limit_all_is_alias_for_999() { + let run = |limit: &str| { + let output = pred() + .args([ + "path", + "MIS", + "QUBO", + "--limit", + limit, + "--unfiltered", + "--json", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout + }; + + assert_eq!(run("all"), run("999")); +} + +#[test] +fn test_path_rejects_zero_limit() { + let output = pred() + .args(["path", "MIS", "QUBO", "--limit", "0"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("limit must be an integer from 1 to 999 or 'all'")); } #[test] @@ -5334,7 +5413,7 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() { "path", "MaximumClique/SimpleGraph/i32", "ILP/bool", - "--max-paths", + "--limit", "1", "--json", ]) @@ -5361,14 +5440,7 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() { #[test] fn test_path_overall_unavailable_reason_matches_each_target_field() { let output = pred() - .args([ - "path", - "Factoring", - "ILP/bool", - "--max-paths", - "7", - "--json", - ]) + .args(["path", "Factoring", "ILP/bool", "--limit", "7", "--json"]) .output() .unwrap(); assert!(output.status.success()); @@ -8199,9 +8271,9 @@ fn test_show_ksat_works() { // ---- Capped multi-path ---- #[test] -fn test_path_max_paths_truncates() { +fn test_path_limit_truncates() { let output = pred() - .args(["path", "KSat", "QUBO", "--max-paths", "3", "--json"]) + .args(["path", "KSat", "QUBO", "--limit", "3", "--json"]) .output() .unwrap(); assert!( @@ -8226,11 +8298,19 @@ fn test_path_max_paths_truncates() { ); } -// Helper: run `pred path S T --max-paths N --json` and return the ordered +// Helper: run `pred path S T --limit N --unfiltered --json` and return the ordered // list of per-path step counts. -fn path_step_counts(max_paths: &str) -> Vec { +fn path_step_counts(limit: &str) -> Vec { let output = pred() - .args(["path", "KSat", "QUBO", "--max-paths", max_paths, "--json"]) + .args([ + "path", + "KSat", + "QUBO", + "--limit", + limit, + "--unfiltered", + "--json", + ]) .output() .unwrap(); assert!( @@ -8251,7 +8331,7 @@ fn path_step_counts(max_paths: &str) -> Vec { #[test] fn test_path_truncates_after_sorting_not_before() { // Path enumeration must order length-first and truncate only after - // ordering, so a small --max-paths returns the SHORTEST routes, not whichever + // ordering, so a small --limit returns the SHORTEST routes, not whichever // routes DFS discovered first. Compare a tightly-truncated run against a run // with a generous budget. let full = path_step_counts("500"); @@ -8282,16 +8362,16 @@ fn test_path_truncates_after_sorting_not_before() { } #[test] -fn test_path_max_paths_text_truncation_note() { +fn test_path_limit_text_truncation_note() { let output = pred() - .args(["path", "KSat", "QUBO", "--max-paths", "2"]) + .args(["path", "KSat", "QUBO", "--limit", "2"]) .output() .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("--max-paths"), - "truncation note should mention --max-paths: {stdout}" + stdout.contains("--limit"), + "truncation note should mention --limit: {stdout}" ); } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 6de195665..2a71e0f03 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -21,9 +21,11 @@ use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex}; use petgraph::visit::EdgeRef; use serde::Serialize; use std::any::Any; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::rc::Rc; +type NodePathOrderKey<'a> = (usize, Vec<(&'static str, &'a BTreeMap)>); + /// A source/target pair from the reduction graph, returned by /// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`]. #[derive(Debug, Clone)] @@ -527,6 +529,141 @@ impl ReductionGraph { ReductionPath { steps } } + fn node_path_order_key(&self, node_path: &[NodeIndex]) -> NodePathOrderKey<'_> { + ( + node_path.len().saturating_sub(1), + node_path + .iter() + .map(|&idx| { + let node = &self.nodes[self.graph[idx]]; + (node.name, &node.variant) + }) + .collect(), + ) + } + + #[allow(clippy::too_many_arguments)] + fn shortest_node_path( + &self, + source: NodeIndex, + target: NodeIndex, + adjacency: &[Vec], + excluded_nodes: &HashSet, + excluded_edges: &HashSet<(NodeIndex, NodeIndex)>, + max_nodes: usize, + ) -> Option> { + if excluded_nodes.contains(&source) || max_nodes == 0 { + return None; + } + if source == target { + return Some(vec![source]); + } + + let mut queue = VecDeque::from([(source, 1usize)]); + let mut parents = HashMap::new(); + let mut visited = HashSet::from([source]); + + while let Some((current, path_nodes)) = queue.pop_front() { + if path_nodes == max_nodes { + continue; + } + for &next in &adjacency[current.index()] { + if excluded_nodes.contains(&next) + || excluded_edges.contains(&(current, next)) + || !visited.insert(next) + { + continue; + } + parents.insert(next, current); + if next == target { + let mut path = vec![target]; + let mut node = target; + while node != source { + node = parents[&node]; + path.push(node); + } + path.reverse(); + return Some(path); + } + queue.push_back((next, path_nodes + 1)); + } + } + None + } + + fn find_k_shortest_node_paths( + &self, + source: NodeIndex, + target: NodeIndex, + mode: ReductionMode, + limit: usize, + max_nodes: usize, + ) -> Vec> { + if source == target || limit == 0 { + return Vec::new(); + } + + let mut adjacency = vec![Vec::new(); self.graph.node_count()]; + for node in self.graph.node_indices() { + adjacency[node.index()] = self + .ordered_outgoing_edges(node, mode) + .into_iter() + .map(|(target, _)| target) + .collect(); + } + + let Some(first) = self.shortest_node_path( + source, + target, + &adjacency, + &HashSet::new(), + &HashSet::new(), + max_nodes, + ) else { + return Vec::new(); + }; + + let mut accepted = vec![first]; + let mut candidates = BTreeSet::new(); + + while accepted.len() < limit { + let previous = accepted.last().expect("accepted path exists"); + for spur_index in 0..previous.len().saturating_sub(1) { + let root = &previous[..=spur_index]; + let excluded_edges = accepted + .iter() + .filter(|path| path.len() > spur_index + 1 && path[..=spur_index] == *root) + .map(|path| (path[spur_index], path[spur_index + 1])) + .collect::>(); + let excluded_nodes = root[..spur_index].iter().copied().collect(); + let max_spur_nodes = max_nodes.saturating_sub(spur_index); + let Some(spur) = self.shortest_node_path( + previous[spur_index], + target, + &adjacency, + &excluded_nodes, + &excluded_edges, + max_spur_nodes, + ) else { + continue; + }; + let mut candidate = root[..spur_index].to_vec(); + candidate.extend(spur); + if !accepted.contains(&candidate) { + let key = self.node_path_order_key(&candidate); + candidates.insert((key, candidate)); + } + } + + let Some((_, next)) = candidates.pop_first() else { + break; + }; + accepted.push(next); + } + + accepted + } + /// Find all simple paths between two specific problem variants. /// /// Uses `all_simple_paths` on the variant-level graph from the exact @@ -582,8 +719,9 @@ impl ReductionGraph { /// Find up to `limit` simple paths between two specific problem variants. /// - /// Like [`find_all_paths`](Self::find_all_paths) but stops enumeration after - /// collecting `limit` paths. This avoids combinatorial explosion on dense graphs. + /// Returns witness-capable paths in deterministic order: fewest edges first, + /// then canonical problem name and variant order. Enumeration stops after + /// collecting `limit` paths. pub fn find_paths_up_to( &self, source: &str, @@ -603,8 +741,8 @@ impl ReductionGraph { ) } - /// Like [`find_all_paths_mode`](Self::find_all_paths_mode) but stops - /// enumeration after collecting `limit` paths. + /// Returns paths whose edges support `mode`, ordered by fewest edges first + /// and canonical problem name and variant order, stopping after `limit` paths. pub fn find_paths_up_to_mode( &self, source: &str, @@ -625,8 +763,8 @@ impl ReductionGraph { ) } - /// Like [`find_paths_up_to_mode`](Self::find_paths_up_to_mode) but also - /// bounds the number of intermediate nodes in each enumerated path. + /// Like [`find_paths_up_to_mode`](Self::find_paths_up_to_mode), with at most + /// `max_intermediate_nodes` nodes strictly between the source and target. #[allow(clippy::too_many_arguments)] pub fn find_paths_up_to_mode_bounded( &self, @@ -651,40 +789,13 @@ impl ReductionGraph { return Vec::new(); } - // Enumerate simple paths breadth-first. Each level is already lexicographic - // because both the preceding level and every outgoing edge list are ordered - // by canonical node identity. Completed paths therefore arrive in the exact - // public order: fewest nodes first, then canonical node identity. Stop immediately - // after `limit` results instead of traversing every simple path. let max_intermediate = max_intermediate_nodes.unwrap_or_else(|| self.graph.node_count().saturating_sub(2)); let max_nodes = max_intermediate.saturating_add(2); - let mut frontier = vec![vec![src]]; - let mut paths = Vec::with_capacity(limit); - - while !frontier.is_empty() && frontier[0].len() < max_nodes { - let mut next_frontier = Vec::new(); - for path in frontier { - let current = path[path.len() - 1]; - for (next, _) in self.ordered_outgoing_edges(current, mode) { - if path.contains(&next) { - continue; - } - let mut extended = path.clone(); - extended.push(next); - if next == dst { - paths.push(self.node_path_to_reduction_path(&extended)); - if paths.len() == limit { - return paths; - } - } else { - next_frontier.push(extended); - } - } - } - frontier = next_frontier; - } - paths + self.find_k_shortest_node_paths(src, dst, mode, limit, max_nodes) + .iter() + .map(|path| self.node_path_to_reduction_path(path)) + .collect() } /// Check if a direct reduction exists from S to T. diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index ac0dcd424..26bf4f768 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -600,6 +600,35 @@ fn find_paths_up_to_stops_after_limit() { ); } +#[test] +fn find_paths_up_to_matches_sorted_exhaustive_prefixes() { + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); + let mut all = graph.find_all_paths("MaximumIndependentSet", &src, "QUBO", &dst); + all.sort_by(|left, right| { + left.len().cmp(&right.len()).then_with(|| { + left.steps + .iter() + .map(|step| (&step.name, &step.variant)) + .cmp(right.steps.iter().map(|step| (&step.name, &step.variant))) + }) + }); + + for limit in 1..=all.len() { + let limited = graph.find_paths_up_to("MaximumIndependentSet", &src, "QUBO", &dst, limit); + let actual = limited + .iter() + .map(|path| path.steps.clone()) + .collect::>(); + let expected = all[..limit] + .iter() + .map(|path| path.steps.clone()) + .collect::>(); + assert_eq!(actual, expected, "wrong prefix for limit {limit}"); + } +} + #[test] fn find_paths_up_to_returns_all_when_limit_exceeds_total() { let graph = ReductionGraph::new(); @@ -631,6 +660,23 @@ fn find_paths_up_to_no_path() { assert!(limited.is_empty()); } +#[test] +fn find_paths_up_to_same_source_has_no_zero_edge_path() { + let graph = ReductionGraph::new(); + let variant = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + + let paths = graph.find_paths_up_to( + "MaximumIndependentSet", + &variant, + "MaximumIndependentSet", + &variant, + 10, + ); + + assert!(paths.is_empty()); +} + // ---- Exact source+target variant matching ---- #[test] From 6c7e4cfbd7348e05f573af3266d740bb8b7aa5f0 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 18 Aug 2026 16:43:03 +0800 Subject: [PATCH 13/15] Clarify registered solver dispatch --- .claude/CLAUDE.md | 2 +- .claude/skills/write-model-in-paper/SKILL.md | 2 - docs/src/cli.md | 42 ++++++++++--------- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/dispatch.rs | 2 +- problemreductions-cli/src/mcp/tests.rs | 2 +- problemreductions-cli/tests/cli_tests.rs | 4 +- .../models/misc/timetable_design.rs | 6 +-- 8 files changed, 32 insertions(+), 30 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fac51c1e4..ef9dc517a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -107,7 +107,7 @@ make papers-pull # Pull PDFs from shared remote - Run `pred list` for the full catalog of problems, variants, and reductions; `pred show ` for details on a specific problem - `src/rules/` - Reduction rules + inventory registration - `src/models/decision.rs` - Generic `Decision

` wrapper converting optimization problems to decision problems -- `src/solvers/` - BruteForce solver for aggregate values plus witness recovery when supported, ILP solver (feature-gated, witness-only), decision search (binary search via Decision queries). To check if a problem supports ILP solving via a witness-capable reduction path, run `pred path ILP` +- `src/solvers/` - BruteForce solver for aggregate values plus witness recovery when supported, ILP solver (feature-gated, witness-only), decision search (binary search via Decision queries), and the exact-variant solver capability registry. Solver dispatch uses only registered customized implementations and fixed ILP pipelines; reduction-graph reachability does not imply solver availability. Run `pred inspect ` to see the registered capabilities for that instance. - `src/traits.rs` - `Problem` trait - `src/rules/traits.rs` - `ReduceTo`, `ReduceToAggregate`, `ReductionResult`, `AggregateReductionResult` traits - `src/registry/` - Compile-time reduction metadata collection diff --git a/.claude/skills/write-model-in-paper/SKILL.md b/.claude/skills/write-model-in-paper/SKILL.md index e3c95d4dd..d8ca47ba2 100644 --- a/.claude/skills/write-model-in-paper/SKILL.md +++ b/.claude/skills/write-model-in-paper/SKILL.md @@ -176,8 +176,6 @@ Add a `pred-commands()` block after the `*Example.*` paragraph and before the `# If `docs/paper/reductions.typ` already defines a shared `problem-spec()` helper, reuse it instead of reintroducing it locally. Do **not** guess whether the default variant matches the canonical example; canonical fixtures may live on non-default variants, and handwritten bare aliases can silently produce broken commands. -For satisfaction problems, replace `pred solve` with `pred solve .json --solver brute-force` if the problem has no ILP reduction path. - **For graph problems**, use the paper's existing graph helpers: - `petersen-graph()`, `house-graph()` or define custom vertex/edge lists - `canvas(length: ..., { ... })` with `g-node()` and `g-edge()` diff --git a/docs/src/cli.md b/docs/src/cli.md index 87e544822..a5ca92e17 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -53,8 +53,8 @@ pred create LengthBoundedDisjointPaths --graph 0-1,1-6,0-2,2-3,3-6,0-4,4-5,5-6 - # Create a Consecutive Block Minimization instance (alias: CBM) pred create CBM --matrix '[[true,false,true],[false,true,true]]' --bound 2 -o cbm.json -# CBM currently needs the brute-force solver -pred solve cbm.json --solver brute-force +# Solve CBM through its registered fixed ILP pipeline +pred solve cbm.json # Or start from a canonical model example pred create --example MIS/SimpleGraph/i32 -o example.json @@ -68,14 +68,14 @@ pred inspect problem.json # Inspect the new path problem pred inspect lbdp.json -# Solve it (auto-reduces to ILP) +# Solve it through the exact variant's registered fixed ILP pipeline pred solve problem.json # Or solve with brute-force pred solve problem.json --solver brute-force -# LengthBoundedDisjointPaths currently needs brute-force -pred solve lbdp.json --solver brute-force +# LengthBoundedDisjointPaths also has a registered fixed ILP pipeline +pred solve lbdp.json # Evaluate a specific configuration (shows the aggregate value, e.g. Max(2) or Min(None)) pred evaluate problem.json --config 1,0,1,0 @@ -85,7 +85,7 @@ pred reduce problem.json --via route.json -o reduced.json pred solve reduced.json --solver brute-force # Pipe commands together (use - to read from stdin) -pred create MIS --graph 0-1,1-2,2-3 | pred solve - # when an ILP reduction path exists +pred create MIS --graph 0-1,1-2,2-3 | pred solve - pred create StringToStringCorrection --source-string "0,1,2,3,1,0" --target-string "0,1,3,2,1" --bound 2 | pred solve - --solver brute-force pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --via route.json | pred solve - ``` @@ -221,8 +221,8 @@ For `LengthBoundedDisjointPaths`, the CLI flag `--bound` maps to the JSON field `max_length`. For `ConsecutiveBlockMinimization`, the `--matrix` flag expects a JSON 2D bool array such as -`'[[true,false,true],[false,true,true]]'`. The example above shows the accepted shape, and solving -CBM instances currently requires `--solver brute-force`. +`'[[true,false,true],[false,true,true]]'`. The example above shows the accepted shape. Its exact +default variant has a registered fixed ILP pipeline, so the default solver dispatch selects ILP. For problem-specific create help, run `pred create ` with no additional flags. The generic `pred create --help` output lists all flags across all problem types. @@ -244,7 +244,7 @@ pred create MaxCut --random --num-vertices 20 --edge-prob 0.5 -o maxcut.json Without `-o`, the problem JSON is printed to stdout, which can be piped to other commands: ```bash -pred create MIS --graph 0-1,1-2,2-3 | pred solve - # when an ILP reduction path exists +pred create MIS --graph 0-1,1-2,2-3 | pred solve - pred create StringToStringCorrection --source-string "0,1,2,3,1,0" --target-string "0,1,3,2,1" --bound 2 | pred solve - --solver brute-force pred create MIS --random --num-vertices 10 | pred inspect - ``` @@ -274,11 +274,11 @@ pred create BoundedComponentSpanningForest \ -o bcsf.json pred evaluate bcsf.json --config 0,0,1,1,1,2,2,0 -pred solve bcsf.json --solver brute-force +pred solve bcsf.json ``` -The brute-force solver is required here because this model does not yet have an -ILP reduction path. +This exact variant has a registered fixed ILP pipeline, so the default dispatch +selects ILP. Use `pred inspect bcsf.json` to view that capability before solving. ### `pred evaluate` — Evaluate a configuration @@ -359,7 +359,9 @@ pred create MinMaxMulticenter --graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weigh pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets "0,1,2;3,4,5;1,3;2,4;0,5" | pred solve - --solver brute-force ``` -Output is JSON. When the problem is not ILP, the solver automatically reduces it to ILP, solves, and maps the solution back: +Output is JSON. When the exact problem variant has a fixed ILP pipeline in the +solver capability registry, the ILP backend follows that registered pipeline and +maps the solution back: ```json {{#include generated/pred-solve-ilp.txt}} @@ -376,17 +378,19 @@ Successful exact solves report `"status": "optimal"`; aggregate-only problems om `"status": "infeasible"` without `solution` or `evaluation`. Solver, timeout, registry, and extraction failures remain command errors. -> **Note:** The ILP solver requires a reduction path from the target problem to ILP. -> Some problems do not currently have one. Examples include BoundedComponentSpanningForest, -> LengthBoundedDisjointPaths, MinimumCardinalityKey, QUBO, SpinGlass, MaxCut, CircuitSAT, MinMaxMulticenter, and MultiprocessorScheduling. -> Use `pred solve --solver brute-force` for these, or reduce to a problem that supports ILP first. -> For other problems, use `pred path ILP` to check whether an ILP reduction path exists. +> **Note:** Solver availability is determined by the exact problem variant's +> registered capabilities. `pred path ILP` reports reduction-graph +> reachability; it does not register a solver pipeline and therefore does not +> establish that `--solver ilp` is available. Use `pred inspect ` to see the +> instance's default solver, available overrides, customized implementation, and +> fixed ILP pipeline. For example, the canonical Minimum Cardinality Key instance can be created and solved with: ```bash pred create MinimumCardinalityKey --num-attributes 6 --dependencies "0,1>2;0,2>3;1,3>4;2,4>5" -o mck.json -pred solve mck.json --solver brute-force +pred inspect mck.json +pred solve mck.json # uses its registered customized solver ``` ## Shell Completions diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index ae68684cf..496176230 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -17,7 +17,7 @@ Typical workflow: pred evaluate problem.json --config 1,0,1,0 Piping (use - to read from stdin): - pred create MIS --graph 0-1,1-2 | pred solve - # when an ILP reduction path exists + pred create MIS --graph 0-1,1-2 | pred solve - pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 pred create MIS --graph 0-1,1-2 | pred reduce - --via route.json diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index cc344de11..cfcde85d0 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -552,7 +552,7 @@ mod tests { solver_request(Some("brute-force")).unwrap(), SolverRequest::BruteForce ); - for rejected in ["auto", "native", "implementation-id"] { + for rejected in ["auto", "implementation-id"] { let error = solver_request(Some(rejected)).unwrap_err(); assert!(error.to_string().contains(rejected), "{error}"); } diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index cc50f1298..cc923e2db 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -418,7 +418,7 @@ fn deterministic_solver_dispatch_defaults_supported_problem_to_customized() { fn test_solve_unknown_solver() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - for rejected in ["auto", "native", "fd-minimum-cardinality-key"] { + for rejected in ["auto", "fd-minimum-cardinality-key"] { let error = server .solve_inner(&problem_json, Some(rejected), None) .unwrap_err(); diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a9471c94d..a89dcb755 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3465,7 +3465,7 @@ fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability #[test] fn test_solve_bundle_ilp() { // Create → Reduce → Solve bundle with ILP - // Use MVC as target since it has an ILP reduction path (QUBO does not) + // Use MVC as the bundle target to exercise its registered fixed ILP pipeline. let problem_file = std::env::temp_dir().join("pred_test_solve_bundle_ilp_in.json"); let bundle_file = std::env::temp_dir().join("pred_test_solve_bundle_ilp.json"); @@ -9149,7 +9149,7 @@ fn deterministic_solver_dispatch_rejects_non_override_solver_names() { .unwrap(); assert!(create_out.status.success()); - for rejected in ["auto", "native", "fd-minimum-cardinality-key"] { + for rejected in ["auto", "fd-minimum-cardinality-key"] { let output = pred() .args([ "solve", diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index aba2eea19..b710bb014 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -143,17 +143,17 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { } #[test] -fn test_timetable_design_native_backend_solves_feasible_example() { +fn test_timetable_design_customized_solver_finds_feasible_solution() { let problem = super::issue_example_problem(); let solution = problem .solve_via_required_assignments() - .expect("expected native backend to find a satisfying timetable"); + .expect("expected customized solver to find a satisfying timetable"); assert!(problem.evaluate(&solution)); } #[test] -fn test_timetable_design_unsat_instance_returns_none_via_native_backend() { +fn test_timetable_design_customized_solver_returns_none_for_infeasible_instance() { let problem = TimetableDesign::new( 1, 2, From 7e5cc345d3e684e735d434f8cdd9ba94aff305c2 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 18 Aug 2026 16:43:08 +0800 Subject: [PATCH 14/15] Validate rounded ILP solutions --- src/solvers/ilp/solver.rs | 31 ++++++++++++++++--- .../rules/threedimensionalmatching_ilp.rs | 7 +++-- src/unit_tests/solvers/ilp/solver.rs | 18 +++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index f3827c76d..1f8ea3017 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -26,6 +26,9 @@ pub enum ILPSolveError { /// Type-erased dispatch received a value other than a supported ILP variant. #[error("the ILP backend supports only ILP and ILP")] UnsupportedProblemType, + /// HiGHS reported an optimal solution that is invalid after integer rounding. + #[error("the ILP backend returned an invalid rounded solution: {0}")] + InvalidSolution(String), /// A target witness could not be mapped back to the source problem. #[error(transparent)] Extraction(#[from] crate::rules::ExtractionError), @@ -193,11 +196,31 @@ impl ILPSolver { // Extract solution: config index = value (no lower bound offset) let result: Vec = vars .iter() - .map(|v| { - let val = solution.value(*v); - val.round().max(0.0) as usize + .enumerate() + .map(|(index, v)| { + let value = solution.value(*v).round(); + if !value.is_finite() || value < 0.0 || value >= V::DIMS_PER_VAR as f64 { + return Err(ILPSolveError::InvalidSolution(format!( + "variable {index} rounded to {value}, outside the {} domain", + V::NAME + ))); + } + Ok(value as usize) }) - .collect(); + .collect::>()?; + + let values = result.iter().map(|&value| value as i64).collect::>(); + if let Some((index, constraint)) = problem + .constraints + .iter() + .enumerate() + .find(|(_, constraint)| !constraint.is_satisfied(&values)) + { + return Err(ILPSolveError::InvalidSolution(format!( + "constraint {index} is violated after rounding: left-hand side {} {:?} right-hand side {}", + constraint.evaluate_lhs(&values), constraint.cmp, constraint.rhs + ))); + } Ok(result) } diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index dea427b3d..55a21754d 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -3,7 +3,7 @@ use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::models::misc::{ResourceConstrainedScheduling, ThreePartition}; use crate::models::set::ThreeDimensionalMatching; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; -use crate::solvers::{BruteForce, ILPSolver}; +use crate::solvers::{BruteForce, ILPSolveError, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -137,9 +137,10 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { let direct_source = direct.extract_solution(&direct_solution).unwrap(); assert_eq!(problem.evaluate(&direct_source), Or(true)); + let indirect_solution = solver.solve(indirect.target_problem()); assert!( - solver.solve(indirect.target_problem()).is_ok(), - "indirect ILP should agree on feasibility" + matches!(indirect_solution, Err(ILPSolveError::InvalidSolution(_))), + "the numerically unstable indirect ILP should be rejected: {indirect_solution:?}" ); assert!(direct.target_problem().num_vars < indirect.target_problem().num_vars); assert!( diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 1972a580f..ff53ec499 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -122,6 +122,24 @@ fn test_backend_errors_are_classified_without_losing_the_cause() { )); } +#[test] +fn test_ilp_rejects_solution_that_is_infeasible_after_rounding() { + let ilp = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 1.0)], 0.999_999_9)], + vec![(0, 1.0)], + ObjectiveSense::Maximize, + ); + + let solution = ILPSolver::new().solve(&ilp); + + assert!(matches!( + solution, + Err(ILPSolveError::InvalidSolution(message)) + if message.contains("constraint 0 is violated after rounding") + )); +} + #[test] fn test_ilp_equality_constraint() { // Minimize x0 subject to x0 + x1 == 1, binary vars From 149356e3dc5d20fcceeecbc6a355ab1d254d6590 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 20 Aug 2026 14:13:13 +0800 Subject: [PATCH 15/15] Complete symbolic reduction size contracts --- problemreductions-cli/tests/cli_tests.rs | 40 ++++-------- src/models/formula/circuit.rs | 37 +++++++++++ .../misc/precedence_constrained_scheduling.rs | 5 ++ .../misc/sequencing_within_intervals.rs | 15 +++++ src/rules/acyclicpartition_ilp.rs | 5 +- .../balancedcompletebipartitesubgraph_ilp.rs | 7 +- src/rules/biconnectivityaugmentation_ilp.rs | 7 +- .../boundedcomponentspanningforest_ilp.rs | 5 +- src/rules/circuit_ilp.rs | 9 +-- src/rules/circuit_spinglass.rs | 6 +- src/rules/clustering_ilp.rs | 7 +- src/rules/coloring_ilp.rs | 6 +- src/rules/consecutiveblockminimization_ilp.rs | 7 +- src/rules/consecutiveonessubmatrix_ilp.rs | 7 +- ...imumdominatingset_minimumsummulticenter.rs | 5 +- .../directedtwocommodityintegralflow_ilp.rs | 7 +- src/rules/disjointconnectingpaths_ilp.rs | 7 +- src/rules/eulerianpath_ilp.rs | 6 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 10 ++- src/rules/factoring_ilp.rs | 6 +- src/rules/flowshopscheduling_ilp.rs | 10 +-- src/rules/hamiltonianpath_ilp.rs | 6 +- src/rules/highlyconnecteddeletion_ilp.rs | 2 +- src/rules/ilp_i32_ilp_bool.rs | 2 +- src/rules/ilp_qubo.rs | 2 +- src/rules/integralflowhomologousarcs_ilp.rs | 7 +- src/rules/isomorphicspanningtree_ilp.rs | 7 +- src/rules/kclique_ilp.rs | 7 +- .../ksatisfiability_preemptivescheduling.rs | 8 +-- .../ksatisfiability_quadraticcongruences.rs | 8 +-- ...fiability_quadraticdiophantineequations.rs | 8 +-- src/rules/ksatisfiability_subsetsum.rs | 4 +- src/rules/lengthboundeddisjointpaths_ilp.rs | 7 +- src/rules/maximumclique_ilp.rs | 7 +- src/rules/maximumcommonedgesubgraph_ilp.rs | 6 +- ...maximumindependentset_maximumsetpacking.rs | 10 +-- .../minimumcapacitatedspanningtree_ilp.rs | 7 +- ...minimumexternalmacrodatacompression_ilp.rs | 6 +- ...minimuminternalmacrodatacompression_ilp.rs | 7 +- src/rules/minimumsummulticenter_ilp.rs | 7 +- src/rules/mixedchinesepostman_ilp.rs | 7 +- .../numericalmatchingwithtargetsums_ilp.rs | 7 +- src/rules/paintshop_ilp.rs | 7 +- src/rules/partitionintopathsoflength2_ilp.rs | 6 +- src/rules/partitionintotriangles_ilp.rs | 6 +- .../precedenceconstrainedscheduling_ilp.rs | 5 +- src/rules/quadraticassignment_ilp.rs | 6 +- src/rules/qubo_ilp.rs | 6 +- .../rectilinearpicturecompression_ilp.rs | 7 +- src/rules/rootedtreestorageassignment_ilp.rs | 7 +- src/rules/sat_circuitsat.rs | 6 +- src/rules/sat_coloring.rs | 6 +- ...cingtominimizemaximumcumulativecost_ilp.rs | 10 +-- ...quencingtominimizeweightedtardiness_ilp.rs | 10 +-- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 10 +-- src/rules/sequencingwithinintervals_ilp.rs | 9 +-- ...uencingwithreleasetimesanddeadlines_ilp.rs | 10 +-- src/rules/shortestcommonsupersequence_ilp.rs | 7 +- src/rules/sparsematrixcompression_ilp.rs | 7 +- src/rules/stringtostringcorrection_ilp.rs | 9 +-- src/rules/subgraphisomorphism_ilp.rs | 7 +- src/unit_tests/models/formula/circuit.rs | 2 + .../misc/precedence_constrained_scheduling.rs | 1 + .../misc/sequencing_within_intervals.rs | 2 + src/unit_tests/symbolic_size_contracts.rs | 65 +++++++++++++++++++ 65 files changed, 273 insertions(+), 284 deletions(-) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a89dcb755..a9814018f 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -5304,9 +5304,7 @@ fn test_path_overall_exact_map_json() { } #[test] -fn test_path_overall_exact_map_composition() { - // The One → i32 cast and graph complement are both exact. Their composition - // must remain in source fields rather than consulting a bound or Growth. +fn test_path_overall_upper_bound_map_composition() { let output = pred() .args([ "path", @@ -5353,8 +5351,9 @@ fn test_path_overall_exact_map_composition() { overall["num_vertices"] ); assert!( - overall["num_edges"].contains("num_vertices") && overall["num_edges"].contains("num_edges"), - "complement edges should be in terms of source vars, got: {}", + overall["num_edges"].contains("num_vertices") + && !overall["num_edges"].contains("num_edges"), + "composed edge bound should be in terms of source vertices, got: {}", overall["num_edges"] ); } @@ -5411,7 +5410,7 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() { let output = pred() .args([ "path", - "MaximumClique/SimpleGraph/i32", + "HighlyConnectedDeletion", "ILP/bool", "--limit", "1", @@ -5433,45 +5432,30 @@ fn test_path_overall_preserves_unavailable_fields_alongside_exact_fields() { ) }) .collect::>(); - assert_eq!(relations["num_vars"], "exact"); - assert_eq!(relations["num_constraints"], "unavailable"); + assert_eq!(relations["num_constraints"], "exact"); + assert_eq!(relations["num_vars"], "unavailable"); } #[test] -fn test_path_overall_unavailable_reason_matches_each_target_field() { +fn test_path_overall_unavailable_reason_explains_unsupported_bound() { let output = pred() - .args(["path", "Factoring", "ILP/bool", "--limit", "7", "--json"]) + .args(["path", "HighlyConnectedDeletion", "ILP/bool", "--json"]) .output() .unwrap(); assert!(output.status.success()); let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let path = envelope["paths"] - .as_array() - .unwrap() - .iter() - .find(|path| { - path["path"].as_array().is_some_and(|steps| { - steps - .iter() - .any(|step| step["from"]["name"] == "Clustering") - }) - }) - .expect("Factoring -> ... -> Clustering -> ILP path"); - let fields = path["overall_size"]["fields"] + let fields = envelope["paths"][0]["overall_size"]["fields"] .as_array() .unwrap() .iter() .map(|field| (field["field"].as_str().unwrap(), field)) .collect::>(); - assert!(fields["num_constraints"]["reason"] - .as_str() - .unwrap() - .contains("constraint count depends")); + assert_eq!(fields["num_vars"]["relation"], "unavailable"); assert!(fields["num_vars"]["reason"] .as_str() .unwrap() - .contains("has no symbolic size transform")); + .contains("variable exponent unsupported")); } #[test] diff --git a/src/models/formula/circuit.rs b/src/models/formula/circuit.rs index 65905e22a..77c109847 100644 --- a/src/models/formula/circuit.rs +++ b/src/models/formula/circuit.rs @@ -112,6 +112,17 @@ impl BooleanExpr { } } + /// Return the number of nodes in this expression tree. + pub fn num_nodes(&self) -> usize { + match &self.op { + BooleanOp::Var(_) | BooleanOp::Const(_) => 1, + BooleanOp::Not(inner) => 1 + inner.num_nodes(), + BooleanOp::And(args) | BooleanOp::Or(args) | BooleanOp::Xor(args) => { + 1 + args.iter().map(BooleanExpr::num_nodes).sum::() + } + } + } + /// Evaluate the expression given variable assignments. pub fn evaluate(&self, assignments: &HashMap) -> bool { match &self.op { @@ -188,6 +199,22 @@ impl Circuit { pub fn num_assignments(&self) -> usize { self.assignments.len() } + + /// Return the total number of Boolean expression nodes. + pub fn num_expression_nodes(&self) -> usize { + self.assignments + .iter() + .map(|assignment| assignment.expr.num_nodes()) + .sum() + } + + /// Return the total number of assignment outputs. + pub fn num_assignment_outputs(&self) -> usize { + self.assignments + .iter() + .map(|assignment| assignment.outputs.len()) + .sum() + } } /// The Circuit SAT problem. @@ -251,6 +278,16 @@ impl CircuitSAT { self.circuit.num_assignments() } + /// Return the total number of Boolean expression nodes. + pub fn num_expression_nodes(&self) -> usize { + self.circuit.num_expression_nodes() + } + + /// Return the total number of assignment outputs. + pub fn num_assignment_outputs(&self) -> usize { + self.circuit.num_assignment_outputs() + } + /// Check if a configuration is a valid satisfying assignment. pub fn is_valid_solution(&self, config: &[usize]) -> bool { self.count_satisfied(config) == self.circuit.num_assignments() diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index 15f726e6b..2b08f8522 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -147,6 +147,11 @@ impl PrecedenceConstrainedScheduling { pub fn precedences(&self) -> &[(usize, usize)] { &self.precedences } + + /// Return the number of precedence relations. + pub fn num_precedences(&self) -> usize { + self.precedences.len() + } } impl Problem for PrecedenceConstrainedScheduling { diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index 53d4d4462..f4f5ee637 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -145,6 +145,21 @@ impl SequencingWithinIntervals { pub fn num_tasks(&self) -> usize { self.release_times.len() } + + /// Return the total number of feasible start slots across all tasks. + pub fn num_start_slots(&self) -> usize { + self.release_times + .iter() + .zip(&self.deadlines) + .zip(&self.lengths) + .map(|((&release, &deadline), &length)| deadline - release - length + 1) + .fold(0usize, |total, slots| { + let slots = usize::try_from(slots).expect("start-slot count does not fit usize"); + total + .checked_add(slots) + .expect("total start-slot count overflow") + }) + } } impl Problem for SequencingWithinIntervals { diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index bcf0c17b3..efa2258e5 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -38,10 +38,7 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { #[reduction( size = exact { num_vars = "num_vertices * num_vertices + num_arcs * num_vertices + num_arcs + 2 * num_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "2 * num_vertices^2 + 3 * num_arcs * num_vertices + 6 * num_vertices + 2 * num_arcs + 1", } )] impl ReduceTo> for AcyclicPartition { diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 8ff95cd64..174b17fea 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -35,12 +35,9 @@ impl ReductionResult for ReductionBCBSToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_vertices^2 + 2", } )] impl ReduceTo> for BalancedCompleteBipartiteSubgraph { diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 29bc741df..3628b1f92 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -35,12 +35,9 @@ impl ReductionResult for ReductionBiconnAugToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_potential_edges + 2 * num_vertices * num_vertices * (num_edges + num_potential_edges)", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_potential_edges + 1 + 4 * num_vertices * (num_edges + num_potential_edges) + num_vertices^2 * (2 * num_edges + 4 * num_potential_edges + num_vertices)", } )] impl ReduceTo> for BiconnectivityAugmentation { diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 8603df195..cc02a5627 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -40,10 +40,7 @@ impl ReductionResult for ReductionBCSFToILP { #[reduction( size = exact { num_vars = "3 * num_vertices * max_components + 2 * max_components + 2 * num_edges * max_components", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_vertices + 5 * max_components + 6 * num_vertices * max_components + 6 * num_edges * max_components", } )] impl ReduceTo> for BoundedComponentSpanningForest { diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index 60719502b..4ba2c8c82 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -178,12 +178,9 @@ impl ILPBuilder { } #[reduction( - size = exact { - num_vars = "num_variables + num_assignments", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_variables + num_expression_nodes", + num_constraints = "5 * num_expression_nodes + num_assignment_outputs", } )] impl ReduceTo> for CircuitSAT { diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index d5ee2476b..a2afdd9bf 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -414,9 +414,9 @@ where } #[reduction( - size = unavailable { - num_spins = "the exact gadget size depends on Boolean expression node counts and operator kinds absent from the source size vector", - num_interactions = "the exact coupling count depends on Boolean expression node counts and operator kinds absent from the source size vector", + size = upper_bound { + num_spins = "num_variables + 2 * num_expression_nodes", + num_interactions = "6 * num_expression_nodes + num_assignment_outputs", } )] impl ReduceTo> for CircuitSAT { diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index 82519a0f5..1941841f9 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -42,12 +42,9 @@ impl ReductionResult for ReductionClusteringToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_elements * num_clusters", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_elements + num_elements * (num_elements - 1) / 2 * num_clusters", } )] impl ReduceTo> for Clustering { diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index d792a07b7..24a110296 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -101,9 +101,9 @@ fn reduce_kcoloring_to_ilp( // Register only the KN variant in the reduction graph #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vertices^2", + num_constraints = "num_vertices + num_vertices * num_edges", } )] impl ReduceTo> for KColoring { diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index d9205a4f4..183adc5b8 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -35,12 +35,9 @@ impl ReductionResult for ReductionCBMToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_cols * num_cols + num_rows * num_cols + num_rows * num_cols", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_cols + num_cols + num_rows * num_cols + num_rows + num_rows * num_cols + 1", } )] impl ReduceTo> for ConsecutiveBlockMinimization { diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index bc949eee1..138a1bba3 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -36,12 +36,9 @@ impl ReductionResult for ReductionCOSToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_cols + num_cols * bound + 5 * num_rows * bound", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "2 + num_cols + bound + 3 * num_rows + 8 * num_rows * bound", } )] impl ReduceTo> for ConsecutiveOnesSubmatrix { diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 9246d6333..d7c8ccbf8 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -34,10 +34,7 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic } } -#[reduction(size = unavailable { - num_vertices = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", - num_edges = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", -})] +#[reduction(size = upper_bound { num_vertices = "num_vertices", num_edges = "num_edges" })] impl ReduceTo> for Decision> { diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index d5d063dd7..a2eabfeaa 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -48,12 +48,9 @@ impl ReductionResult for ReductionD2CIFToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "2 * num_arcs", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_arcs + 2 * num_vertices + 2", } )] impl ReduceTo> for DirectedTwoCommodityIntegralFlow { diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 2cbf4b091..4780dca28 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -59,12 +59,9 @@ impl ReductionResult for ReductionDCPToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_pairs * 2 * num_edges", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_pairs * num_vertices + num_pairs * num_edges + num_edges + num_vertices", } )] impl ReduceTo> for DisjointConnectingPaths { diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index 4018a9265..0490a5dc7 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -140,9 +140,9 @@ fn compatible_pairs(arcs: &[(usize, usize)]) -> Vec<(usize, usize)> { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "3 * num_arcs + num_arcs * num_arcs", + num_constraints = "5 * num_arcs + 2 * num_arcs * num_arcs + 2", } )] impl ReduceTo> for EulerianPath { diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index 5c857ed78..94a8158bd 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -28,12 +28,10 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { } } -#[reduction( - size = exact { num_variables = "num_sets" }, - unavailable = { - num_equations = "the source size vector does not track per-element incidence degrees", - } -)] +#[reduction(size = upper_bound { + num_variables = "num_sets", + num_equations = "universe_size + 9 * num_sets^2", +})] impl ReduceTo for ExactCoverBy3Sets { type Result = ReductionX3CToAlgebraicEquationsOverGF2; diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index 303e19fa2..450f537f1 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -100,9 +100,9 @@ impl ReductionResult for ReductionFactoringToILP { } } -#[reduction(size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = upper_bound { + num_vars = "num_bits_first * num_bits_second + 2 * num_bits_first + 2 * num_bits_second + 64", + num_constraints = "3 * num_bits_first * num_bits_second + 4 * num_bits_first + 4 * num_bits_second + 193", })] impl ReduceTo> for Factoring { type Result = ReductionFactoringToILP; diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index 60145ab3b..afd87548a 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -78,13 +78,9 @@ impl ReductionResult for ReductionFSSToILP { } } -#[reduction( - size = exact { - num_vars = "num_jobs * (num_jobs - 1) / 2 + num_jobs * num_processors", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = upper_bound { + num_vars = "num_jobs * (num_jobs - 1) / 2 + num_jobs * num_processors", + num_constraints = "num_jobs * (num_jobs - 1) + num_jobs + num_jobs * (num_processors - 1) + num_jobs * (num_jobs - 1) * num_processors + num_jobs", })] impl ReduceTo> for FlowShopScheduling { type Result = ReductionFSSToILP; diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index d883f8860..945b98b10 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -46,9 +46,9 @@ impl ReductionResult for ReductionHamiltonianPathToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vertices^2 + 2 * num_edges * num_vertices", + num_constraints = "2 * num_vertices + 6 * num_edges * num_vertices + num_vertices", } )] impl ReduceTo> for HamiltonianPath { diff --git a/src/rules/highlyconnecteddeletion_ilp.rs b/src/rules/highlyconnecteddeletion_ilp.rs index b1d6887af..0c16e0044 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -155,7 +155,7 @@ fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { num_constraints = "num_vertices", }, unavailable = { - num_vars = "the exact count is the number of feasible highly connected vertex subsets, a hard structural parameter absent from the source size vector", + num_vars = "the feasible-cluster count depends on graph structure, and its 2^num_vertices upper bound requires a variable exponent unsupported by the size-transform evaluator", } )] impl ReduceTo> for HighlyConnectedDeletion { diff --git a/src/rules/ilp_i32_ilp_bool.rs b/src/rules/ilp_i32_ilp_bool.rs index f2fb3176d..47254321d 100644 --- a/src/rules/ilp_i32_ilp_bool.rs +++ b/src/rules/ilp_i32_ilp_bool.rs @@ -271,7 +271,7 @@ impl ReductionResult for ReductionIntILPToBinaryILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "31 * num_vars", num_constraints = "num_constraints", },)] diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index 756ef3378..16e9a981b 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -41,7 +41,7 @@ impl ReductionResult for ReductionILPToQUBO { #[reduction( size = unavailable { - num_vars = "the exact count depends on source incidence structure or construction branches not represented by registered source fields", + num_vars = "the slack-bit count depends on coefficient magnitudes and right-hand sides absent from the registered source size vector", } )] impl ReduceTo> for ILP { diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 207c14920..5b7e38600 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -33,12 +33,9 @@ impl ReductionResult for ReductionIFHAToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_arcs", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_arcs^2 + num_arcs + num_vertices + 1", } )] impl ReduceTo> for IntegralFlowHomologousArcs { diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index 646f40d4a..be4d0834b 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -35,12 +35,9 @@ impl ReductionResult for ReductionISTToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_vertices * num_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "2 * num_vertices + 2 * (num_vertices - 1) * num_vertices * num_vertices", } )] impl ReduceTo> for IsomorphicSpanningTree { diff --git a/src/rules/kclique_ilp.rs b/src/rules/kclique_ilp.rs index eb11a6709..057b60896 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -50,12 +50,9 @@ impl ReductionResult for ReductionKCliqueToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_vertices^2 + 1", } )] impl ReduceTo> for KClique { diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index 9333d2386..c4d997e1a 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -352,10 +352,10 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { } #[reduction( - size = unavailable { - num_tasks = "the exact count uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", - num_processors = "the exact count uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", - d_max = "the exact deadline uses the maximum of literal and clause gadget counts, which is not representable by the size expression language", + size = upper_bound { + num_tasks = "(2 * num_vars + 2 + 6 * num_clauses) * (num_vars + 3)", + num_processors = "2 * num_vars + 2 + 6 * num_clauses", + d_max = "(2 * num_vars + 2 + 6 * num_clauses) * (num_vars + 3)", } )] impl ReduceTo for KSatisfiability { diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index 9589ca16f..756f76346 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -515,10 +515,10 @@ fn exhaustive_alpha_solution(source: &KSatisfiability) -> Option> { } #[reduction( - size = unavailable { - bit_length_a = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", - bit_length_b = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", - bit_length_c = "the exact coefficient bit length depends on the selected prime sequence rather than only clause and variable counts", + size = upper_bound { + bit_length_a = "64 * (4 * num_vars^3 + num_vars + 1)^2 + 6 * num_vars^3 + 5", + bit_length_b = "64 * (4 * num_vars^3 + num_vars + 1)^2 + 6 * num_vars^3 + 5", + bit_length_c = "64 * (4 * num_vars^3 + num_vars + 1)^2 + 10 * num_vars^3 + num_vars + 8", } )] impl ReduceTo for KSatisfiability { diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index 1b1a933f2..cedc14059 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -79,12 +79,10 @@ fn translate_congruence(source: &QuadraticCongruences) -> QuadraticDiophantineEq } #[reduction( - size = exact { + size = upper_bound { bit_length_a = "1", - }, - unavailable = { - bit_length_b = "the exact coefficient bit length depends on constructed prime products and is not determined by clause and variable counts", - bit_length_c = "the exact coefficient bit length depends on constructed prime products and padding and is not determined by clause and variable counts", + bit_length_b = "64 * (4 * num_vars^3 + num_vars + 1)^2 + 6 * num_vars^3 + 5", + bit_length_c = "128 * (4 * num_vars^3 + num_vars + 1)^2 + 20 * num_vars^3 + 2 * num_vars + 15", } )] impl ReduceTo for KSatisfiability { diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 09927fc5a..74d85f7af 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -72,9 +72,7 @@ fn digits_to_integer(digits: &[u8]) -> BigUint { } #[reduction( - size = unavailable { - num_elements = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", - } + size = upper_bound { num_elements = "2 * num_vars + 2 * num_clauses" } )] impl ReduceTo for KSatisfiability { type Result = Reduction3SATToSubsetSum; diff --git a/src/rules/lengthboundeddisjointpaths_ilp.rs b/src/rules/lengthboundeddisjointpaths_ilp.rs index df501d32f..4cd9a622c 100644 --- a/src/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/rules/lengthboundeddisjointpaths_ilp.rs @@ -75,12 +75,9 @@ impl ReductionResult for ReductionLBDPToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "max_paths * 2 * num_edges + max_paths", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "max_paths * num_vertices + max_paths * num_edges + max_paths + num_edges + num_vertices + max_paths", } )] impl ReduceTo> for LengthBoundedDisjointPaths { diff --git a/src/rules/maximumclique_ilp.rs b/src/rules/maximumclique_ilp.rs index ea0cd0b6b..ec1e2b68e 100644 --- a/src/rules/maximumclique_ilp.rs +++ b/src/rules/maximumclique_ilp.rs @@ -46,12 +46,9 @@ impl ReductionResult for ReductionCliqueToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_vertices^2", } )] impl ReduceTo> for MaximumClique { diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index e2f830900..fa9d08b17 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -67,9 +67,9 @@ impl ReductionResult for ReductionMCESToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vertices_1 * num_vertices_2 + num_arcs_1 * num_arcs_2", + num_constraints = "num_vertices_1 + num_vertices_2 + 3 * num_arcs_1 * num_arcs_2", } )] impl ReduceTo> for MaximumCommonEdgeSubgraph { diff --git a/src/rules/maximumindependentset_maximumsetpacking.rs b/src/rules/maximumindependentset_maximumsetpacking.rs index 107af5f7f..f9e68e05d 100644 --- a/src/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/rules/maximumindependentset_maximumsetpacking.rs @@ -41,10 +41,7 @@ where macro_rules! impl_is_to_sp { ($W:ty) => { - #[reduction(size = unavailable { - num_sets = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", - universe_size = "the exact set statistic depends on membership or intersection incidence not represented by registered source fields", - })] + #[reduction(size = upper_bound { num_sets = "num_vertices", universe_size = "num_edges" })] impl ReduceTo> for MaximumIndependentSet { type Result = ReductionISToSP<$W>; @@ -100,10 +97,7 @@ where macro_rules! impl_sp_to_is { ($W:ty) => { - #[reduction(size = unavailable { - num_vertices = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", - num_edges = "the exact graph statistic depends on adjacency, incidence, or reachability structure not represented by registered source fields", - })] + #[reduction(size = upper_bound { num_vertices = "num_sets", num_edges = "num_sets^2" })] impl ReduceTo> for MaximumSetPacking<$W> { type Result = ReductionSPToIS<$W>; diff --git a/src/rules/minimumcapacitatedspanningtree_ilp.rs b/src/rules/minimumcapacitatedspanningtree_ilp.rs index 6a07c4a96..c10222cb8 100644 --- a/src/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/rules/minimumcapacitatedspanningtree_ilp.rs @@ -56,12 +56,9 @@ impl ReductionResult for ReductionMinimumCapacitatedSpanningTreeToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "3 * num_edges", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "5 * num_edges + num_vertices + 1", } )] impl ReduceTo> for MinimumCapacitatedSpanningTree { diff --git a/src/rules/minimumexternalmacrodatacompression_ilp.rs b/src/rules/minimumexternalmacrodatacompression_ilp.rs index 5e72621b4..3317f3543 100644 --- a/src/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/rules/minimumexternalmacrodatacompression_ilp.rs @@ -214,9 +214,9 @@ fn encode_pointer(n: usize, start: usize, len: usize) -> usize { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "string_length * alphabet_size + 2 * string_length + string_length ^ 3", + num_constraints = "string_length + string_length * alphabet_size + string_length + string_length + 1 + string_length ^ 3 * string_length", } )] impl ReduceTo> for MinimumExternalMacroDataCompression { diff --git a/src/rules/minimuminternalmacrodatacompression_ilp.rs b/src/rules/minimuminternalmacrodatacompression_ilp.rs index c1bac1f0f..501cc5a27 100644 --- a/src/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/rules/minimuminternalmacrodatacompression_ilp.rs @@ -160,12 +160,9 @@ impl ReductionResult for ReductionIMDCToILP { } #[reduction( - size = exact { - + size = upper_bound { + num_vars = "string_len + string_len ^ 3", num_constraints = "string_len + 1", - }, - unavailable = { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for MinimumInternalMacroDataCompression { diff --git a/src/rules/minimumsummulticenter_ilp.rs b/src/rules/minimumsummulticenter_ilp.rs index ce826fa91..ebd802e7b 100644 --- a/src/rules/minimumsummulticenter_ilp.rs +++ b/src/rules/minimumsummulticenter_ilp.rs @@ -115,12 +115,9 @@ fn weighted_distances_msmc( } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_vertices + num_vertices^2", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_vertices^2 + 2 * num_vertices + 1", } )] impl ReduceTo> for MinimumSumMulticenter { diff --git a/src/rules/mixedchinesepostman_ilp.rs b/src/rules/mixedchinesepostman_ilp.rs index ee34886a3..22bb78d75 100644 --- a/src/rules/mixedchinesepostman_ilp.rs +++ b/src/rules/mixedchinesepostman_ilp.rs @@ -40,12 +40,9 @@ impl ReductionResult for ReductionMCPToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_edges + 4 * (num_arcs + 2 * num_edges) + 3 * num_vertices + 1", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_edges + 8 * (num_arcs + 2 * num_edges) + 10 * num_vertices + 2", } )] impl ReduceTo> for MixedChinesePostman { diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 91e58dc77..3777471ea 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -63,12 +63,9 @@ impl ReductionResult for ReductionNMTSToILP { } #[reduction( - size = exact { - + size = upper_bound { + num_vars = "num_pairs * num_pairs * num_pairs", num_constraints = "3 * num_pairs", - }, - unavailable = { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for NumericalMatchingWithTargetSums { diff --git a/src/rules/paintshop_ilp.rs b/src/rules/paintshop_ilp.rs index f94fce6f3..bf3372c43 100644 --- a/src/rules/paintshop_ilp.rs +++ b/src/rules/paintshop_ilp.rs @@ -35,12 +35,9 @@ impl ReductionResult for ReductionPaintShopToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_cars + 2 * num_sequence", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_sequence + 2 * num_sequence", } )] impl ReduceTo> for PaintShop { diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index 7ac4bb1ae..61dab71fc 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -59,9 +59,9 @@ impl ReductionResult for ReductionPIPL2ToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vertices^2 + num_edges * num_vertices", + num_constraints = "num_vertices^2 + num_edges * num_vertices + num_vertices", } )] impl ReduceTo> for PartitionIntoPathsOfLength2 { diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index 39f431d63..6c81cda2c 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -53,9 +53,9 @@ impl ReductionResult for ReductionPITToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vertices^2", + num_constraints = "num_vertices^2 * num_vertices", } )] impl ReduceTo> for PartitionIntoTriangles { diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index bd7721e52..1c4b1a71d 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -56,10 +56,7 @@ impl ReductionResult for ReductionPCSToILP { #[reduction( size = exact { num_vars = "num_tasks * deadline", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_tasks + deadline + num_precedences", } )] impl ReduceTo> for PrecedenceConstrainedScheduling { diff --git a/src/rules/quadraticassignment_ilp.rs b/src/rules/quadraticassignment_ilp.rs index 8da7639f2..837aafb80 100644 --- a/src/rules/quadraticassignment_ilp.rs +++ b/src/rules/quadraticassignment_ilp.rs @@ -50,9 +50,9 @@ impl ReductionResult for ReductionQAPToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_facilities * num_locations + num_facilities^2 * num_locations^2", + num_constraints = "num_facilities + num_locations + 3 * num_facilities^2 * num_locations^2", } )] impl ReduceTo> for QuadraticAssignment { diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 5504c0ef4..577732910 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -44,9 +44,9 @@ impl ReductionResult for ReductionQUBOToILP { } #[reduction( - size = unavailable { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_vars^2 + num_vars", + num_constraints = "3 * num_vars^2", } )] impl ReduceTo> for QUBO { diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index 2246ae644..9cf11e5b2 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -32,12 +32,9 @@ impl ReductionResult for ReductionRPCToILP { } #[reduction( - size = exact { - + size = upper_bound { + num_vars = "num_rows^2 * num_cols^2", num_constraints = "num_rows * num_cols + 1", - }, - unavailable = { - num_vars = "the exact variable count depends on auxiliary, slack, or feasible-structure counts absent from the registered source size vector", } )] impl ReduceTo> for RectilinearPictureCompression { diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index 5a3947e23..8071bb353 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -83,12 +83,9 @@ impl ReductionResult for ReductionRTSAToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "universe_size * universe_size * universe_size + 2 * universe_size * universe_size + universe_size + num_subsets * (universe_size * universe_size + 2 * universe_size + 3)", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "4 * universe_size^3 + 6 * universe_size^2 + 5 * universe_size + 2 + num_subsets * (2 * universe_size^3 + 5 * universe_size^2 + 8 * universe_size + 8)", } )] impl ReduceTo> for RootedTreeStorageAssignment { diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index 6eb85a671..ece9df5b6 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -42,9 +42,9 @@ impl ReductionResult for ReductionSATToCircuit { } #[reduction( - size = unavailable { - num_variables = "the exact circuit variable count depends on used-variable and clause-expression incidence absent from the source size vector", - num_assignments = "the exact assignment count depends on the number of source variables unused by every clause", + size = upper_bound { + num_variables = "2 * num_vars + num_clauses + 1", + num_assignments = "num_vars + num_clauses + 2", } )] impl ReduceTo for Satisfiability { diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 71d789bec..d63ca6022 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -299,9 +299,9 @@ impl ReductionSATToColoring { } #[reduction( - size = unavailable { - num_vertices = "the exact graph size depends on clause-length-specific coloring gadgets absent from the source size vector", - num_edges = "the exact graph size depends on clause-length-specific coloring gadgets absent from the source size vector", + size = exact { + num_vertices = "2 * num_vars + 3 + 5 * (num_literals - num_clauses)", + num_edges = "3 + 3 * num_vars + 11 * num_literals - 9 * num_clauses", } )] impl ReduceTo> for Satisfiability { diff --git a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index 424e78a2b..a2413f8bd 100644 --- a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -45,13 +45,9 @@ impl ReductionResult for ReductionSTMMCCToILP { } } -#[reduction( - size = exact { - num_vars = "num_tasks * num_tasks + 1", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = exact { + num_vars = "num_tasks^2 + 1", + num_constraints = "num_tasks^2 + 3 * num_tasks + num_precedences + 1", })] impl ReduceTo> for SequencingToMinimizeMaximumCumulativeCost { type Result = ReductionSTMMCCToILP; diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index df58c12f1..ca991fd39 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -65,13 +65,9 @@ impl ReductionResult for ReductionSTMWTToILP { } } -#[reduction( - size = exact { - num_vars = "num_tasks * (num_tasks - 1) / 2 + 2 * num_tasks", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = upper_bound { + num_vars = "num_tasks^2 + 2 * num_tasks", + num_constraints = "2 * num_tasks^2 + 3 * num_tasks + 1", })] impl ReduceTo> for SequencingToMinimizeWeightedTardiness { type Result = ReductionSTMWTToILP; diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index f6ef76785..c156e01e9 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -50,13 +50,9 @@ impl ReductionResult for ReductionSWDSTToILP { } } -#[reduction( - size = exact { - num_vars = "num_tasks * num_tasks + (num_tasks - 1) + num_tasks * (num_tasks - 1)", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = upper_bound { + num_vars = "2 * num_tasks^2 + num_tasks", + num_constraints = "2 * num_tasks + num_tasks^2 * (num_tasks - 1) + 3 * num_tasks * (num_tasks - 1) + num_tasks * num_tasks", })] impl ReduceTo> for SequencingWithDeadlinesAndSetUpTimes { type Result = ReductionSWDSTToILP; diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index ff9b72b3a..4bbc4e026 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -69,12 +69,9 @@ impl ReductionResult for ReductionSWIToILP { } #[reduction( - size = exact { - num_vars = "num_tasks^2", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "num_start_slots", + num_constraints = "num_start_slots^2 + num_tasks", } )] impl ReduceTo> for SequencingWithinIntervals { diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 1551368f6..80d641735 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -67,13 +67,9 @@ impl ReductionResult for ReductionSWRTDToILP { } } -#[reduction( - size = exact { - num_vars = "num_tasks * time_horizon", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", +#[reduction(size = upper_bound { + num_vars = "num_tasks * time_horizon", + num_constraints = "num_tasks * time_horizon + num_tasks + time_horizon", })] impl ReduceTo> for SequencingWithReleaseTimesAndDeadlines { type Result = ReductionSWRTDToILP; diff --git a/src/rules/shortestcommonsupersequence_ilp.rs b/src/rules/shortestcommonsupersequence_ilp.rs index 114f4112f..861c0a995 100644 --- a/src/rules/shortestcommonsupersequence_ilp.rs +++ b/src/rules/shortestcommonsupersequence_ilp.rs @@ -43,12 +43,9 @@ impl ReductionResult for ReductionSCSToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "max_length * (alphabet_size + 1) + total_length * max_length", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "max_length + total_length + total_length * max_length + total_length + max_length", } )] impl ReduceTo> for ShortestCommonSupersequence { diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index 6e22b034a..fd0422e0f 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -38,12 +38,9 @@ impl ReductionResult for ReductionSMCToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_rows * bound_k", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_rows + num_rows * num_rows * bound_k * bound_k", } )] impl ReduceTo> for SparseMatrixCompression { diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 6c4b7071b..f70656d8c 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -108,12 +108,9 @@ impl ReductionResult for ReductionSTSCToILP { } #[reduction( - size = exact { - num_vars = "(bound + 1) * source_length * source_length + (bound + 1) * source_length + 2 * bound * source_length", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + size = upper_bound { + num_vars = "(bound + 1) * source_length^2 + (bound + 1) * source_length + 2 * bound * source_length + bound", + num_constraints = "4 * bound * source_length^3 + 2 * bound * source_length^2 + source_length^2 + 6 * bound * source_length + 5 * source_length + bound", } )] impl ReduceTo> for StringToStringCorrection { diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index 98d170c58..eb99672c3 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -50,12 +50,9 @@ impl ReductionResult for ReductionSubIsoToILP { } #[reduction( - size = exact { + size = upper_bound { num_vars = "num_pattern_vertices * num_host_vertices", - - }, - unavailable = { - num_constraints = "the exact constraint count depends on generated constraint families or incidence statistics absent from the registered source size vector", + num_constraints = "num_pattern_vertices + num_host_vertices + num_pattern_edges * num_host_vertices^2", } )] impl ReduceTo> for SubgraphIsomorphism { diff --git a/src/unit_tests/models/formula/circuit.rs b/src/unit_tests/models/formula/circuit.rs index 137829ba9..e5f1c434e 100644 --- a/src/unit_tests/models/formula/circuit.rs +++ b/src/unit_tests/models/formula/circuit.rs @@ -260,6 +260,8 @@ fn test_size_getters() { )]); let problem = CircuitSAT::new(circuit); assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_expression_nodes(), 3); + assert_eq!(problem.num_assignment_outputs(), 1); } #[test] diff --git a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs index 1d42716ad..0e35860c8 100644 --- a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs @@ -111,6 +111,7 @@ fn test_precedence_constrained_scheduling_serialization() { assert_eq!(restored.num_processors(), problem.num_processors()); assert_eq!(restored.deadline(), problem.deadline()); assert_eq!(restored.precedences(), problem.precedences()); + assert_eq!(restored.num_precedences(), problem.num_precedences()); } #[test] diff --git a/src/unit_tests/models/misc/sequencing_within_intervals.rs b/src/unit_tests/models/misc/sequencing_within_intervals.rs index 71410517c..52ed3ee7d 100644 --- a/src/unit_tests/models/misc/sequencing_within_intervals.rs +++ b/src/unit_tests/models/misc/sequencing_within_intervals.rs @@ -124,6 +124,7 @@ fn test_sequencing_within_intervals_no_solution() { #[test] fn test_sequencing_within_intervals_serialization() { let problem = SequencingWithinIntervals::new(vec![0, 2, 4], vec![3, 5, 7], vec![2, 2, 2]); + assert_eq!(problem.num_start_slots(), 6); let json = serde_json::to_value(&problem).unwrap(); let restored: SequencingWithinIntervals = serde_json::from_value(json).unwrap(); assert_eq!(restored.release_times(), problem.release_times()); @@ -135,6 +136,7 @@ fn test_sequencing_within_intervals_serialization() { fn test_sequencing_within_intervals_empty() { let problem = SequencingWithinIntervals::new(vec![], vec![], vec![]); assert_eq!(problem.num_tasks(), 0); + assert_eq!(problem.num_start_slots(), 0); assert_eq!(problem.dims(), Vec::::new()); assert!(problem.evaluate(&[])); } diff --git a/src/unit_tests/symbolic_size_contracts.rs b/src/unit_tests/symbolic_size_contracts.rs index e5fd0123a..7e1310d4a 100644 --- a/src/unit_tests/symbolic_size_contracts.rs +++ b/src/unit_tests/symbolic_size_contracts.rs @@ -80,3 +80,68 @@ fn every_registered_rule_has_one_valid_size_contract() { assert!(contract.transform().is_some() || !contract.unavailable().is_empty()); } } + +#[cfg(feature = "example-db")] +#[test] +fn canonical_examples_satisfy_upper_bound_size_contracts() { + use crate::size::EvaluatedSize; + + for spec in crate::rules::canonical_rule_example_specs() { + let example = (spec.build)(); + let source = crate::registry::load_dyn( + &example.source.problem, + &example.source.variant, + example.source.instance.clone(), + ) + .unwrap(); + let target = crate::registry::load_dyn( + &example.target.problem, + &example.target.variant, + example.target.instance.clone(), + ) + .unwrap(); + let graph = ReductionGraph::new(); + let entry = graph + .find_entry( + &example.source.problem, + &example.source.variant, + &example.target.problem, + &example.target.variant, + ) + .unwrap_or_else(|| panic!("{} has no registered direct edge", spec.id)); + let Ok(contract) = entry.size_contract else { + continue; + }; + let Some(transform) = contract.transform() else { + continue; + }; + if transform.relation() != SizeRelation::UpperBound { + continue; + } + let source_size = ReductionGraph::compute_problem_size( + &example.source.problem, + &example.source.variant, + source.as_any(), + ); + let target_size = ReductionGraph::compute_problem_size( + &example.target.problem, + &example.target.variant, + target.as_any(), + ); + let predicted = transform + .evaluate(&EvaluatedSize::from_problem_size(&source_size)) + .unwrap_or_else(|error| panic!("{}: {error}", spec.id)); + + for (field, actual) in target_size.components { + let Some(predicted_value) = predicted.values().get(&field) else { + continue; + }; + let actual = BigUint::from(actual); + assert!( + predicted_value >= &actual, + "{}: target field {field}: predicted {predicted_value}, actual {actual}", + spec.id + ); + } + } +}