diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 2fb789602..d50be98cd 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -38,6 +38,30 @@ # Attributes (all optional unless noted): # # Per-field: +# description – human-readable meaning of the field. Emitted into +# cuopt_mcp_schema.json as the JSON Schema +# `description`, which is what an MCP client shows a +# model deciding whether to set the field. Consumed +# for settings sections only. +# default – what the field does when omitted, written as a string +# describing the C++ member initializer (e.g. "1e-4", +# "-1 (automatic)"). Appended to the emitted +# description as "Default: ...". Documentation only — +# the generator neither derives nor validates it, and +# it does not affect the wire format. Take the value +# from the C++ struct, not from docs/: the two are +# known to disagree. +# param_name – the CUOPT_* string parameter this field corresponds +# to, when it differs from the field name (the field +# name is the *proto* name; MIP diverges heavily — +# relative_mip_gap vs mip_relative_gap, mir_cuts vs +# mip_mixed_integer_rounding_cuts). Emitted into the +# MCP schema as "x-parameter-name". Set it explicitly +# to `null` for a field with no CUOPT_* constant at +# all: it stays on the wire but is dropped from the +# MCP schema, since set_parameter would reject it. +# python/cuopt_mcp/tests/test_parameter_names.py +# asserts every advertised name exists in constants.h. # type – proto wire type (default: double for scalars, repeated double for arrays) # field_num – proto message field number (see "Field numbers" above) # array_id – enum value for ArrayFieldId or ResultFieldId @@ -426,32 +450,74 @@ pdlp_settings: # Termination tolerances (nested: settings.tolerances.) - tolerances: - absolute_gap_tolerance: + description: >- + Absolute term in PDLP's duality gap check: + duality_gap < absolute_gap_tolerance + relative_gap_tolerance * + (|primal_objective| + |dual_objective|). + default: "1e-4" field_num: 1 optional: true - relative_gap_tolerance: + description: >- + Relative term in PDLP's duality gap check; multiplies + (|primal_objective| + |dual_objective|). Significant impact on + accuracy and runtime. + default: "1e-4" field_num: 2 optional: true - primal_infeasible_tolerance: + description: >- + Tolerance used when PDLP declares the problem primal infeasible. + Only consulted when detect_infeasibility is enabled. + default: "1e-10" field_num: 3 optional: true - dual_infeasible_tolerance: + description: >- + Tolerance used when PDLP declares the problem dual infeasible + (unbounded). Only consulted when detect_infeasibility is enabled. + default: "1e-10" field_num: 4 optional: true - absolute_dual_tolerance: + description: >- + Absolute term in PDLP's dual feasibility check: + dual_feasibility < absolute_dual_tolerance + relative_dual_tolerance + * l2_norm(c). + default: "1e-4" field_num: 5 optional: true - relative_dual_tolerance: + description: >- + Relative term in PDLP's dual feasibility check; multiplies the + objective vector L2 norm. + default: "1e-4" field_num: 6 optional: true - absolute_primal_tolerance: + description: >- + Absolute term in PDLP's primal feasibility check: + primal_feasibility < absolute_primal_tolerance + + relative_primal_tolerance * l2_norm(b). + default: "1e-4" field_num: 7 optional: true - relative_primal_tolerance: + description: >- + Relative term in PDLP's primal feasibility check; multiplies the + right-hand-side vector L2 norm. + default: "1e-4" field_num: 8 optional: true # Limits - time_limit: + description: >- + Wall-clock limit in seconds after which the solver stops and returns + the current solution. Checked periodically rather than continuously, + so the solver may run slightly over. When set together with + iteration_limit, the first limit reached wins. + default: "no limit (infinity)" field_num: 9 optional: true - iteration_limit: @@ -460,6 +526,12 @@ pdlp_settings: # (max() <=> -1, preserving 26.04 compatibility) and `optional` adds the # missing piece — an omitted field would otherwise decode to the wire # zero (0), which is `>= 0` and would overwrite the C++ default with 0. + description: >- + Iteration limit after which the solver stops and returns the current + solution. Checked periodically, so the solver may run a few extra + iterations. When set together with time_limit, the first limit reached + wins. + default: "no limit (INT_MAX)" field_num: 10 type: int64 sentinel: max_as_negative_1 @@ -467,13 +539,28 @@ pdlp_settings: # Solver configuration - log_to_console: + description: >- + Whether the solver writes log output to the console. Logs may still be + written to a file when this is false. + default: "true" field_num: 11 type: bool optional: true - detect_infeasibility: + param_name: infeasibility_detection + description: >- + Whether PDLP attempts to detect infeasibility. Detection is not always + accurate — some problems reported infeasible converge under a different + tolerance — and costs roughly 3-7% runtime and 10-20% memory. Dual + simplex always detects infeasibility regardless of this setting. + default: "false" field_num: 12 type: bool - strict_infeasibility: + description: >- + When true, PDLP stops if either the current or the average solution is + detected infeasible. When false, both must be detected infeasible. + default: "false" field_num: 13 type: bool - pdlp_solver_mode: @@ -482,13 +569,32 @@ pdlp_settings: # omitted field would silently apply `Stable1` instead of the cuOpt # default. Declared `optional` so the mapper preserves the C++ # default via `has_pdlp_solver_mode()`. + description: >- + Mode under which PDLP operates; changes how the problem is internally + optimized and can drastically change solve time. Stable3 is the best + overall mode from cuOpt experiments; Methodical1 takes slower but fewer + steps at 1.3-1.7x the memory; Fast1 is fastest but converges less + often. There is no way to know upfront which mode suits a given + problem — testing is encouraged. + default: "Stable3" field_num: 14 type: pdlp_solver_mode optional: true - method: + description: >- + Method used to solve the LP. Concurrent runs PDLP, dual simplex and + barrier in parallel. Default accuracy differs per method: PDLP 1e-4 + relative, barrier 1e-8 relative, dual simplex 1e-6 absolute. + default: "Concurrent" field_num: 15 type: lp_method - presolver: + param_name: presolve + description: >- + Which presolver performs presolve reductions: 0 disables presolve, + 1 selects Papilo, 2 selects PSLP. By default LP uses PSLP and MIP uses + Papilo. + default: "Default (PSLP for LP, Papilo for MIP)" field_num: 16 type: int32 from_proto_cast: "presolver_t" @@ -498,44 +604,99 @@ pdlp_settings: # omits this field gets the solver default rather than the proto3 zero # (`false`). See also the matching `optional` markers on # `barrier_iterative_refinement` (pdlp) and `probing` (mip). + description: >- + Whether dual postsolve runs when the Papilo presolver is used on an LP. + Disabling it can improve solve time at the cost of losing access to the + dual solution. Not relevant for MIP. + default: "true" field_num: 17 type: bool optional: true - crossover: + description: >- + Whether PDLP or barrier crosses over to a basic solution after reaching + optimality. PDLP and barrier solutions need not lie at a vertex; + crossover yields a basic solution with n - m variables on their bounds. + Significant impact on accuracy and runtime. + default: "false" field_num: 18 type: bool - num_gpus: + description: >- + Number of GPUs used for the solve. Relevant to LP in concurrent mode + (up to 2 GPUs), where PDLP and barrier run on separate GPUs rather than + sharing one. For distributed PDLP, -1 selects all visible devices. + default: "1" field_num: 19 type: int32 optional: true - per_constraint_residual: + description: >- + Whether PDLP computes primal and dual residuals per constraint instead + of globally. + default: "false" field_num: 20 type: bool - cudss_deterministic: + description: >- + Whether cuDSS runs in deterministic mode. Deterministic mode makes + results reproducible across runs but may be slower. + default: "false" field_num: 21 type: bool - folding: + description: >- + Barrier: whether to fold the LP, reducing problem size by exploiting + symmetry. -1 automatic, 0 disabled, 1 forced. + default: "-1 (automatic)" field_num: 22 type: int32 optional: true - augmented: + description: >- + Barrier: which linear system to solve at each iteration. -1 automatic, + 0 the ADAT system (normal equations), 1 the augmented system. The + augmented system can be more stable, ADAT is often faster. + default: "-1 (automatic)" field_num: 23 type: int32 optional: true - dualize: + description: >- + Barrier: whether presolve dualizes the LP, which can help problems with + inequality constraints that have more constraints than variables. + -1 automatic, 0 disabled, 1 forced. + default: "-1 (automatic)" field_num: 24 type: int32 optional: true - ordering: + description: >- + Barrier: ordering algorithm cuDSS uses for sparse factorizations, which + can significantly affect runtime. -1 automatic, 0 cuDSS default + ordering, 1 AMD (approximate minimum degree). + default: "-1 (automatic)" field_num: 25 type: int32 optional: true - barrier_dual_initial_point: + description: >- + Barrier: how the dual initial point is computed, which affects the + iteration count. -1 automatic, 0 the Lustig-Marsten-Shanno heuristic, + 1 a least-squares problem minimizing the norms of the dual variables + and reduced costs subject to the dual equality constraints. + default: "-1 (automatic)" field_num: 26 type: int32 optional: true - eliminate_dense_columns: + description: >- + Barrier: whether dense columns are eliminated from the constraint + matrix before solving, reducing factorization fill-in at the cost of + extra solves per iteration. Only has an effect when the ADAT (normal + equation) system is solved. + default: "true" field_num: 27 type: bool optional: true @@ -545,6 +706,10 @@ pdlp_settings: # proto3 `optional` so that a client which omits this field preserves # the solver default; without `optional`, the proto3 wire zero (`false`) # would silently overwrite the C++ default. + description: >- + Barrier: whether iterative refinement runs after each barrier solve to + improve solution accuracy (see cpp/src/barrier/barrier.cu). + default: "true" field_num: 31 type: bool optional: true @@ -553,19 +718,49 @@ pdlp_settings: # binding restricts the range to [0.5, 0.9999] with default 0.9; see # solver_settings.cu. No post-decode clamp is applied here for # consistency with the other f_t scalars (tolerances, time_limit, ...). + description: >- + Barrier: scaling factor applied to the primal/dual step size. Must be + strictly less than 1 — 0.9 is conservative, 0.999 aggressive. The + local-solve binding restricts the range to [0.5, 0.9999]; this field is + not clamped after decoding, for consistency with the other float + scalars. + default: "0.9" field_num: 32 optional: true - postsolve_info: + # No user-facing documentation exists for this field yet (it is absent + # from docs/cuopt/source/convex-settings.rst); only the C++ default is + # recorded here. Add a `description:` once the behavior is documented. + default: "-1" field_num: 33 type: int32 optional: true - save_best_primal_so_far: + description: >- + Whether PDLP keeps the best primal solution seen so far. When enabled, + a primal feasible iterate always beats an infeasible one; among + feasible iterates the best primal objective wins; among infeasible + ones the lowest primal residual wins, breaking ties on objective. + default: "false" field_num: 28 type: bool - first_primal_feasible: + description: >- + Whether the solver stops as soon as a primal feasible iterate is found, + without waiting for optimality or dual feasibility. Composable with + per_constraint_residual. + default: "false" field_num: 29 type: bool - pdlp_precision: + description: >- + Precision mode for the PDLP solver. DefaultPrecision uses the problem's + native precision; SinglePrecision runs PDHG in FP32 (half the memory, + roughly 2x faster iterations, possibly more of them); DoublePrecision + forces FP64; MixedPrecision stores the constraint matrix in FP32 for + faster SpMV while keeping vectors and compute in FP64 (convergence + checks still use the FP64 matrix, so memory is not reduced). + default: "DefaultPrecision" field_num: 30 type: int32 from_proto_cast: "pdlp_precision_t" @@ -581,72 +776,148 @@ mip_settings: fields: # Limits - time_limit: + description: >- + Wall-clock limit in seconds after which the solver stops and returns + the best solution found so far. When set together with node_limit or + work_limit, the first limit reached wins. + default: "no limit (infinity)" field_num: 1 optional: true # Tolerances (nested: settings.tolerances.) - tolerances: - relative_mip_gap: + param_name: mip_relative_gap + description: >- + Relative gap at which the solve terminates: + abs(best_objective - dual_bound) / abs(best_objective). The gap is + zero when both are zero, and infinite when only the best objective + is zero. + default: "1e-4" field_num: 2 optional: true - absolute_mip_gap: + param_name: mip_absolute_gap + description: >- + Absolute gap at which the solve terminates: best_objective - + dual_bound when minimizing, dual_bound - best_objective when + maximizing. + default: "1e-10" field_num: 3 optional: true - integrality_tolerance: + param_name: mip_integrality_tolerance + description: >- + How close to an integer a variable must be to count as integral. + default: "1e-5" field_num: 4 optional: true - absolute_tolerance: + param_name: mip_absolute_tolerance + description: MIP absolute tolerance. + default: "1e-6" field_num: 5 optional: true - relative_tolerance: + param_name: mip_relative_tolerance + description: MIP relative tolerance. + default: "1e-12" field_num: 6 optional: true - presolve_absolute_tolerance: + # Not settable through cuOpt's string parameter API (no CUOPT_* + # constant in constants.h), so it is omitted from the MCP schema. + param_name: null + # No user-facing documentation exists for this field yet (it is absent + # from docs/cuopt/source/mip-settings.rst); only the C++ default is + # recorded here. Add a `description:` once the behavior is documented. + default: "1e-6" field_num: 7 optional: true # Solver configuration - log_to_console: + description: >- + Whether the solver writes log output to the console. Logs may still be + written to a file when this is false. + default: "true" field_num: 8 type: bool optional: true - heuristics_only: + param_name: mip_heuristics_only + description: >- + When true only the GPU heuristics run, improving the primal bound + alone. When false both GPU and CPU are used and the dual bound is + improved on the CPU. + default: "false" field_num: 9 type: bool - num_cpu_threads: + description: >- + Number of CPU threads used by the MIP solver. Lower values cap cuOpt's + CPU footprint; higher values speed up the CPU-parallel parts. -1 + derives the count from the number of CPU cores. + default: "-1 (automatic)" field_num: 10 type: int32 optional: true - num_gpus: + description: Number of GPUs used for the solve. + default: "1" field_num: 11 type: int32 optional: true - presolver: + param_name: presolve + description: >- + Which presolver performs presolve reductions: 0 disables presolve, + 1 selects Papilo, 2 selects PSLP. MIP uses Papilo by default. + default: "Default (Papilo for MIP)" field_num: 12 type: int32 from_proto_cast: "presolver_t" optional: true - mip_scaling: + description: >- + Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied + but not to the objective. + default: "2 (no objective scaling)" field_num: 13 type: int32 optional: true - symmetry: + param_name: mip_symmetry # Symmetry-detection level (dejavu). Valid: -1 (default), 0 (off), # 1 (orbital fixing), 2 (orbital fixing + lexical reduction). The # mapper clamps out-of-range values to -1 to match the local-solve # range check in cpp/src/math_optimization/solver_settings.cu. + description: >- + Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital + fixing, 2 orbital fixing plus lexical reduction. + default: "-1 (automatic)" field_num: 33 type: int32 optional: true # Additional limits - work_limit: + description: >- + Limit in work units — a machine-independent measure of solver effort — + after which the solver stops and returns the current solution. When set + together with time_limit or node_limit, the first limit reached wins. + default: "no limit (infinity)" field_num: 14 optional: true - node_limit: # C++ default `max()`. See iteration_limit for the rationale on # composing `sentinel` with `optional` (sentinel for the explicit # wire-encoded "default" value, optional for the omitted-field case). + description: >- + Maximum number of branch-and-bound nodes explored before the solver + stops and returns the best feasible solution found, if any. When set + together with time_limit, the first limit reached wins. + default: "no limit (INT_MAX)" field_num: 15 type: int32 sentinel: max_as_negative_1 @@ -654,80 +925,181 @@ mip_settings: # Branching - reliability_branching: + param_name: mip_reliability_branching + description: >- + Reliability branching mode. -1 automatic (the solver picks whether to + use it and with what factor), 0 disabled, k > 0 enables it and treats a + variable as reliable once it has been branched on k times. + default: "-1 (automatic)" field_num: 16 type: int32 optional: true - mip_batch_pdlp_strong_branching: + description: >- + Whether strong branching at the root evaluates candidates with a single + batched PDLP solve instead of solving them in parallel with dual + simplex, which can cut strong-branching time when dual simplex + struggles. 0 disabled, 1 enabled. + default: "0 (disabled)" field_num: 17 type: int32 - mip_batch_pdlp_reliability_branching: + description: >- + Whether reliability-branching candidates are evaluated simultaneously + with a single batched PDLP solve. 0 disabled, 1 enabled. + default: "0 (disabled)" field_num: 32 type: int32 - strong_branching_simplex_iteration_limit: + param_name: mip_strong_branching_simplex_iteration_limit + description: >- + Maximum simplex iterations per candidate during strong branching. + Lowering it speeds up strong branching at the cost of less accurate + candidate evaluations. + default: "-1 (automatic)" field_num: 30 type: int32 optional: true # Cut configuration + # The individual cut-family toggles (mir_cuts, mixed_integer_gomory_cuts, + # knapsack_cuts, clique_cuts, zero_half_cuts, implied_bound_cuts, + # strong_chvatal_gomory_cuts) share the same tri-state contract: + # -1 automatic, 0 disabled, 1 enabled. - max_cut_passes: + param_name: mip_cut_passes + description: >- + Maximum number of cut passes to run. 0 disables cuts entirely; larger + values perform more passes. + default: "10" field_num: 18 type: int32 optional: true - mir_cuts: + param_name: mip_mixed_integer_rounding_cuts + description: >- + Whether mixed-integer rounding cuts are used. -1 automatic (the solver + decides from problem characteristics), 0 disabled, 1 enabled. + default: "-1 (automatic)" field_num: 19 type: int32 optional: true - mixed_integer_gomory_cuts: + param_name: mip_mixed_integer_gomory_cuts + description: >- + Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, + 1 enabled. + default: "-1 (automatic)" field_num: 20 type: int32 optional: true - knapsack_cuts: + param_name: mip_knapsack_cuts + description: >- + Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. + default: "-1 (automatic)" field_num: 21 type: int32 optional: true - clique_cuts: + param_name: mip_clique_cuts + description: >- + Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. + default: "-1 (automatic)" field_num: 22 type: int32 optional: true - zero_half_cuts: + param_name: mip_zero_half_cuts + description: >- + Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. + default: "-1 (automatic)" field_num: 52 type: int32 optional: true - implied_bound_cuts: + param_name: mip_implied_bound_cuts + description: >- + Whether implied bound cuts are used. -1 automatic, 0 disabled, + 1 enabled. + default: "-1 (automatic)" field_num: 31 type: int32 optional: true - strong_chvatal_gomory_cuts: + param_name: mip_strong_chvatal_gomory_cuts + description: >- + Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, + 1 enabled. + default: "-1 (automatic)" field_num: 23 type: int32 optional: true - reduced_cost_strengthening: + param_name: mip_reduced_cost_strengthening + description: >- + Whether integer feasible solutions are used to strengthen integer + variable bounds. -1 automatic, 0 disabled, 1 during the root cut + passes, 2 during the root cut passes and after strong branching. + default: "-1 (automatic)" field_num: 24 type: int32 optional: true - cut_change_threshold: + param_name: mip_cut_change_threshold + description: >- + Required improvement in the dual bound per cut pass. Larger values + demand significant improvement each pass; -1 lets cut passes continue + even without improvement. + default: "-1 (no threshold)" field_num: 25 optional: true - cut_min_orthogonality: + param_name: mip_cut_min_orthogonality + description: >- + Minimum orthogonality a cut needs to be added to the LP relaxation. + Values near 1 require cuts to be nearly orthogonal to each other; + values near 0 admit more cuts. + default: "0.5" field_num: 26 optional: true # Determinism and reproducibility - determinism_mode: + param_name: mip_determinism_mode + description: >- + 0 opportunistic — results may vary between runs due to parallelism. + 1 deterministic — improves reproducibility across runs with the same + thread count. Deterministic mode is experimental and does not yet + guarantee fully deterministic results in every scenario. + default: "0 (opportunistic)" field_num: 27 type: int32 - seed: + param_name: random_seed + description: >- + Random seed. A fixed seed gives reproducible results when running in + deterministic mode. + default: "-1 (chosen automatically)" field_num: 28 type: int32 optional: true # Presolve sub-steps - probing: + param_name: mip_probing # C++ default is `true`; declared as proto3 `optional` so that a client # which omits this field preserves the solver default. Without # `optional`, the proto3 wire zero (`false`) would silently overwrite # the C++ default. See also `dual_postsolve` and # `barrier_iterative_refinement` in pdlp_settings. + description: >- + Whether the probing-cache step of MIP presolve runs. Probing evaluates + variable fixings to discover implications later used by branch-and-bound + and the rounding heuristics. Only meaningful when presolve is otherwise + enabled — presolver=0 disables the whole pipeline. Probing is also + skipped in deterministic mode and on LP-only solves. + default: "true" field_num: 29 type: bool optional: true @@ -737,6 +1109,15 @@ mip_settings: # cannot derive a finite UB. Local-solve binding restricts the range to # [1.0, +inf] with default 1e10; see solver_settings.cu. - semi_continuous_big_m: + param_name: mip_semi_continuous_big_m + description: >- + Big-M coefficient used when linearizing semi-continuous variable + constraints (such a variable is either zero or lies in + [lower_bound, upper_bound]). Should be at least as large as the upper + bound of any semi-continuous variable in the problem. Serves as the + fallback upper bound when bounds-strengthening cannot derive a finite + one. The local-solve binding restricts the range to [1.0, +inf]. + default: "1e10" field_num: 34 optional: true @@ -746,12 +1127,21 @@ mip_settings: # All fields carry non-zero defaults on the C++ side, so they are declared # `optional` in the proto: a client that omits any of them gets the C++ # default rather than the proto3 wire zero (0). + # These are internal tuning knobs with no user-facing documentation page; + # the descriptions below are taken from the declaring comments in + # heuristics_hyper_params.hpp rather than from docs/. - heuristic_params: - population_size: + param_name: mip_hyper_heuristic_population_size + description: Maximum number of solutions held in the solution pool. + default: "32" field_num: 35 type: int32 optional: true - num_cpufj_threads: + param_name: mip_hyper_heuristic_num_cpufj_threads + description: Number of parallel CPU Feasibility Jump climbers. + default: "8" field_num: 36 type: int32 optional: true @@ -759,54 +1149,111 @@ mip_settings: # presolve stopped taking a wall budget. Do not reuse: an older client # still sends them on those numbers. - root_lp_time_ratio: + param_name: mip_hyper_heuristic_root_lp_time_ratio + description: Fraction of the total time budget given to the root LP. + default: "0.1" field_num: 39 optional: true - root_lp_max_time: + param_name: mip_hyper_heuristic_root_lp_max_time + description: Hard cap in seconds on the root LP solve. + default: "15.0" field_num: 40 optional: true - rins_time_limit: + param_name: mip_hyper_heuristic_rins_time_limit + description: Per-call time budget in seconds for the RINS sub-MIP. + default: "3.0" field_num: 41 optional: true - rins_max_time_limit: + param_name: mip_hyper_heuristic_rins_max_time_limit + description: >- + Ceiling in seconds on the adaptive RINS time budget. + default: "20.0" field_num: 42 optional: true - rins_fix_rate: + param_name: mip_hyper_heuristic_rins_fix_rate + description: Fraction of variables RINS fixes before solving the sub-MIP. + default: "0.5" field_num: 43 optional: true - stagnation_trigger: + param_name: mip_hyper_heuristic_stagnation_trigger + description: >- + Number of Feasibility Pump loops without improvement before + recombination is triggered. + default: "3" field_num: 44 type: int32 optional: true - max_iterations_without_improvement: + param_name: mip_hyper_heuristic_max_iterations_without_improvement + description: Depth of the diversity step taken after stagnation. + default: "8" field_num: 45 type: int32 optional: true - initial_infeasibility_weight: + param_name: mip_hyper_heuristic_initial_infeasibility_weight + description: Seed value for the constraint violation penalty. + default: "1000.0" field_num: 46 optional: true - n_of_minimums_for_exit: + param_name: mip_hyper_heuristic_n_of_minimums_for_exit + description: >- + Number of local minima after which the Feasibility Jump baseline + exits. + default: "7000" field_num: 47 type: int32 optional: true - enabled_recombiners: + param_name: mip_hyper_heuristic_enabled_recombiners + description: >- + Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility + pump, 4 local search, 8 sub-MIP. + default: "15 (all enabled)" field_num: 48 type: int32 optional: true - cycle_detection_length: + param_name: mip_hyper_heuristic_cycle_detection_length + description: >- + Size of the ring buffer used to detect Feasibility Pump assignment + cycles. + default: "30" field_num: 49 type: int32 optional: true - relaxed_lp_time_limit: + param_name: mip_hyper_heuristic_relaxed_lp_time_limit + description: >- + Base time cap in seconds for relaxed LP solves inside the heuristics. + default: "1.0" field_num: 50 optional: true - related_vars_time_limit: + param_name: mip_hyper_heuristic_related_vars_time_limit + description: >- + Time in seconds allowed for building the related-variable structure. + default: "30.0" field_num: 51 optional: true - presolve_max_rounds: + param_name: mip_hyper_heuristic_presolve_max_rounds + description: >- + Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. + default: "-1 (no override)" field_num: 53 type: int32 optional: true - papilo_probing_max_badgesize: + param_name: mip_hyper_heuristic_papilo_probing_max_badgesize + description: Ceiling on Papilo's probing.minbadgesize. + default: "-1 (no override)" field_num: 54 type: int32 optional: true diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index b7088a677..bcf891c66 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -17,6 +17,7 @@ """ import argparse +import json import os import re import sys @@ -334,6 +335,131 @@ def _array_wire_type_comment(f): return f"raw bytes ({size} B/elem)" +_JSON_SCHEMA_TYPES = { + "double": "number", + "float": "number", + "int32": "integer", + "int64": "integer", + "uint32": "integer", + "uint64": "integer", + "bool": "boolean", + "string": "string", +} + + +def _json_schema_property(registry, f): + """Render one settings field as a JSON Schema property. + + Used by the MCP tool-input schema (see generate_mcp_schema). Enums + become string enums keyed by their proto value names so a model emits + `"Stable3"` rather than a magic integer. + """ + ftype = f.get("type", "double") + prop = {} + edef = _lookup_enum(registry, ftype) + if edef is not None and "values" in edef: + # Proto value names, not C++ names, so the schema and the wire agree + # on the spelling a client sends. + prefix = edef.get("proto_prefix", "") + named = { + _proto_enum_value_name(cpp_name, prefix): num + for cpp_name, num in parse_enum_values(edef["values"]) + } + prop["type"] = "string" + prop["enum"] = list(named) + # cuOpt's string parameter interface takes the integer for an enum + # setting, not its name. Callers show the name to a user and send + # the number; emitting the mapping keeps that translation derived + # from the registry instead of hand-written in each client. + prop["x-enum-values"] = named + else: + prop["type"] = _JSON_SCHEMA_TYPES.get(ftype, "string") + + description = f.get("description") + if description: + prop["description"] = " ".join(str(description).split()) + default = f.get("default") + if default is not None: + # Rendered into the description rather than JSON Schema `default`: + # the registry stores prose ("-1 (automatic)", "no limit (INT_MAX)"), + # not a typed value, and a wrong-typed `default` misleads a model + # more than no `default` does. + note = f"Default: {default}." + prop["description"] = ( + f"{prop['description']} {note}" if description else note + ) + # The registry field name is the proto field name, which is not always + # the CUOPT_* string parameter a client passes to set_parameter (MIP + # diverges heavily: relative_mip_gap vs mip_relative_gap, mir_cuts vs + # mip_mixed_integer_rounding_cuts). Carry the real name so no client has + # to rediscover the mapping. + param_name = f.get("param_name") + if param_name: + prop["x-parameter-name"] = param_name + if f.get("sentinel"): + # The wire encoding (e.g. max() <=> -1) is an implementation detail. + # A model must express "no limit" by omitting the field, never by + # sending the reserved value. + prop["description"] = ( + prop.get("description", "") + " Omit for the default limit." + ).strip() + return prop + + +def generate_mcp_schema(registry): + """Build the MCP tool-input JSON Schema for the solver settings. + + Emitted as a generated artifact so an MCP server never hand-maintains a + second copy of the settings surface: a field added to the registry + reaches the schema in the same commit as the proto, and + ci/verify_grpc_codegen.sh guards the pair. + + Only fields carrying a `field_num` are included — those are exactly the + settings that cross the gRPC wire, which is exactly what a remote MCP + server can set. + """ + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": ( + "AUTO-GENERATED by src/grpc/codegen/generate_conversions.py " + "from field_registry.yaml. DO NOT EDIT — regenerate with " + "./build.sh codegen." + ), + "settings": {}, + } + for section, title in ( + ("pdlp_settings", "PDLPSolverSettings"), + ("mip_settings", "MIPSolverSettings"), + ): + properties = {} + for f in parse_settings_fields(registry[section].get("fields", [])): + if f.get("field_num") is None: + continue + name = f["name"] + # An explicit `param_name: null` marks a field with no CUOPT_* + # string parameter — settable over the wire but not through the + # parameter API an MCP client uses, so advertising it would + # produce calls that can only fail. + if "param_name" in f and f["param_name"] is None: + continue + assert name not in properties, ( + f"duplicate settings field {section}.{name} — JSON Schema " + "properties must be unique" + ) + properties[name] = _json_schema_property(registry, f) + schema["settings"][section] = { + "title": title, + "type": "object", + "description": ( + "Solver settings. Every field is optional; omit a field to " + "keep the cuOpt default." + ), + "properties": properties, + "additionalProperties": False, + } + return json.dumps(schema, indent=2, sort_keys=False) + "\n" + + # ============================================================================ # Enum helpers — convention-based derivation # ============================================================================ @@ -3828,6 +3954,12 @@ def main(): os.path.join(outdir, "generated_array_field_element_size.inc"), HEADER + generate_array_field_element_size_inc(registry) + "\n", ) + # JSON has no comment syntax, so the provenance banner every other + # artifact carries in HEADER lives in the schema's own $comment. + write_file( + os.path.join(outdir, "cuopt_mcp_schema.json"), + generate_mcp_schema(registry), + ) print(f"\nDone! Generated {len(os.listdir(outdir))} files in: {outdir}") diff --git a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json new file mode 100644 index 000000000..7218ad914 --- /dev/null +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -0,0 +1,428 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "AUTO-GENERATED by src/grpc/codegen/generate_conversions.py from field_registry.yaml. DO NOT EDIT \u2014 regenerate with ./build.sh codegen.", + "settings": { + "pdlp_settings": { + "title": "PDLPSolverSettings", + "type": "object", + "description": "Solver settings. Every field is optional; omit a field to keep the cuOpt default.", + "properties": { + "absolute_gap_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's duality gap check: duality_gap < absolute_gap_tolerance + relative_gap_tolerance * (|primal_objective| + |dual_objective|). Default: 1e-4." + }, + "relative_gap_tolerance": { + "type": "number", + "description": "Relative term in PDLP's duality gap check; multiplies (|primal_objective| + |dual_objective|). Significant impact on accuracy and runtime. Default: 1e-4." + }, + "primal_infeasible_tolerance": { + "type": "number", + "description": "Tolerance used when PDLP declares the problem primal infeasible. Only consulted when detect_infeasibility is enabled. Default: 1e-10." + }, + "dual_infeasible_tolerance": { + "type": "number", + "description": "Tolerance used when PDLP declares the problem dual infeasible (unbounded). Only consulted when detect_infeasibility is enabled. Default: 1e-10." + }, + "absolute_dual_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's dual feasibility check: dual_feasibility < absolute_dual_tolerance + relative_dual_tolerance * l2_norm(c). Default: 1e-4." + }, + "relative_dual_tolerance": { + "type": "number", + "description": "Relative term in PDLP's dual feasibility check; multiplies the objective vector L2 norm. Default: 1e-4." + }, + "absolute_primal_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's primal feasibility check: primal_feasibility < absolute_primal_tolerance + relative_primal_tolerance * l2_norm(b). Default: 1e-4." + }, + "relative_primal_tolerance": { + "type": "number", + "description": "Relative term in PDLP's primal feasibility check; multiplies the right-hand-side vector L2 norm. Default: 1e-4." + }, + "time_limit": { + "type": "number", + "description": "Wall-clock limit in seconds after which the solver stops and returns the current solution. Checked periodically rather than continuously, so the solver may run slightly over. When set together with iteration_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "iteration_limit": { + "type": "integer", + "description": "Iteration limit after which the solver stops and returns the current solution. Checked periodically, so the solver may run a few extra iterations. When set together with time_limit, the first limit reached wins. Default: no limit (INT_MAX). Omit for the default limit." + }, + "log_to_console": { + "type": "boolean", + "description": "Whether the solver writes log output to the console. Logs may still be written to a file when this is false. Default: true." + }, + "detect_infeasibility": { + "type": "boolean", + "description": "Whether PDLP attempts to detect infeasibility. Detection is not always accurate \u2014 some problems reported infeasible converge under a different tolerance \u2014 and costs roughly 3-7% runtime and 10-20% memory. Dual simplex always detects infeasibility regardless of this setting. Default: false.", + "x-parameter-name": "infeasibility_detection" + }, + "strict_infeasibility": { + "type": "boolean", + "description": "When true, PDLP stops if either the current or the average solution is detected infeasible. When false, both must be detected infeasible. Default: false." + }, + "pdlp_solver_mode": { + "type": "string", + "enum": [ + "Stable1", + "Stable2", + "Methodical1", + "Fast1", + "Stable3" + ], + "x-enum-values": { + "Stable1": 0, + "Stable2": 1, + "Methodical1": 2, + "Fast1": 3, + "Stable3": 4 + }, + "description": "Mode under which PDLP operates; changes how the problem is internally optimized and can drastically change solve time. Stable3 is the best overall mode from cuOpt experiments; Methodical1 takes slower but fewer steps at 1.3-1.7x the memory; Fast1 is fastest but converges less often. There is no way to know upfront which mode suits a given problem \u2014 testing is encouraged. Default: Stable3." + }, + "method": { + "type": "string", + "enum": [ + "Concurrent", + "PDLP", + "DualSimplex", + "Barrier" + ], + "x-enum-values": { + "Concurrent": 0, + "PDLP": 1, + "DualSimplex": 2, + "Barrier": 3 + }, + "description": "Method used to solve the LP. Concurrent runs PDLP, dual simplex and barrier in parallel. Default accuracy differs per method: PDLP 1e-4 relative, barrier 1e-8 relative, dual simplex 1e-6 absolute. Default: Concurrent." + }, + "presolver": { + "type": "integer", + "description": "Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. By default LP uses PSLP and MIP uses Papilo. Default: Default (PSLP for LP, Papilo for MIP).", + "x-parameter-name": "presolve" + }, + "dual_postsolve": { + "type": "boolean", + "description": "Whether dual postsolve runs when the Papilo presolver is used on an LP. Disabling it can improve solve time at the cost of losing access to the dual solution. Not relevant for MIP. Default: true." + }, + "crossover": { + "type": "boolean", + "description": "Whether PDLP or barrier crosses over to a basic solution after reaching optimality. PDLP and barrier solutions need not lie at a vertex; crossover yields a basic solution with n - m variables on their bounds. Significant impact on accuracy and runtime. Default: false." + }, + "num_gpus": { + "type": "integer", + "description": "Number of GPUs used for the solve. Relevant to LP in concurrent mode (up to 2 GPUs), where PDLP and barrier run on separate GPUs rather than sharing one. For distributed PDLP, -1 selects all visible devices. Default: 1." + }, + "per_constraint_residual": { + "type": "boolean", + "description": "Whether PDLP computes primal and dual residuals per constraint instead of globally. Default: false." + }, + "cudss_deterministic": { + "type": "boolean", + "description": "Whether cuDSS runs in deterministic mode. Deterministic mode makes results reproducible across runs but may be slower. Default: false." + }, + "folding": { + "type": "integer", + "description": "Barrier: whether to fold the LP, reducing problem size by exploiting symmetry. -1 automatic, 0 disabled, 1 forced. Default: -1 (automatic)." + }, + "augmented": { + "type": "integer", + "description": "Barrier: which linear system to solve at each iteration. -1 automatic, 0 the ADAT system (normal equations), 1 the augmented system. The augmented system can be more stable, ADAT is often faster. Default: -1 (automatic)." + }, + "dualize": { + "type": "integer", + "description": "Barrier: whether presolve dualizes the LP, which can help problems with inequality constraints that have more constraints than variables. -1 automatic, 0 disabled, 1 forced. Default: -1 (automatic)." + }, + "ordering": { + "type": "integer", + "description": "Barrier: ordering algorithm cuDSS uses for sparse factorizations, which can significantly affect runtime. -1 automatic, 0 cuDSS default ordering, 1 AMD (approximate minimum degree). Default: -1 (automatic)." + }, + "barrier_dual_initial_point": { + "type": "integer", + "description": "Barrier: how the dual initial point is computed, which affects the iteration count. -1 automatic, 0 the Lustig-Marsten-Shanno heuristic, 1 a least-squares problem minimizing the norms of the dual variables and reduced costs subject to the dual equality constraints. Default: -1 (automatic)." + }, + "eliminate_dense_columns": { + "type": "boolean", + "description": "Barrier: whether dense columns are eliminated from the constraint matrix before solving, reducing factorization fill-in at the cost of extra solves per iteration. Only has an effect when the ADAT (normal equation) system is solved. Default: true." + }, + "barrier_iterative_refinement": { + "type": "boolean", + "description": "Barrier: whether iterative refinement runs after each barrier solve to improve solution accuracy (see cpp/src/barrier/barrier.cu). Default: true." + }, + "barrier_step_scale": { + "type": "number", + "description": "Barrier: scaling factor applied to the primal/dual step size. Must be strictly less than 1 \u2014 0.9 is conservative, 0.999 aggressive. The local-solve binding restricts the range to [0.5, 0.9999]; this field is not clamped after decoding, for consistency with the other float scalars. Default: 0.9." + }, + "postsolve_info": { + "type": "integer", + "description": "Default: -1." + }, + "save_best_primal_so_far": { + "type": "boolean", + "description": "Whether PDLP keeps the best primal solution seen so far. When enabled, a primal feasible iterate always beats an infeasible one; among feasible iterates the best primal objective wins; among infeasible ones the lowest primal residual wins, breaking ties on objective. Default: false." + }, + "first_primal_feasible": { + "type": "boolean", + "description": "Whether the solver stops as soon as a primal feasible iterate is found, without waiting for optimality or dual feasibility. Composable with per_constraint_residual. Default: false." + }, + "pdlp_precision": { + "type": "integer", + "description": "Precision mode for the PDLP solver. DefaultPrecision uses the problem's native precision; SinglePrecision runs PDHG in FP32 (half the memory, roughly 2x faster iterations, possibly more of them); DoublePrecision forces FP64; MixedPrecision stores the constraint matrix in FP32 for faster SpMV while keeping vectors and compute in FP64 (convergence checks still use the FP64 matrix, so memory is not reduced). Default: DefaultPrecision." + } + }, + "additionalProperties": false + }, + "mip_settings": { + "title": "MIPSolverSettings", + "type": "object", + "description": "Solver settings. Every field is optional; omit a field to keep the cuOpt default.", + "properties": { + "time_limit": { + "type": "number", + "description": "Wall-clock limit in seconds after which the solver stops and returns the best solution found so far. When set together with node_limit or work_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "relative_mip_gap": { + "type": "number", + "description": "Relative gap at which the solve terminates: abs(best_objective - dual_bound) / abs(best_objective). The gap is zero when both are zero, and infinite when only the best objective is zero. Default: 1e-4.", + "x-parameter-name": "mip_relative_gap" + }, + "absolute_mip_gap": { + "type": "number", + "description": "Absolute gap at which the solve terminates: best_objective - dual_bound when minimizing, dual_bound - best_objective when maximizing. Default: 1e-10.", + "x-parameter-name": "mip_absolute_gap" + }, + "integrality_tolerance": { + "type": "number", + "description": "How close to an integer a variable must be to count as integral. Default: 1e-5.", + "x-parameter-name": "mip_integrality_tolerance" + }, + "absolute_tolerance": { + "type": "number", + "description": "MIP absolute tolerance. Default: 1e-6.", + "x-parameter-name": "mip_absolute_tolerance" + }, + "relative_tolerance": { + "type": "number", + "description": "MIP relative tolerance. Default: 1e-12.", + "x-parameter-name": "mip_relative_tolerance" + }, + "log_to_console": { + "type": "boolean", + "description": "Whether the solver writes log output to the console. Logs may still be written to a file when this is false. Default: true." + }, + "heuristics_only": { + "type": "boolean", + "description": "When true only the GPU heuristics run, improving the primal bound alone. When false both GPU and CPU are used and the dual bound is improved on the CPU. Default: false.", + "x-parameter-name": "mip_heuristics_only" + }, + "num_cpu_threads": { + "type": "integer", + "description": "Number of CPU threads used by the MIP solver. Lower values cap cuOpt's CPU footprint; higher values speed up the CPU-parallel parts. -1 derives the count from the number of CPU cores. Default: -1 (automatic)." + }, + "num_gpus": { + "type": "integer", + "description": "Number of GPUs used for the solve. Default: 1." + }, + "presolver": { + "type": "integer", + "description": "Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. MIP uses Papilo by default. Default: Default (Papilo for MIP).", + "x-parameter-name": "presolve" + }, + "mip_scaling": { + "type": "integer", + "description": "Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied but not to the objective. Default: 2 (no objective scaling)." + }, + "symmetry": { + "type": "integer", + "description": "Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital fixing, 2 orbital fixing plus lexical reduction. Default: -1 (automatic).", + "x-parameter-name": "mip_symmetry" + }, + "work_limit": { + "type": "number", + "description": "Limit in work units \u2014 a machine-independent measure of solver effort \u2014 after which the solver stops and returns the current solution. When set together with time_limit or node_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "node_limit": { + "type": "integer", + "description": "Maximum number of branch-and-bound nodes explored before the solver stops and returns the best feasible solution found, if any. When set together with time_limit, the first limit reached wins. Default: no limit (INT_MAX). Omit for the default limit." + }, + "reliability_branching": { + "type": "integer", + "description": "Reliability branching mode. -1 automatic (the solver picks whether to use it and with what factor), 0 disabled, k > 0 enables it and treats a variable as reliable once it has been branched on k times. Default: -1 (automatic).", + "x-parameter-name": "mip_reliability_branching" + }, + "mip_batch_pdlp_strong_branching": { + "type": "integer", + "description": "Whether strong branching at the root evaluates candidates with a single batched PDLP solve instead of solving them in parallel with dual simplex, which can cut strong-branching time when dual simplex struggles. 0 disabled, 1 enabled. Default: 0 (disabled)." + }, + "mip_batch_pdlp_reliability_branching": { + "type": "integer", + "description": "Whether reliability-branching candidates are evaluated simultaneously with a single batched PDLP solve. 0 disabled, 1 enabled. Default: 0 (disabled)." + }, + "strong_branching_simplex_iteration_limit": { + "type": "integer", + "description": "Maximum simplex iterations per candidate during strong branching. Lowering it speeds up strong branching at the cost of less accurate candidate evaluations. Default: -1 (automatic).", + "x-parameter-name": "mip_strong_branching_simplex_iteration_limit" + }, + "max_cut_passes": { + "type": "integer", + "description": "Maximum number of cut passes to run. 0 disables cuts entirely; larger values perform more passes. Default: 10.", + "x-parameter-name": "mip_cut_passes" + }, + "mir_cuts": { + "type": "integer", + "description": "Whether mixed-integer rounding cuts are used. -1 automatic (the solver decides from problem characteristics), 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_mixed_integer_rounding_cuts" + }, + "mixed_integer_gomory_cuts": { + "type": "integer", + "description": "Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_mixed_integer_gomory_cuts" + }, + "knapsack_cuts": { + "type": "integer", + "description": "Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_knapsack_cuts" + }, + "clique_cuts": { + "type": "integer", + "description": "Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_clique_cuts" + }, + "zero_half_cuts": { + "type": "integer", + "description": "Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_zero_half_cuts" + }, + "implied_bound_cuts": { + "type": "integer", + "description": "Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_implied_bound_cuts" + }, + "strong_chvatal_gomory_cuts": { + "type": "integer", + "description": "Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_strong_chvatal_gomory_cuts" + }, + "reduced_cost_strengthening": { + "type": "integer", + "description": "Whether integer feasible solutions are used to strengthen integer variable bounds. -1 automatic, 0 disabled, 1 during the root cut passes, 2 during the root cut passes and after strong branching. Default: -1 (automatic).", + "x-parameter-name": "mip_reduced_cost_strengthening" + }, + "cut_change_threshold": { + "type": "number", + "description": "Required improvement in the dual bound per cut pass. Larger values demand significant improvement each pass; -1 lets cut passes continue even without improvement. Default: -1 (no threshold).", + "x-parameter-name": "mip_cut_change_threshold" + }, + "cut_min_orthogonality": { + "type": "number", + "description": "Minimum orthogonality a cut needs to be added to the LP relaxation. Values near 1 require cuts to be nearly orthogonal to each other; values near 0 admit more cuts. Default: 0.5.", + "x-parameter-name": "mip_cut_min_orthogonality" + }, + "determinism_mode": { + "type": "integer", + "description": "0 opportunistic \u2014 results may vary between runs due to parallelism. 1 deterministic \u2014 improves reproducibility across runs with the same thread count. Deterministic mode is experimental and does not yet guarantee fully deterministic results in every scenario. Default: 0 (opportunistic).", + "x-parameter-name": "mip_determinism_mode" + }, + "seed": { + "type": "integer", + "description": "Random seed. A fixed seed gives reproducible results when running in deterministic mode. Default: -1 (chosen automatically).", + "x-parameter-name": "random_seed" + }, + "probing": { + "type": "boolean", + "description": "Whether the probing-cache step of MIP presolve runs. Probing evaluates variable fixings to discover implications later used by branch-and-bound and the rounding heuristics. Only meaningful when presolve is otherwise enabled \u2014 presolver=0 disables the whole pipeline. Probing is also skipped in deterministic mode and on LP-only solves. Default: true.", + "x-parameter-name": "mip_probing" + }, + "semi_continuous_big_m": { + "type": "number", + "description": "Big-M coefficient used when linearizing semi-continuous variable constraints (such a variable is either zero or lies in [lower_bound, upper_bound]). Should be at least as large as the upper bound of any semi-continuous variable in the problem. Serves as the fallback upper bound when bounds-strengthening cannot derive a finite one. The local-solve binding restricts the range to [1.0, +inf]. Default: 1e10.", + "x-parameter-name": "mip_semi_continuous_big_m" + }, + "population_size": { + "type": "integer", + "description": "Maximum number of solutions held in the solution pool. Default: 32.", + "x-parameter-name": "mip_hyper_heuristic_population_size" + }, + "num_cpufj_threads": { + "type": "integer", + "description": "Number of parallel CPU Feasibility Jump climbers. Default: 8.", + "x-parameter-name": "mip_hyper_heuristic_num_cpufj_threads" + }, + "root_lp_time_ratio": { + "type": "number", + "description": "Fraction of the total time budget given to the root LP. Default: 0.1.", + "x-parameter-name": "mip_hyper_heuristic_root_lp_time_ratio" + }, + "root_lp_max_time": { + "type": "number", + "description": "Hard cap in seconds on the root LP solve. Default: 15.0.", + "x-parameter-name": "mip_hyper_heuristic_root_lp_max_time" + }, + "rins_time_limit": { + "type": "number", + "description": "Per-call time budget in seconds for the RINS sub-MIP. Default: 3.0.", + "x-parameter-name": "mip_hyper_heuristic_rins_time_limit" + }, + "rins_max_time_limit": { + "type": "number", + "description": "Ceiling in seconds on the adaptive RINS time budget. Default: 20.0.", + "x-parameter-name": "mip_hyper_heuristic_rins_max_time_limit" + }, + "rins_fix_rate": { + "type": "number", + "description": "Fraction of variables RINS fixes before solving the sub-MIP. Default: 0.5.", + "x-parameter-name": "mip_hyper_heuristic_rins_fix_rate" + }, + "stagnation_trigger": { + "type": "integer", + "description": "Number of Feasibility Pump loops without improvement before recombination is triggered. Default: 3.", + "x-parameter-name": "mip_hyper_heuristic_stagnation_trigger" + }, + "max_iterations_without_improvement": { + "type": "integer", + "description": "Depth of the diversity step taken after stagnation. Default: 8.", + "x-parameter-name": "mip_hyper_heuristic_max_iterations_without_improvement" + }, + "initial_infeasibility_weight": { + "type": "number", + "description": "Seed value for the constraint violation penalty. Default: 1000.0.", + "x-parameter-name": "mip_hyper_heuristic_initial_infeasibility_weight" + }, + "n_of_minimums_for_exit": { + "type": "integer", + "description": "Number of local minima after which the Feasibility Jump baseline exits. Default: 7000.", + "x-parameter-name": "mip_hyper_heuristic_n_of_minimums_for_exit" + }, + "enabled_recombiners": { + "type": "integer", + "description": "Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, 4 local search, 8 sub-MIP. Default: 15 (all enabled).", + "x-parameter-name": "mip_hyper_heuristic_enabled_recombiners" + }, + "cycle_detection_length": { + "type": "integer", + "description": "Size of the ring buffer used to detect Feasibility Pump assignment cycles. Default: 30.", + "x-parameter-name": "mip_hyper_heuristic_cycle_detection_length" + }, + "relaxed_lp_time_limit": { + "type": "number", + "description": "Base time cap in seconds for relaxed LP solves inside the heuristics. Default: 1.0.", + "x-parameter-name": "mip_hyper_heuristic_relaxed_lp_time_limit" + }, + "related_vars_time_limit": { + "type": "number", + "description": "Time in seconds allowed for building the related-variable structure. Default: 30.0.", + "x-parameter-name": "mip_hyper_heuristic_related_vars_time_limit" + }, + "presolve_max_rounds": { + "type": "integer", + "description": "Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. Default: -1 (no override).", + "x-parameter-name": "mip_hyper_heuristic_presolve_max_rounds" + }, + "papilo_probing_max_badgesize": { + "type": "integer", + "description": "Ceiling on Papilo's probing.minbadgesize. Default: -1 (no override).", + "x-parameter-name": "mip_hyper_heuristic_papilo_probing_max_badgesize" + } + }, + "additionalProperties": false + } + } +} diff --git a/dependencies.yaml b/dependencies.yaml index b3d56c171..8cea43c4f 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -212,6 +212,29 @@ files: key: mps includes: - mps_cuopt_sh_client + py_build_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + extras: + table: build-system + includes: + - build_wheels + py_run_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + extras: + table: project + includes: + - run_cuopt_mcp + - depends_on_cuopt + py_test_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + extras: + table: project.optional-dependencies + key: test + includes: + - test_python_common channels: - rapidsai-nightly - rapidsai @@ -373,6 +396,19 @@ dependencies: packages: - *msgpack + run_cuopt_mcp: + # cuopt itself comes from depends_on_cuopt (CUDA-suffixed on PyPI). + # cuopt_mcp imports it lazily so the MCP handshake stays fast, which + # means an undeclared dependency would only surface on the first tool + # call rather than at install time. + common: + - output_types: [conda, requirements, pyproject] + packages: + - mcp>=2.0 + - output_types: conda + packages: + - pip + run_cuopt_sh_client: common: - output_types: [conda, requirements, pyproject] diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE new file mode 120000 index 000000000..30cff7403 --- /dev/null +++ b/python/cuopt_mcp/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md new file mode 100644 index 000000000..f8b42507b --- /dev/null +++ b/python/cuopt_mcp/README.md @@ -0,0 +1,74 @@ +# cuopt_mcp — MCP server for NVIDIA cuOpt + +Exposes cuOpt LP and MILP solving to MCP clients (Claude Code, Cursor, Codex) +over the cuOpt gRPC backend. + +``` +MCP client ──stdio (JSON-RPC)──> cuopt-mcp ──gRPC──> cuopt_grpc_server (GPU) +``` + +The MCP server runs as a stdio subprocess on the user's machine and needs no +GPU: the solve happens wherever `cuopt_grpc_server` runs. No HTTP is involved. + +## Install + +```bash +pip install cuopt_mcp +``` + +## Configure + +Start the solver backend on a GPU host: + +```bash +cuopt_grpc_server --port 50051 +``` + +Then register the MCP server with your client: + +```json +{ + "mcpServers": { + "cuopt": { + "command": "cuopt-mcp", + "env": { "CUOPT_REMOTE_HOST": "gpu-host", "CUOPT_REMOTE_PORT": "50051" } + } + } +} +``` + +Configuration reuses the environment the cuOpt gRPC client already honours — +`CUOPT_REMOTE_HOST`, `CUOPT_REMOTE_PORT`, and `CUOPT_TLS_*`. + +## Tools + +| Tool | Purpose | +|------|---------| +| `cuopt_solve_lp` | Submit an LP; returns a `job_id` immediately | +| `cuopt_solve_milp` | Submit a MILP; returns a `job_id` immediately | +| `cuopt_status` | Poll job state | +| `cuopt_result` | Fetch the solution, shaped to stay readable | +| `cuopt_incumbents` | Watch a MILP's objective improve | +| `cuopt_logs` | Recent solver log lines | +| `cuopt_cancel` | Stop a running job | +| `cuopt_list_settings` | Discover solver parameters | + +Solves are asynchronous by design. A blocking call would exceed the MCP +client timeout on any realistic MILP and would make cancellation impossible. + +## Design notes + +**No per-job state.** Column names needed to label a solution are supplied +per call via `names_from`, so any process can retrieve a named result for a +job it did not submit. The only state this process holds is the gRPC channel. + +**Result shaping.** Problems can have millions of variables; the binding +limit on a tool result is the model's context window, not the transport. So +`cuopt_result` returns a summary plus narrow accessors (`variables`, +`nonzero_only`), writing the full vector to a file past `limit`. + +**Settings catalogue is generated.** `_generated/cuopt_mcp_schema.json` is +emitted from `cpp/src/grpc/codegen/field_registry.yaml` by +`./build.sh codegen`, the same source of truth that drives the proto and the +C++ conversion code. A new solver parameter reaches this server with no +MCP-specific work. diff --git a/python/cuopt_mcp/cuopt_mcp/VERSION b/python/cuopt_mcp/cuopt_mcp/VERSION new file mode 100644 index 000000000..6549ba652 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/VERSION @@ -0,0 +1 @@ +26.10.00 diff --git a/python/cuopt_mcp/cuopt_mcp/__init__.py b/python/cuopt_mcp/cuopt_mcp/__init__.py new file mode 100644 index 000000000..cadf4ee8c --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/__init__.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""MCP server for NVIDIA cuOpt.""" + +__all__ = ["main"] + + +def main(): + """Console-script entry point (``cuopt-mcp``).""" + from .server import main as _main + + _main() diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py new file mode 100644 index 000000000..cf9724e07 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""gRPC connection handling for the cuOpt MCP server. + +``cuopt`` is imported lazily inside the accessors rather than at module +scope: importing it pulls the compiled ``libcuopt`` extension, and under +stdio that cost would be paid on every session start, delaying the +``initialize`` / ``tools/list`` handshake. +""" + +import os +import threading + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 50051 + +_lock = threading.Lock() +_client = None + + +def endpoint() -> tuple: + """Return the configured ``(host, port)``. + + Reuses the same environment the cuOpt gRPC client already honours, so + the MCP server introduces no new configuration surface. + """ + host = os.environ.get("CUOPT_REMOTE_HOST", DEFAULT_HOST) + port = int(os.environ.get("CUOPT_REMOTE_PORT", DEFAULT_PORT)) + return host, port + + +def _tls_config(): + if os.environ.get("CUOPT_TLS_ENABLED", "").lower() not in ( + "1", + "true", + "yes", + ): + return None + from cuopt.grpc.linear_programming import TlsConfig + + return TlsConfig( + root_certs=os.environ.get("CUOPT_TLS_ROOT_CERT"), + client_cert=os.environ.get("CUOPT_TLS_CLIENT_CERT"), + client_key=os.environ.get("CUOPT_TLS_CLIENT_KEY"), + ) + + +def get_client(): + """Return a process-wide gRPC client, connecting on first use. + + The channel is the only state this process holds; jobs themselves live + in cuopt_grpc_server and are addressed by the ``job_id`` returned to the + caller, so a restart loses nothing but the connection. + """ + global _client + with _lock: + if _client is None: + from cuopt.grpc.linear_programming import Client + + host, port = endpoint() + _client = Client(host, port, tls=_tls_config()) + return _client + + +def reset_client() -> None: + """Drop the cached client. Used by tests and after a fatal channel error.""" + global _client + with _lock: + _client = None + + +class CuOptMCPError(RuntimeError): + """Raised with text meant for the model, not a stack trace.""" + + +def describe_connection_error(exc: Exception) -> CuOptMCPError: + host, port = endpoint() + text = str(exc) + if "UNAVAILABLE" in text or "failed to connect" in text.lower(): + return CuOptMCPError( + f"cuOpt gRPC server unreachable at {host}:{port}. Start it with " + f"`cuopt_grpc_server --port {port}`, or set CUOPT_REMOTE_HOST / " + "CUOPT_REMOTE_PORT to point at a running server." + ) + return CuOptMCPError(text) diff --git a/python/cuopt_mcp/cuopt_mcp/schema.py b/python/cuopt_mcp/cuopt_mcp/schema.py new file mode 100644 index 000000000..63867fa7d --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/schema.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Access to the generated solver-settings JSON Schema. + +The schema is emitted from ``cpp/src/grpc/codegen/field_registry.yaml`` by +``./build.sh codegen`` — the same single source of truth that drives the +proto and the C++ conversion code. Nothing here hand-maintains a second +copy of the settings surface. +""" + +import functools +import json +from pathlib import Path + +SCHEMA_FILENAME = "cuopt_mcp_schema.json" + +# In an installed wheel the schema is packaged alongside this module. In a +# source checkout it lives in the codegen output directory; fall back to that +# so the server runs from the repo without a build step. +_PACKAGED = Path(__file__).parent / "_generated" / SCHEMA_FILENAME +_IN_TREE = ( + Path(__file__).resolve().parents[3] + / "cpp" + / "src" + / "grpc" + / "codegen" + / "generated" + / SCHEMA_FILENAME +) + + +def schema_path() -> Path: + for candidate in (_PACKAGED, _IN_TREE): + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"{SCHEMA_FILENAME} not found. In a source checkout, run " + "`./build.sh codegen` to generate it." + ) + + +@functools.lru_cache(maxsize=1) +def load() -> dict: + """Return the full generated schema document.""" + return json.loads(schema_path().read_text()) + + +def settings_schema(kind: str) -> dict: + """Return the JSON Schema for ``pdlp_settings`` or ``mip_settings``.""" + return load()["settings"][kind] + + +def known_parameters(kind: str) -> set: + return set(settings_schema(kind)["properties"]) + + +def validate_settings(kind: str, settings: dict) -> None: + """Reject unknown or wrongly-typed settings before any gRPC traffic. + + The schema declares ``additionalProperties: false``, so a typo like + ``time_limt`` fails here with the near-miss named rather than being + silently dropped by the solver. + """ + if not settings: + return + schema = settings_schema(kind) + unknown = set(settings) - set(schema["properties"]) + if unknown: + import difflib + + hints = [] + for name in sorted(unknown): + close = difflib.get_close_matches( + name, schema["properties"], n=1, cutoff=0.7 + ) + hints.append( + f"{name}" + (f" (did you mean {close[0]}?)" if close else "") + ) + raise ValueError( + f"unknown {kind} parameter(s): {', '.join(hints)}. " + f"Call cuopt_list_settings('{kind}') for the full list." + ) + for name, value in settings.items(): + prop = schema["properties"][name] + expected = prop.get("type") + if expected == "string" and "enum" in prop: + if value not in prop["enum"]: + raise ValueError( + f"{name} must be one of {prop['enum']}, got {value!r}" + ) + elif expected == "integer" and not isinstance(value, int): + raise ValueError(f"{name} must be an integer, got {value!r}") + elif expected == "number" and not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a number, got {value!r}") + elif expected == "boolean" and not isinstance(value, bool): + raise ValueError(f"{name} must be a boolean, got {value!r}") diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py new file mode 100644 index 000000000..9c51f5fcc --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""MCP server exposing cuOpt LP/MILP solves over the gRPC backend. + +Runs as a stdio subprocess of an MCP client, holding a gRPC channel to +``cuopt_grpc_server``. No HTTP is involved and the host needs no GPU — the +solve happens wherever the gRPC server runs. + +stdout carries the JSON-RPC stream, so every diagnostic goes to stderr; a +stray ``print()`` here corrupts the protocol. +""" + +import logging +import sys +from typing import Any + +from mcp.server.mcpserver import MCPServer + +from . import tools +from .client import CuOptMCPError + +logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format="%(asctime)s cuopt-mcp %(levelname)s %(message)s", +) + +server = MCPServer( + name="cuopt", + instructions=( + "Solve linear and mixed-integer programs with NVIDIA cuOpt on GPU. " + "Solves are asynchronous: cuopt_solve_lp / cuopt_solve_milp return a " + "job_id immediately, then poll cuopt_status and fetch cuopt_result. " + "Call cuopt_list_settings to discover solver parameters before " + "passing a settings object." + ), +) + + +def _guard(fn, /, **kwargs) -> dict[str, Any]: + try: + return fn(**kwargs) + except CuOptMCPError as exc: + return {"error": str(exc)} + except ValueError as exc: + return {"error": str(exc)} + + +@server.tool(structured_output=True) +def cuopt_solve_lp( + problem_path: str, settings: dict | None = None +) -> dict[str, Any]: + """Submit a linear program to cuOpt and return a job handle immediately. + + problem_path: path to an MPS, QPS, or LP file readable by this process. + settings: optional PDLP solver settings, e.g. {"time_limit": 60, + "method": "Barrier"}. Call cuopt_list_settings("pdlp_settings") for + the full list with descriptions and defaults. Omit any setting to + keep the cuOpt default. + + Returns a job_id. The solve runs asynchronously — poll cuopt_status, + then call cuopt_result. + """ + return _guard( + tools.submit, + problem_path=problem_path, + kind="pdlp_settings", + settings=settings, + ) + + +@server.tool(structured_output=True) +def cuopt_solve_milp( + problem_path: str, settings: dict | None = None +) -> dict[str, Any]: + """Submit a mixed-integer program to cuOpt and return a job handle. + + problem_path: path to an MPS file containing integer variables. + settings: optional MIP solver settings, e.g. {"time_limit": 300, + "relative_mip_gap": 0.01}. Call cuopt_list_settings("mip_settings") + for the full list. + + Returns a job_id. Use cuopt_incumbents to watch the objective improve + and cuopt_cancel to stop early once it is good enough. + """ + return _guard( + tools.submit, + problem_path=problem_path, + kind="mip_settings", + settings=settings, + ) + + +@server.tool(structured_output=True) +def cuopt_status(job_id: str) -> dict[str, Any]: + """Report whether a cuOpt job is queued, running, or finished. + + Cheap to call repeatedly. Returns terminal=true once the job has + reached COMPLETED, FAILED, CANCELLED, or NOT_FOUND. + """ + return _guard(tools.status, job_id=job_id) + + +@server.tool(structured_output=True) +def cuopt_result( + job_id: str, + names_from: str | None = None, + variables: list | None = None, + nonzero_only: bool = False, + limit: int = tools.INLINE_SOLUTION_LIMIT, +) -> dict[str, Any]: + """Fetch the solution for a finished cuOpt job. + + Always returns the termination status, objective, and solve time. + Variable values are shaped to stay readable: + + names_from: path to the problem file, to key values by variable name + rather than column index. Pass the "source" returned by the solve. + variables: fetch only these named variables. + nonzero_only: return only variables with a non-zero value — usually + what matters for a MILP. + limit: maximum values returned inline. Beyond this the full solution is + written to a file and its path returned instead. + """ + return _guard( + tools.result, + job_id=job_id, + names_from=names_from, + variables=variables, + nonzero_only=nonzero_only, + limit=limit, + ) + + +@server.tool(structured_output=True) +def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: + """Return improving MILP solutions found so far, oldest first. + + Use the returned next_index on the following call to fetch only new + incumbents. A flat objective across several calls means the solver has + plateaued and cuopt_cancel may be worthwhile. + """ + return _guard(tools.incumbents, job_id=job_id, from_index=from_index) + + +@server.tool(structured_output=True) +def cuopt_logs( + job_id: str, from_byte: int = 0, tail_lines: int = 100 +) -> dict[str, Any]: + """Return recent solver log lines for a job, for diagnosing a slow solve.""" + return _guard( + tools.logs, job_id=job_id, from_byte=from_byte, tail_lines=tail_lines + ) + + +@server.tool(structured_output=True) +def cuopt_cancel(job_id: str) -> dict[str, Any]: + """Stop a running cuOpt job. Any incumbent found so far remains fetchable.""" + return _guard(tools.cancel, job_id=job_id) + + +@server.tool(structured_output=True) +def cuopt_list_settings(kind: str, name: str | None = None) -> dict[str, Any]: + """List cuOpt solver settings with descriptions, types, and defaults. + + kind: "pdlp_settings" for LP, "mip_settings" for MILP. + name: a single parameter to describe in full, instead of listing names. + + The catalogue is generated from cuOpt's field registry, so it always + matches the solver build being talked to. + """ + return _guard(tools.list_settings, kind=kind, name=name) + + +def main() -> None: + host, port = __import__( + "cuopt_mcp.client", fromlist=["endpoint"] + ).endpoint() + logging.info("cuopt-mcp starting; gRPC target %s:%s", host, port) + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py new file mode 100644 index 000000000..ef6256983 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tool implementations for the cuOpt MCP server. + +Every solve is asynchronous: submitting returns a ``job_id`` and nothing +blocks. A blocking call inside a single ``tools/call`` would exceed the +client timeout on any realistic MILP, and would make cancellation +impossible. + +No per-job state is kept here. Column names needed to label a solution are +supplied per call via ``names_from``, so any process — a second editor +window, or this one after a restart — can retrieve a named result for a job +it did not submit. +""" + +import json +import os +import tempfile +from pathlib import Path + +from .client import CuOptMCPError, describe_connection_error, get_client +from .schema import known_parameters, settings_schema, validate_settings + +# Above this many variables a solution is written to a file instead of +# returned inline. The binding limit is the model's context window, not the +# transport: ~200 values is already a large tool result, and cuOpt problems +# routinely have millions. +INLINE_SOLUTION_LIMIT = 200 + + +def _solution_dir() -> Path: + path = Path( + os.environ.get( + "CUOPT_MCP_SOLUTION_DIR", Path(tempfile.gettempdir()) / "cuopt-mcp" + ) + ) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _read_problem(path: str): + from cuopt.linear_programming import Read + + resolved = Path(path).expanduser() + if not resolved.is_file(): + raise CuOptMCPError(f"problem file not found: {resolved}") + try: + return Read(str(resolved)) + except Exception as exc: + raise CuOptMCPError(f"failed to parse {resolved}: {exc}") from exc + + +def _build_settings(kind: str, settings: dict | None): + from cuopt.linear_programming import SolverSettings + + validate_settings(kind, settings or {}) + properties = settings_schema(kind)["properties"] + solver_settings = SolverSettings() + for name, value in (settings or {}).items(): + # Enum settings are exposed to callers by name ("Barrier") because a + # bare integer is meaningless to an agent, but cuOpt's string + # parameter interface takes the integer. The mapping is generated + # from the field registry alongside the enum itself. + prop = properties[name] + mapping = prop.get("x-enum-values") + if mapping is not None: + value = mapping[value] + # The proto field name is not always the CUOPT_* parameter name. + solver_settings.set_parameter( + prop.get("x-parameter-name", name), value + ) + return solver_settings + + +def _variable_names(names_from: str | None): + if not names_from: + return None + model = _read_problem(names_from) + names = model.get_variable_names() + return list(names) if names is not None else None + + +def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: + """Parse a problem file and submit it; return the job handle.""" + model = _read_problem(problem_path) + solver_settings = _build_settings(kind, settings) + try: + job_id = get_client().submit(model, solver_settings) + except Exception as exc: + raise describe_connection_error(exc) from exc + + # DataModel exposes no public size accessors, so derive both from the + # arrays it does expose: one lower bound per column, and CSR row offsets + # numbering rows + 1. + offsets = model.get_constraint_matrix_offsets() + return { + "job_id": job_id, + "source": str(Path(problem_path).expanduser()), + "num_variables": int(len(model.get_variable_lower_bounds())), + "num_constraints": int(max(len(offsets) - 1, 0)), + "next": ( + "Poll cuopt_status(job_id). When it reports COMPLETED, call " + "cuopt_result(job_id, names_from=source) for a named solution." + ), + } + + +def status(job_id: str) -> dict: + try: + state = get_client().status(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + return { + "job_id": job_id, + "status": state.name, + "terminal": state.name + in ("COMPLETED", "FAILED", "CANCELLED", "NOT_FOUND"), + } + + +def _write_solution_file(job_id: str, vars_by_name: dict) -> str: + path = _solution_dir() / f"{job_id}.json" + path.write_text(json.dumps(vars_by_name, indent=1)) + return str(path) + + +def result( + job_id: str, + names_from: str | None = None, + variables: list | None = None, + nonzero_only: bool = False, + limit: int = INLINE_SOLUTION_LIMIT, +) -> dict: + """Fetch a completed solution, shaped to stay within a usable size.""" + try: + solution = get_client().result(job_id, _variable_names(names_from)) + except Exception as exc: + raise describe_connection_error(exc) from exc + if solution is None: + return { + "job_id": job_id, + "ready": False, + "hint": "Job has not finished. Poll cuopt_status(job_id).", + } + + primal = solution.get_primal_solution() + # The status is an IntEnum, so str() would yield the bare number ("1"). + # get_termination_reason() is its .name, which is what a caller can act + # on. Note LPTerminationStatus numbers Optimal=1 while the wire enum + # pdlp_termination_status numbers it 2 — never map between them. + status_enum = solution.get_termination_status() + summary = { + "job_id": job_id, + "ready": True, + "termination_status": getattr(status_enum, "name", str(status_enum)), + "termination_status_code": int(status_enum), + "primal_objective": float(solution.get_primal_objective()), + "solve_time_s": float(solution.get_solve_time()), + "num_variables": int(len(primal)), + } + + vars_by_name = solution.get_vars() + if not vars_by_name: + vars_by_name = {str(i): float(v) for i, v in enumerate(primal)} + if not names_from: + summary["names"] = ( + "Values are keyed by column index. Pass names_from= to key them by variable name." + ) + + if variables: + missing = [v for v in variables if v not in vars_by_name] + summary["variables"] = { + v: float(vars_by_name[v]) for v in variables if v in vars_by_name + } + if missing: + summary["missing_variables"] = missing + return summary + + selected = vars_by_name + if nonzero_only: + selected = {k: v for k, v in vars_by_name.items() if v != 0} + summary["num_nonzero"] = len(selected) + + if len(selected) <= limit: + summary["variables"] = {k: float(v) for k, v in selected.items()} + else: + summary["variables_truncated"] = True + summary["variables_shown"] = limit + summary["variables"] = { + k: float(v) for k, v in list(selected.items())[:limit] + } + summary["solution_path"] = _write_solution_file(job_id, vars_by_name) + summary["hint"] = ( + f"{len(selected)} values exceed the inline limit of {limit}. The " + "full solution is at solution_path; use variables=[...] or " + "nonzero_only=true to narrow the result." + ) + return summary + + +def cancel(job_id: str) -> dict: + try: + get_client().cancel(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + return {"job_id": job_id, "cancelled": True} + + +def incumbents(job_id: str, from_index: int = 0) -> dict: + """Return the MILP incumbent trajectory so far. + + Lets a caller watch the objective improve and stop a run that has + plateaued, rather than waiting out the full time limit. + """ + try: + found = get_client().incumbents(job_id, from_index) + except Exception as exc: + raise describe_connection_error(exc) from exc + objectives = [ + {"index": from_index + i, "objective": float(obj)} + for i, (obj, _) in enumerate(found or []) + ] + return { + "job_id": job_id, + "count": len(objectives), + "next_index": from_index + len(objectives), + "incumbents": objectives, + } + + +def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: + try: + text = get_client().logs(job_id, from_byte) + except Exception as exc: + raise describe_connection_error(exc) from exc + lines = (text or "").splitlines() + truncated = len(lines) > tail_lines + return { + "job_id": job_id, + "truncated": truncated, + "lines": lines[-tail_lines:], + "next_byte": from_byte + len(text or ""), + } + + +def list_settings(kind: str, name: str | None = None) -> dict: + """Describe available solver settings, from the generated schema.""" + if kind not in ("pdlp_settings", "mip_settings"): + raise CuOptMCPError( + "kind must be 'pdlp_settings' (LP) or 'mip_settings' (MILP)" + ) + schema = settings_schema(kind) + if name: + if name not in schema["properties"]: + raise CuOptMCPError( + f"unknown {kind} parameter {name!r}. " + f"Known: {sorted(known_parameters(kind))}" + ) + return {"kind": kind, "name": name, **schema["properties"][name]} + return {"kind": kind, "parameters": sorted(schema["properties"])} diff --git a/python/cuopt_mcp/pyproject.toml b/python/cuopt_mcp/pyproject.toml new file mode 100644 index 000000000..b6cec104b --- /dev/null +++ b/python/cuopt_mcp/pyproject.toml @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = [ + "setuptools>=77.0.0", + "wheel", +] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. +build-backend = "setuptools.build_meta" + +[project] +name = "cuopt_mcp" +dynamic = ["version"] +description = "MCP server exposing NVIDIA cuOpt LP/MILP solving to AI agents" +readme = { file = "README.md", content-type = "text/markdown" } +authors = [ + { name = "NVIDIA Corporation" }, +] +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dependencies = [ + "cuopt==26.10.*,>=0.0.0a0", + "mcp>=2.0", +] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.optional-dependencies] +test = [ + "pytest-cov", + "pytest-rerunfailures", + "pytest-xdist", + "pytest<9.0", +] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. + +[project.urls] +Homepage = "https://docs.nvidia.com/cuopt/introduction.html" +Source = "https://github.com/nvidia/cuopt" + +[project.scripts] +cuopt-mcp = "cuopt_mcp:main" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.dynamic] +version = {file = "cuopt_mcp/VERSION"} + +[tool.setuptools.packages.find] +include = ["cuopt_mcp*"] + +[tool.setuptools.package-data] +cuopt_mcp = ["VERSION", "_generated/*.json"] diff --git a/python/cuopt_mcp/setup.py b/python/cuopt_mcp/setup.py new file mode 100644 index 000000000..ad8aafaec --- /dev/null +++ b/python/cuopt_mcp/setup.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Copy the generated settings schema into the package at build time. + +The schema is a codegen artifact owned by cpp/src/grpc/codegen/generated/. +Copying it here at build time rather than committing a second copy keeps +field_registry.yaml the single source of truth: there is no checked-in file +that can drift from it. + +A source checkout needs no copy — cuopt_mcp.schema falls back to the codegen +output directory directly. +""" + +import shutil +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + +SCHEMA = "cuopt_mcp_schema.json" +SOURCE = ( + Path(__file__).resolve().parents[2] + / "cpp" + / "src" + / "grpc" + / "codegen" + / "generated" + / SCHEMA +) + + +class BuildPyWithSchema(build_py): + def run(self): + if not SOURCE.is_file(): + raise SystemExit( + f"{SOURCE} is missing. Run `./build.sh codegen` before " + "building cuopt_mcp." + ) + target = Path(self.build_lib) / "cuopt_mcp" / "_generated" + target.mkdir(parents=True, exist_ok=True) + shutil.copyfile(SOURCE, target / SCHEMA) + super().run() + + +setup(cmdclass={"build_py": BuildPyWithSchema}) diff --git a/python/cuopt_mcp/tests/conftest.py b/python/cuopt_mcp/tests/conftest.py new file mode 100644 index 000000000..643a9a7f0 --- /dev/null +++ b/python/cuopt_mcp/tests/conftest.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + + +@pytest.fixture +def anyio_backend(): + """Run @pytest.mark.anyio tests on asyncio only. + + anyio's plugin would otherwise parametrise across trio as well, which is + not a dependency of this package. + """ + return "asyncio" diff --git a/python/cuopt_mcp/tests/test_end_to_end.py b/python/cuopt_mcp/tests/test_end_to_end.py new file mode 100644 index 000000000..e86550630 --- /dev/null +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end test: a real MCP client driving a real cuOpt solve. + +Spawns the MCP server as a stdio subprocess exactly as an MCP client would, +speaks the MCP protocol to it, and lets it forward the solve to a live +cuopt_grpc_server. Nothing is mocked. + +Set CUOPT_TEST_GRPC_PORT to point at a running server; the test skips if no +server is reachable. +""" + +import os +import textwrap + +import pytest + +mcp = pytest.importorskip("mcp") + +from mcp import ClientSession, StdioServerParameters, stdio_client # noqa: E402 + +PORT = os.environ.get("CUOPT_TEST_GRPC_PORT") + +pytestmark = pytest.mark.skipif( + not PORT, reason="CUOPT_TEST_GRPC_PORT not set; no live cuopt_grpc_server" +) + +# minimize x + y s.t. x + y >= 10, x <= 8, y <= 8 +# optimum: 10 with x + y == 10 +LP_MPS = textwrap.dedent( + """\ + NAME TESTLP + ROWS + N COST + G LIM1 + COLUMNS + X COST 1.0 LIM1 1.0 + Y COST 1.0 LIM1 1.0 + RHS + RHS LIM1 10.0 + BOUNDS + UP BND X 8.0 + UP BND Y 8.0 + ENDATA + """ +) + + +@pytest.fixture +def mps_file(tmp_path): + path = tmp_path / "testlp.mps" + path.write_text(LP_MPS) + return str(path) + + +@pytest.fixture +async def session(tmp_path): + params = StdioServerParameters( + command="cuopt-mcp", + env={ + **os.environ, + "CUOPT_REMOTE_HOST": "localhost", + "CUOPT_REMOTE_PORT": PORT, + "CUOPT_MCP_SOLUTION_DIR": str(tmp_path / "solutions"), + }, + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as client: + await client.initialize() + yield client + + +async def _call(session, tool, /, **args): + """Positional-only params so a tool argument named `name` cannot shadow them.""" + result = await session.call_tool(tool, args) + assert not result.is_error, result.content + return result.structured_content + + +@pytest.mark.anyio +async def test_tools_are_discoverable(session): + listed = await session.list_tools() + names = {t.name for t in listed.tools} + assert {"cuopt_solve_lp", "cuopt_status", "cuopt_result"} <= names + + +@pytest.mark.anyio +async def test_settings_catalogue_reaches_the_client(session): + detail = await _call( + session, + "cuopt_list_settings", + kind="pdlp_settings", + name="pdlp_solver_mode", + ) + assert "Stable3" in detail["enum"] + assert "Default: Stable3" in detail["description"] + + +@pytest.mark.anyio +async def test_solve_lp_end_to_end(session, mps_file): + """Submit, poll, and fetch a named solution through the MCP protocol.""" + submitted = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limit": 30.0}, + ) + assert "error" not in submitted, submitted + job_id = submitted["job_id"] + assert submitted["num_variables"] == 2 + + for _ in range(120): + state = await _call(session, "cuopt_status", job_id=job_id) + if state["terminal"]: + break + await __import__("asyncio").sleep(0.5) + assert state["status"] == "COMPLETED", state + + solved = await _call( + session, "cuopt_result", job_id=job_id, names_from=mps_file + ) + assert solved["ready"] is True + assert solved["primal_objective"] == pytest.approx(10.0, abs=1e-4) + total = sum(solved["variables"].values()) + assert total == pytest.approx(10.0, abs=1e-4) + + +@pytest.mark.anyio +async def test_invalid_setting_is_rejected_before_submission( + session, mps_file +): + """A typo must come back as a named error, not a silently ignored field.""" + out = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limt": 5.0}, + ) + assert "error" in out + assert "did you mean time_limit" in out["error"] + + +@pytest.mark.anyio +async def test_missing_file_reports_cleanly(session): + out = await _call(session, "cuopt_solve_lp", problem_path="/no/such.mps") + assert "problem file not found" in out["error"] + + +@pytest.mark.anyio +async def test_enum_setting_is_accepted_by_name(session, mps_file): + """Regression: settings={"method": "Barrier"} must reach the solver. + + cuOpt's set_parameter takes an integer for enum settings, so passing the + readable name straight through failed with "value Barrier is not an + integer". + """ + out = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"method": "Barrier", "time_limit": 30.0}, + ) + assert "error" not in out, out + assert out["job_id"] + + +@pytest.mark.anyio +async def test_termination_status_is_readable(session, mps_file): + """Regression: status must be a name, not the IntEnum's bare number.""" + sub = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limit": 30.0}, + ) + while not (await _call(session, "cuopt_status", job_id=sub["job_id"]))[ + "terminal" + ]: + await __import__("asyncio").sleep(0.3) + out = await _call(session, "cuopt_result", job_id=sub["job_id"]) + assert out["termination_status"] == "Optimal" + assert out["termination_status_code"] == 1 diff --git a/python/cuopt_mcp/tests/test_parameter_names.py b/python/cuopt_mcp/tests/test_parameter_names.py new file mode 100644 index 000000000..0eb249fdf --- /dev/null +++ b/python/cuopt_mcp/tests/test_parameter_names.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Every advertised setting must be one cuOpt actually accepts. + +The registry field name is the *proto* field name, which frequently differs +from the ``CUOPT_*`` string parameter (``relative_mip_gap`` vs +``mip_relative_gap``, ``mir_cuts`` vs ``mip_mixed_integer_rounding_cuts``). +Without this check a renamed or newly added field reaches an agent as a +setting that fails at solve time with "Invalid parameter". + +Parsed from the in-repo constants.h rather than the installed cuOpt, so the +check stays consistent with the registry it is validating even when the +environment has a different cuOpt version installed. +""" + +import re +from pathlib import Path + +import pytest + +from cuopt_mcp import schema + +CONSTANTS_H = ( + Path(__file__).resolve().parents[3] + / "cpp" + / "include" + / "cuopt" + / "mathematical_optimization" + / "constants.h" +) + +pytestmark = pytest.mark.skipif( + not CONSTANTS_H.is_file(), reason="not a source checkout" +) + + +def cuopt_parameter_names() -> set: + text = CONSTANTS_H.read_text() + # #define CUOPT_X \ "x" — join continuations before matching. + text = re.sub(r"\\\s*\n\s*", " ", text) + return { + m.group(2) + for m in re.finditer(r'#define\s+(CUOPT_\w+)\s+"(\w+)"', text) + } + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_advertised_setting_is_a_real_cuopt_parameter(kind): + valid = cuopt_parameter_names() + assert valid, "failed to parse any parameter names from constants.h" + bad = {} + for name, prop in schema.settings_schema(kind)["properties"].items(): + resolved = prop.get("x-parameter-name", name) + if resolved not in valid: + bad[name] = resolved + assert not bad, ( + f"{kind} advertises settings cuOpt will reject: {bad}. Add or fix " + "`param_name:` in field_registry.yaml, then ./build.sh codegen." + ) + + +def test_known_divergent_names_are_mapped(): + """Spot-check the renames that motivated param_name.""" + mip = schema.settings_schema("mip_settings")["properties"] + assert mip["relative_mip_gap"]["x-parameter-name"] == "mip_relative_gap" + assert mip["mir_cuts"]["x-parameter-name"] == ( + "mip_mixed_integer_rounding_cuts" + ) + assert mip["seed"]["x-parameter-name"] == "random_seed" + pdlp = schema.settings_schema("pdlp_settings")["properties"] + assert pdlp["detect_infeasibility"]["x-parameter-name"] == ( + "infeasibility_detection" + ) + + +def test_unsettable_field_is_not_advertised(): + """presolve_absolute_tolerance has no CUOPT_* constant, so it is omitted.""" + assert ( + "presolve_absolute_tolerance" + not in schema.settings_schema("mip_settings")["properties"] + ) diff --git a/python/cuopt_mcp/tests/test_schema.py b/python/cuopt_mcp/tests/test_schema.py new file mode 100644 index 000000000..0e53b02a8 --- /dev/null +++ b/python/cuopt_mcp/tests/test_schema.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the generated settings schema and its validation.""" + +import pytest + +from cuopt_mcp import schema + + +def test_both_settings_sections_present(): + doc = schema.load() + assert set(doc["settings"]) == {"pdlp_settings", "mip_settings"} + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_parameter_is_documented(kind): + """Every generated property carries a description. + + Guards the registry: a field added without `description:`/`default:` + would reach an agent as a bare name and type, which is unusable for + deciding whether to set it. + """ + props = schema.settings_schema(kind)["properties"] + assert props + undocumented = [n for n, p in props.items() if not p.get("description")] + assert undocumented == [] + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_parameter_states_a_default(kind): + props = schema.settings_schema(kind)["properties"] + missing = [ + n + for n, p in props.items() + if "Default:" not in p.get("description", "") + ] + assert missing == [] + + +def test_enum_parameters_expose_their_values(): + mode = schema.settings_schema("pdlp_settings")["properties"][ + "pdlp_solver_mode" + ] + assert mode["type"] == "string" + assert "Stable3" in mode["enum"] + + +def test_sentinel_field_tells_client_to_omit_not_send_minus_one(): + """iteration_limit's -1 encoding must not leak into agent-facing text. + + The wire encodes "no limit" as -1, but a client should express that by + omitting the field; advertising -1 invites sending it as a literal + iteration count. + """ + prop = schema.settings_schema("pdlp_settings")["properties"][ + "iteration_limit" + ] + assert "Omit" in prop["description"] + assert "-1" not in prop["description"] + + +def test_settings_schema_is_closed(): + for kind in ("pdlp_settings", "mip_settings"): + assert schema.settings_schema(kind)["additionalProperties"] is False + + +def test_validate_accepts_known_settings(): + schema.validate_settings("pdlp_settings", {"time_limit": 5.0}) + schema.validate_settings("pdlp_settings", {"method": "Barrier"}) + + +def test_validate_rejects_unknown_setting_with_suggestion(): + with pytest.raises(ValueError, match="did you mean time_limit"): + schema.validate_settings("pdlp_settings", {"time_limt": 5.0}) + + +def test_validate_rejects_bad_enum_value(): + with pytest.raises(ValueError, match="must be one of"): + schema.validate_settings("pdlp_settings", {"method": "Simplex"}) + + +def test_validate_rejects_wrong_type(): + with pytest.raises(ValueError, match="must be a number"): + schema.validate_settings("pdlp_settings", {"time_limit": "fast"}) + + +def test_enum_parameters_carry_a_name_to_integer_mapping(): + """Enum settings must ship the integer cuOpt's parameter interface wants. + + Callers show a model the readable name ("Barrier") but set_parameter + rejects it with "value Barrier is not an integer", so the schema has to + carry the mapping rather than leave each client to hand-write it. + """ + method = schema.settings_schema("pdlp_settings")["properties"]["method"] + mapping = method["x-enum-values"] + assert set(mapping) == set(method["enum"]) + assert all(isinstance(v, int) for v in mapping.values()) + # Must agree with constants.h (CUOPT_METHOD_CONCURRENT 0, PDLP 1, ...) + assert mapping["Concurrent"] == 0 + assert mapping["PDLP"] == 1 + assert mapping["DualSimplex"] == 2 + assert mapping["Barrier"] == 3 + + +def test_non_enum_parameters_have_no_mapping(): + prop = schema.settings_schema("pdlp_settings")["properties"]["time_limit"] + assert "x-enum-values" not in prop diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py new file mode 100644 index 000000000..3e0c72225 --- /dev/null +++ b/python/cuopt_mcp/tests/test_tools.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tool behaviour with a stubbed gRPC client. + +These run without a GPU or a cuopt_grpc_server; the live path is covered by +test_end_to_end.py. +""" + +import pytest + +from cuopt_mcp import client, tools + + +class FakeSolution: + def __init__(self, values, names=None): + self._values = values + self._names = names + + def get_primal_solution(self): + return self._values + + def get_vars(self): + return dict(zip(self._names, self._values)) if self._names else {} + + def get_primal_objective(self): + return 42.0 + + def get_solve_time(self): + return 1.5 + + def get_termination_status(self): + import enum + + class LPTerminationStatus(enum.IntEnum): + Optimal = 1 + + return LPTerminationStatus.Optimal + + def get_termination_reason(self): + return "Optimal solution found" + + +class FakeClient: + def __init__(self, solution=None): + self.solution = solution + self.cancelled = [] + + def result(self, job_id, variable_names=None): + return self.solution + + def cancel(self, job_id): + self.cancelled.append(job_id) + + def incumbents(self, job_id, from_index=0): + return [(10.0, None), (8.0, None)][from_index:] + + def logs(self, job_id, from_byte=0): + return "\n".join(f"line {i}" for i in range(10)) + + +@pytest.fixture +def fake(monkeypatch): + def _install(solution=None): + stub = FakeClient(solution) + monkeypatch.setattr(tools, "get_client", lambda: stub) + return stub + + yield _install + client.reset_client() + + +def test_result_reports_not_ready_without_raising(fake): + fake(None) + out = tools.result("job-1") + assert out["ready"] is False + assert "cuopt_status" in out["hint"] + + +def test_result_returns_summary_and_named_values(fake): + fake(FakeSolution([1.0, 0.0, 3.0], names=["x", "y", "z"])) + out = tools.result("job-1") + assert out["primal_objective"] == 42.0 + # IntEnum: str() would give "1", which tells a caller nothing. + assert out["termination_status"] == "Optimal" + assert out["termination_status_code"] == 1 + assert out["variables"] == {"x": 1.0, "y": 0.0, "z": 3.0} + + +def test_result_nonzero_only_filters(fake): + fake(FakeSolution([1.0, 0.0, 3.0], names=["x", "y", "z"])) + out = tools.result("job-1", nonzero_only=True) + assert out["variables"] == {"x": 1.0, "z": 3.0} + assert out["num_nonzero"] == 2 + + +def test_result_named_lookup_reports_missing(fake): + fake(FakeSolution([1.0, 2.0], names=["x", "y"])) + out = tools.result("job-1", variables=["x", "nope"]) + assert out["variables"] == {"x": 1.0} + assert out["missing_variables"] == ["nope"] + + +def test_large_solution_is_written_to_file_not_inlined( + fake, tmp_path, monkeypatch +): + """A big solution must not be returned inline. + + The binding limit is the model's context window, so past `limit` the + values go to a file and only a pointer comes back. + """ + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + n = 5000 + fake( + FakeSolution( + [float(i) for i in range(n)], names=[f"x{i}" for i in range(n)] + ) + ) + out = tools.result("job-big", limit=10) + assert out["variables_truncated"] is True + assert len(out["variables"]) == 10 + assert out["num_variables"] == n + written = tmp_path / "job-big.json" + assert written.is_file() + import json + + assert len(json.loads(written.read_text())) == n + + +def test_unnamed_solution_falls_back_to_indices_with_a_hint(fake): + fake(FakeSolution([1.0, 2.0])) + out = tools.result("job-1") + assert out["variables"] == {"0": 1.0, "1": 2.0} + assert "names_from" in out["names"] + + +def test_incumbents_paginate(fake): + fake() + out = tools.incumbents("job-1", from_index=1) + assert out["count"] == 1 + assert out["incumbents"][0]["index"] == 1 + assert out["next_index"] == 2 + + +def test_logs_tail_is_bounded(fake): + fake() + out = tools.logs("job-1", tail_lines=3) + assert out["lines"] == ["line 7", "line 8", "line 9"] + assert out["truncated"] is True + + +def test_cancel(fake): + stub = fake() + assert tools.cancel("job-1")["cancelled"] is True + assert stub.cancelled == ["job-1"] + + +def test_missing_problem_file_is_a_clear_error(): + with pytest.raises(client.CuOptMCPError, match="problem file not found"): + tools.submit("/nonexistent/model.mps", "pdlp_settings") + + +def test_list_settings_names_and_detail(): + listing = tools.list_settings("pdlp_settings") + assert "time_limit" in listing["parameters"] + detail = tools.list_settings("pdlp_settings", name="pdlp_solver_mode") + assert "Stable3" in detail["enum"] + + +def test_list_settings_rejects_bad_kind(): + with pytest.raises(client.CuOptMCPError, match="mip_settings"): + tools.list_settings("nonsense") + + +def test_unreachable_server_message_names_the_endpoint(monkeypatch): + monkeypatch.setenv("CUOPT_REMOTE_HOST", "gpu-host") + monkeypatch.setenv("CUOPT_REMOTE_PORT", "50999") + err = client.describe_connection_error(RuntimeError("UNAVAILABLE")) + assert "gpu-host:50999" in str(err) diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index aa488e064..07191a030 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -167,6 +167,18 @@ cuopt/ - Never suggest `--no-verify` or skipping checks - All PRs must pass CI +### Never Hand-Edit Generated Files +Edit the source, run the generator, commit both. A generated file usually says so in its first line or two — check before editing anything unfamiliar. + +| Generated | Source | Regenerate with | +|-----------|--------|-----------------| +| `cpp/src/grpc/codegen/generated/` | `cpp/src/grpc/codegen/field_registry.yaml` | `./build.sh codegen` | +| `conda/environments/*.yaml`, `pyproject.toml` | `dependencies.yaml` | `pre-commit run --all-files` | +| `docs/cuopt/source/versions1.json` | `ci/utils/update_doc_versions.py` | `pre-commit run --all-files` | +| `version:` in `skills/*/SKILL.md`, plugin/marketplace JSONs | `VERSION` | `pre-commit run --all-files` | + +Only the gRPC codegen needs an explicit command — the rest are pre-commit hooks that fix the file for you, so a `git commit` that trips one just needs the regenerated file staged and re-committed. **The gRPC codegen never runs on its own**, so a missed `./build.sh codegen` surfaces only as a CI failure in `ci/verify_grpc_codegen.sh`. See [gRPC wire fields](#grpc-wire-fields-and-codegen). + ### CUDA/GPU Hygiene - Keep operations stream-ordered - Follow existing RAFT/RMM patterns @@ -246,6 +258,8 @@ For build/test pitfalls (Cython rebuild, OOM, CUDA driver mismatch, missing `nvc | Conda environments | `conda/environments/` | | Test data | `datasets/` | | CI scripts | `ci/` | +| gRPC field registry | `cpp/src/grpc/codegen/field_registry.yaml` | +| gRPC generated output | `cpp/src/grpc/codegen/generated/` (never hand-edit) | ## Canonical Documentation @@ -257,6 +271,14 @@ For build/test pitfalls (Cython rebuild, OOM, CUDA driver mismatch, missing `nvc _Shell-execution, install, conda-env, and sudo policies are covered by [Refusal Rules — Read First](#refusal-rules--read-first) at the top of this skill._ +## gRPC wire fields and codegen + +When adding or changing anything that crosses the cuOpt gRPC wire — a problem field, a solution field, or a solver setting — read: + +- **`references/grpc_codegen.md`** — the `field_registry.yaml` → `./build.sh codegen` → commit-`generated/` workflow, the `optional` / `sentinel` presence traps, field-number permanence, and why the C++ member initializer (not the docs) is ground truth for a setting's default. + +Read it **before** editing `field_registry.yaml` or any file under `cpp/src/grpc/codegen/generated/`. Codegen never runs as part of a normal build, so a skipped regeneration surfaces only as a CI failure. + ## VRP dimension internals (routing engine) When implementing or debugging **VRP dimensions** (constraints, objectives, forward/backward propagation, `combine`, local-search deltas), read: diff --git a/skills/cuopt-developer/references/grpc_codegen.md b/skills/cuopt-developer/references/grpc_codegen.md new file mode 100644 index 000000000..9d63041ec --- /dev/null +++ b/skills/cuopt-developer/references/grpc_codegen.md @@ -0,0 +1,113 @@ +# gRPC Wire Fields and the Codegen Registry + +Read this before touching anything that crosses the cuOpt gRPC wire: problem +fields, solution fields, or solver settings. + +## The one rule + +**Never hand-edit files under `cpp/src/grpc/codegen/generated/`.** They are +generated from `cpp/src/grpc/codegen/field_registry.yaml` by +`cpp/src/grpc/codegen/generate_conversions.py`, and CI will reject a mismatch. + +Every field that crosses the wire is declared once in the registry. One entry +drives ~28 generated artifacts: the `.proto`, the C++ to/from-proto converters, +the chunked upload/download paths, and the size estimator. + +## Workflow + +```bash +# 1. edit cpp/src/grpc/codegen/field_registry.yaml +# 2. regenerate +./build.sh codegen +# 3. commit BOTH the registry and cpp/src/grpc/codegen/generated/ +# 4. optional local pre-check of what CI runs +bash ci/verify_grpc_codegen.sh +``` + +**Codegen is not part of the build.** `build.sh` guards it behind an explicit +`codegen` argument, and CMake only *consumes* the checked-in `generated/` +directory. Building cuOpt will never regenerate for you, and it will never warn +you that the registry has drifted — `ci/verify_grpc_codegen.sh` is what catches +that, in CI, after you push. + +`./build.sh codegen` needs only `pyyaml` — no GPU, no compile. It is cheap to +run and cheap to re-run; there is no reason to skip it. + +## Registry sections + +| Section | Generates | +|---------|-----------| +| `enums` | proto enums + C++ converters | +| `optimization_problem` | `OptimizationProblem` message, problem converters | +| `pdlp_settings` / `mip_settings` | `PDLPSolverSettings` / `MIPSolverSettings` | +| `lp_solution` / `mip_solution` | solution messages and converters | +| `chunked_result_header` | `ChunkedResultHeader` for the chunked download path | + +## Attributes worth knowing + +Full reference: `cpp/src/grpc/codegen/FIELD_REGISTRY_REFERENCE.md`. The ones +that bite: + +- **`optional`** — emits proto3 presence tracking. Required whenever the C++ + default differs from the proto3 zero value. Without it, a client that *omits* + the field silently overwrites the solver default with `0` / `false` / the + first enum value. `bool foo{true}` and enums whose C++ default is not the + first declared value both need this. +- **`sentinel`** — maps a C++ sentinel (e.g. `numeric_limits::max()`) to a + reserved wire value (e.g. `-1`). Composes with `optional`: the sentinel covers + the explicitly-sent case, `optional` covers the omitted case. `iteration_limit` + and `node_limit` need both. +- **`description`** / **`default`** — documentation for a settings field, + emitted into `cuopt_mcp_schema.json`. `description` becomes the JSON Schema + `description`, which is what an MCP client shows a model deciding whether to + set the field, so write it for someone choosing a value rather than for + someone reading the struct. `default` is a free-text string describing the + C++ member initializer (`"1e-4"`, `"-1 (automatic)"`); the generator neither + derives nor validates it, so take it from the C++ struct — `docs/` is known + to disagree in several places. +- **`param_name`** — the `CUOPT_*` string parameter a client passes to + `set_parameter`, when it differs from the field name. The field name is the + *proto* name, and MIP diverges heavily: `relative_mip_gap` is + `mip_relative_gap`, `mir_cuts` is `mip_mixed_integer_rounding_cuts`, `seed` + is `random_seed`. **44 of 85 settings fields need this.** Omitting it on a + diverging field produces a setting that fails at solve time with "Invalid + parameter". Set it to `null` for a field with no `CUOPT_*` constant at all, + which drops it from the MCP schema. + `python/cuopt_mcp/tests/test_parameter_names.py` asserts every advertised + name exists in `constants.h`. + +## Field numbers are permanent + +`field_num` and `array_id` are wire identifiers. Never renumber or reuse a +retired number — older clients still send data on it. The registry records +retired numbers in comments (see the note on 37/38 in `mip_settings`); follow +that convention when removing a field. + +## Adding a settings field: checklist + +1. Add the C++ member with its initializer. +2. Add the registry entry with the next free `field_num`. +3. Add `optional:` if the C++ default is not the proto3 zero value. +4. Add `description:` and `default:` — match the C++ initializer, not the docs + (see below). +5. `./build.sh codegen`, commit registry + `generated/`. +6. Document the user-facing parameter in `docs/cuopt/source/convex-settings.rst` + (LP) or `mip-settings.rst` (MIP), and add the constant to + `cpp/include/cuopt/mathematical_optimization/constants.h`. + +## Documented defaults drift from the code + +`docs/cuopt/source/*-settings.rst` is the prose source for what a setting means, +but its stated defaults have been observed to disagree with the C++ member +initializers. **The C++ initializer is ground truth.** When writing `default:`, +read the struct — `pdlp/solver_settings.hpp`, `mip/solver_settings.hpp`, +`mip/heuristics_hyper_params.hpp` — and treat a docs mismatch as a docs bug to +report separately, not as something to propagate into the registry. + +## The registry is not a complete mirror of the settings structs + +Some settings exist in C++ and in the user docs but have no registry entry, +which means they cannot be set over gRPC at all. Before assuming a parameter is +remotely settable, grep `field_registry.yaml` for it. Adding a missing one is a +wire change (new `field_num`), not a documentation change — scope it as its own +PR.