From aac94dea244918d5122a8d14a9d09e1a36e0bf66 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 11 Aug 2026 11:37:27 -0500 Subject: [PATCH 1/6] Document solver settings in the gRPC codegen field registry Adds `description:` and `default:` attributes to field_registry.yaml and emits them as leading comments on the generated proto fields. The generated .proto is part of the public wire contract (GRPC_INTERFACE.md, "Custom Clients"), but carried no comments, so a third-party client could not learn what a settings field means or what omitting it does. Covers all 85 fields in pdlp_settings and mip_settings. Two fields (postsolve_info, presolve_absolute_tolerance) have no documentation anywhere, so they carry only `default:` plus a note to add prose later. `default:` values are taken from the C++ member initializers rather than from docs/cuopt/source/*-settings.rst, which disagrees in four places (barrier_iterative_refinement, barrier_step_scale, pdlp_precision, semi_continuous_big_m). Those look like docs bugs and are left for a separate change. Fields without either attribute generate byte-identical output, so the only generated file that changes is cuopt_remote_data.proto, and the change is purely additive comments. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 388 ++++++++++++++++++ cpp/src/grpc/codegen/generate_conversions.py | 44 +- .../codegen/generated/cuopt_remote_data.proto | 235 +++++++++++ 3 files changed, 666 insertions(+), 1 deletion(-) diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 2fb7896027..e1bb9b202d 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -38,6 +38,17 @@ # Attributes (all optional unless noted): # # Per-field: +# description – human-readable meaning of the field, emitted as a +# leading comment on the generated proto field so a +# third-party client reading only the .proto learns +# what it does (see GRPC_INTERFACE.md, "Custom +# Clients"). Currently consumed for settings messages. +# 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 generated proto +# comment as "(default: ...)". Documentation only — +# the generator neither derives nor validates it, and +# it does not affect the wire format. # 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 +437,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 +513,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. Encoded on the wire as -1 for "no limit". + default: "no limit (INT_MAX)" field_num: 10 type: int64 sentinel: max_as_negative_1 @@ -467,13 +526,27 @@ 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: + 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 +555,31 @@ 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: + 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 +589,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 +691,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 +703,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,52 +761,103 @@ 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: + 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: + 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: + description: >- + How close to an integer a variable must be to count as integral. + default: "1e-5" field_num: 4 optional: true - absolute_tolerance: + description: MIP absolute tolerance. + default: "1e-6" field_num: 5 optional: true - relative_tolerance: + description: MIP relative tolerance. + default: "1e-12" field_num: 6 optional: true - presolve_absolute_tolerance: + # 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: + 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: + 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 @@ -635,18 +866,33 @@ mip_settings: # 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. Encoded on the + wire as -1 for "no limit". + default: "no limit (INT_MAX)" field_num: 15 type: int32 sentinel: max_as_negative_1 @@ -654,69 +900,147 @@ mip_settings: # Branching - 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: + 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: + 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: + 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: + 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: + description: >- + Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. + default: "-1 (automatic)" field_num: 21 type: int32 optional: true - 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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 @@ -728,6 +1052,13 @@ mip_settings: # `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 +1068,14 @@ 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: + 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 +1085,19 @@ 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: + description: Maximum number of solutions held in the solution pool. + default: "32" field_num: 35 type: int32 optional: true - num_cpufj_threads: + description: Number of parallel CPU Feasibility Jump climbers. + default: "8" field_num: 36 type: int32 optional: true @@ -759,54 +1105,96 @@ mip_settings: # presolve stopped taking a wall budget. Do not reuse: an older client # still sends them on those numbers. - 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: + description: Hard cap in seconds on the root LP solve. + default: "15.0" field_num: 40 optional: true - 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: + description: >- + Ceiling in seconds on the adaptive RINS time budget. + default: "20.0" field_num: 42 optional: true - rins_fix_rate: + description: Fraction of variables RINS fixes before solving the sub-MIP. + default: "0.5" field_num: 43 optional: true - 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: + description: Depth of the diversity step taken after stagnation. + default: "8" field_num: 45 type: int32 optional: true - initial_infeasibility_weight: + description: Seed value for the constraint violation penalty. + default: "1000.0" field_num: 46 optional: true - 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: + 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: + 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: + 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: + description: >- + Time in seconds allowed for building the related-variable structure. + default: "30.0" field_num: 51 optional: true - 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: + 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 b7088a677e..9a681bc3a4 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -334,6 +334,46 @@ def _array_wire_type_comment(f): return f"raw bytes ({size} B/elem)" +_DOC_COMMENT_WIDTH = 78 + + +def _field_doc_comment(f, indent=" "): + """Render a field's `description:` / `default:` registry attributes as + proto leading comment lines. + + The generated proto is part of the public wire contract (see + GRPC_INTERFACE.md, "Custom Clients"), so a third-party client reading + only the .proto should learn what a settings field means and what it + does when omitted. Returns [] when the field carries neither + attribute, so undocumented fields emit exactly as before. + + `default:` is rendered verbatim from the registry — it is a string + describing the C++ member initializer, not a value the generator + derives or validates. + """ + description = f.get("description") + default = f.get("default") + if not description and default is None: + return [] + body = " ".join(str(description).split()) if description else "" + if default is not None: + suffix = f"(default: {default})" + body = f"{body} {suffix}" if body else suffix + prefix = f"{indent}// " + width = max(_DOC_COMMENT_WIDTH - len(prefix), 20) + lines, current = [], "" + for word in body.split(): + candidate = f"{current} {word}" if current else word + if current and len(candidate) > width: + lines.append(f"{prefix}{current}") + current = word + else: + current = candidate + if current: + lines.append(f"{prefix}{current}") + return lines + + # ============================================================================ # Enum helpers — convention-based derivation # ============================================================================ @@ -1264,7 +1304,9 @@ def generate_settings_message_proto(registry, message_name, obj): continue ptype = _settings_field_proto_type(registry, f) prefix = "optional " if f.get("optional") else "" - lines.append((num, f" {prefix}{ptype} {f['name']} = {num};")) + decl = f" {prefix}{ptype} {f['name']} = {num};" + doc = _field_doc_comment(f) + lines.append((num, "\n".join(doc + [decl]))) lines.extend(_iter_embeds(obj)) lines.sort(key=lambda x: x[0]) return "\n".join(item[1] for item in lines) diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 4b1e36d134..1f45454b16 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -161,94 +161,329 @@ message OptimizationProblem { } message PDLPSolverSettings { + // Absolute term in PDLP's duality gap check: duality_gap < + // absolute_gap_tolerance + relative_gap_tolerance * (|primal_objective| + + // |dual_objective|). (default: 1e-4) optional double absolute_gap_tolerance = 1; + // Relative term in PDLP's duality gap check; multiplies (|primal_objective| + // + |dual_objective|). Significant impact on accuracy and runtime. + // (default: 1e-4) optional double relative_gap_tolerance = 2; + // Tolerance used when PDLP declares the problem primal infeasible. Only + // consulted when detect_infeasibility is enabled. (default: 1e-10) optional double primal_infeasible_tolerance = 3; + // Tolerance used when PDLP declares the problem dual infeasible + // (unbounded). Only consulted when detect_infeasibility is enabled. + // (default: 1e-10) optional double dual_infeasible_tolerance = 4; + // Absolute term in PDLP's dual feasibility check: dual_feasibility < + // absolute_dual_tolerance + relative_dual_tolerance * l2_norm(c). (default: + // 1e-4) optional double absolute_dual_tolerance = 5; + // Relative term in PDLP's dual feasibility check; multiplies the objective + // vector L2 norm. (default: 1e-4) optional double relative_dual_tolerance = 6; + // Absolute term in PDLP's primal feasibility check: primal_feasibility < + // absolute_primal_tolerance + relative_primal_tolerance * l2_norm(b). + // (default: 1e-4) optional double absolute_primal_tolerance = 7; + // Relative term in PDLP's primal feasibility check; multiplies the + // right-hand-side vector L2 norm. (default: 1e-4) optional double relative_primal_tolerance = 8; + // 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)) optional double time_limit = 9; + // 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. Encoded on the wire as -1 for "no limit". (default: no limit + // (INT_MAX)) optional int64 iteration_limit = 10; + // Whether the solver writes log output to the console. Logs may still be + // written to a file when this is false. (default: true) optional bool log_to_console = 11; + // 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) bool detect_infeasibility = 12; + // When true, PDLP stops if either the current or the average solution is + // detected infeasible. When false, both must be detected infeasible. + // (default: false) bool strict_infeasibility = 13; + // 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) optional PDLPSolverMode pdlp_solver_mode = 14; + // 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) LPMethod method = 15; + // 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)) optional int32 presolver = 16; + // 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) optional bool dual_postsolve = 17; + // 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) bool crossover = 18; + // 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) optional int32 num_gpus = 19; + // Whether PDLP computes primal and dual residuals per constraint instead of + // globally. (default: false) bool per_constraint_residual = 20; + // Whether cuDSS runs in deterministic mode. Deterministic mode makes + // results reproducible across runs but may be slower. (default: false) bool cudss_deterministic = 21; + // Barrier: whether to fold the LP, reducing problem size by exploiting + // symmetry. -1 automatic, 0 disabled, 1 forced. (default: -1 (automatic)) optional int32 folding = 22; + // 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)) optional int32 augmented = 23; + // 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)) optional int32 dualize = 24; + // 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)) optional int32 ordering = 25; + // 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)) optional int32 barrier_dual_initial_point = 26; + // 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) optional bool eliminate_dense_columns = 27; + // 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) bool save_best_primal_so_far = 28; + // 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) bool first_primal_feasible = 29; + // 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) optional int32 pdlp_precision = 30; + // Barrier: whether iterative refinement runs after each barrier solve to + // improve solution accuracy (see cpp/src/barrier/barrier.cu). (default: + // true) optional bool barrier_iterative_refinement = 31; + // 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) optional double barrier_step_scale = 32; + // (default: -1) optional int32 postsolve_info = 33; PDLPWarmStartData warm_start_data = 50; } message MIPSolverSettings { + // 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)) optional double time_limit = 1; + // 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) optional double relative_mip_gap = 2; + // Absolute gap at which the solve terminates: best_objective - dual_bound + // when minimizing, dual_bound - best_objective when maximizing. (default: + // 1e-10) optional double absolute_mip_gap = 3; + // How close to an integer a variable must be to count as integral. + // (default: 1e-5) optional double integrality_tolerance = 4; + // MIP absolute tolerance. (default: 1e-6) optional double absolute_tolerance = 5; + // MIP relative tolerance. (default: 1e-12) optional double relative_tolerance = 6; + // (default: 1e-6) optional double presolve_absolute_tolerance = 7; + // Whether the solver writes log output to the console. Logs may still be + // written to a file when this is false. (default: true) optional bool log_to_console = 8; + // 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) bool heuristics_only = 9; + // 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)) optional int32 num_cpu_threads = 10; + // Number of GPUs used for the solve. (default: 1) optional int32 num_gpus = 11; + // Which presolver performs presolve reductions: 0 disables presolve, 1 + // selects Papilo, 2 selects PSLP. MIP uses Papilo by default. (default: + // Default (Papilo for MIP)) optional int32 presolver = 12; + // Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied but + // not to the objective. (default: 2 (no objective scaling)) optional int32 mip_scaling = 13; + // 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)) optional double work_limit = 14; + // 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. Encoded on the wire as -1 + // for "no limit". (default: no limit (INT_MAX)) optional int32 node_limit = 15; + // 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)) optional int32 reliability_branching = 16; + // 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)) int32 mip_batch_pdlp_strong_branching = 17; + // Maximum number of cut passes to run. 0 disables cuts entirely; larger + // values perform more passes. (default: 10) optional int32 max_cut_passes = 18; + // Whether mixed-integer rounding cuts are used. -1 automatic (the solver + // decides from problem characteristics), 0 disabled, 1 enabled. (default: + // -1 (automatic)) optional int32 mir_cuts = 19; + // Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 + // enabled. (default: -1 (automatic)) optional int32 mixed_integer_gomory_cuts = 20; + // Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. + // (default: -1 (automatic)) optional int32 knapsack_cuts = 21; + // Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. + // (default: -1 (automatic)) optional int32 clique_cuts = 22; + // Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 + // enabled. (default: -1 (automatic)) optional int32 strong_chvatal_gomory_cuts = 23; + // 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)) optional int32 reduced_cost_strengthening = 24; + // 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)) optional double cut_change_threshold = 25; + // 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) optional double cut_min_orthogonality = 26; + // 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)) int32 determinism_mode = 27; + // Random seed. A fixed seed gives reproducible results when running in + // deterministic mode. (default: -1 (chosen automatically)) optional int32 seed = 28; + // 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) optional bool probing = 29; + // 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)) optional int32 strong_branching_simplex_iteration_limit = 30; + // Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. + // (default: -1 (automatic)) optional int32 implied_bound_cuts = 31; + // Whether reliability-branching candidates are evaluated simultaneously + // with a single batched PDLP solve. 0 disabled, 1 enabled. (default: 0 + // (disabled)) int32 mip_batch_pdlp_reliability_branching = 32; + // Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital + // fixing, 2 orbital fixing plus lexical reduction. (default: -1 + // (automatic)) optional int32 symmetry = 33; + // 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) optional double semi_continuous_big_m = 34; + // Maximum number of solutions held in the solution pool. (default: 32) optional int32 population_size = 35; + // Number of parallel CPU Feasibility Jump climbers. (default: 8) optional int32 num_cpufj_threads = 36; + // Fraction of the total time budget given to the root LP. (default: 0.1) optional double root_lp_time_ratio = 39; + // Hard cap in seconds on the root LP solve. (default: 15.0) optional double root_lp_max_time = 40; + // Per-call time budget in seconds for the RINS sub-MIP. (default: 3.0) optional double rins_time_limit = 41; + // Ceiling in seconds on the adaptive RINS time budget. (default: 20.0) optional double rins_max_time_limit = 42; + // Fraction of variables RINS fixes before solving the sub-MIP. (default: + // 0.5) optional double rins_fix_rate = 43; + // Number of Feasibility Pump loops without improvement before recombination + // is triggered. (default: 3) optional int32 stagnation_trigger = 44; + // Depth of the diversity step taken after stagnation. (default: 8) optional int32 max_iterations_without_improvement = 45; + // Seed value for the constraint violation penalty. (default: 1000.0) optional double initial_infeasibility_weight = 46; + // Number of local minima after which the Feasibility Jump baseline exits. + // (default: 7000) optional int32 n_of_minimums_for_exit = 47; + // Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, + // 4 local search, 8 sub-MIP. (default: 15 (all enabled)) optional int32 enabled_recombiners = 48; + // Size of the ring buffer used to detect Feasibility Pump assignment + // cycles. (default: 30) optional int32 cycle_detection_length = 49; + // Base time cap in seconds for relaxed LP solves inside the heuristics. + // (default: 1.0) optional double relaxed_lp_time_limit = 50; + // Time in seconds allowed for building the related-variable structure. + // (default: 30.0) optional double related_vars_time_limit = 51; + // Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. + // (default: -1 (automatic)) optional int32 zero_half_cuts = 52; + // Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. + // (default: -1 (no override)) optional int32 presolve_max_rounds = 53; + // Ceiling on Papilo's probing.minbadgesize. (default: -1 (no override)) optional int32 papilo_probing_max_badgesize = 54; } From 4bca459314bbef18016aa6d8d703f3fa9dc252f7 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 11 Aug 2026 11:42:00 -0500 Subject: [PATCH 2/6] Add gRPC codegen guidance to cuopt-developer The skill had no coverage of cpp/src/grpc/codegen/, so an agent editing a wire field could hand-edit the generated output or skip regeneration. Codegen is an explicit ./build.sh target that never runs during a normal build, so a missed regeneration surfaces only as a CI failure. Adds references/grpc_codegen.md covering the registry workflow, the optional/sentinel presence traps, field-number permanence, and the rule that the C++ member initializer rather than docs/ is ground truth for a setting's default. Adds a safety rule and pointer section in SKILL.md. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- skills/cuopt-developer/SKILL.md | 15 +++ .../references/grpc_codegen.md | 102 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 skills/cuopt-developer/references/grpc_codegen.md diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index aa488e064b..5268dc1c8e 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -167,6 +167,11 @@ cuopt/ - Never suggest `--no-verify` or skipping checks - All PRs must pass CI +### Never Hand-Edit Generated Files +- `cpp/src/grpc/codegen/generated/` is generated from `field_registry.yaml` +- Edit the registry, then run `./build.sh codegen`, then commit both +- Codegen is an explicit build target, never automatic — see [gRPC wire fields](#grpc-wire-fields-and-codegen) + ### CUDA/GPU Hygiene - Keep operations stream-ordered - Follow existing RAFT/RMM patterns @@ -246,6 +251,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 +264,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 0000000000..02ebe99c61 --- /dev/null +++ b/skills/cuopt-developer/references/grpc_codegen.md @@ -0,0 +1,102 @@ +# 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, emitted as leading comments + on the generated proto field. The generated `.proto` is part of the public + wire contract (`cpp/src/grpc/GRPC_INTERFACE.md`, "Custom Clients"), so a + third-party client reading only the `.proto` should learn what a settings + field means and what omitting it does. `default` is a free-text string + describing the C++ member initializer (`"1e-4"`, `"-1 (automatic)"`); the + generator neither derives nor validates it. + +## 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. From 086739391cd6ffd932787a4595c1eaca470be6d0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 11 Aug 2026 11:48:51 -0500 Subject: [PATCH 3/6] Generalize the generated-files rule beyond gRPC codegen The rule only named cpp/src/grpc/codegen/generated/, which reads as if that were the only generated tree. Replaces it with a source -> generated -> regenerate table covering the dependency files, doc versions, and skill/plugin version sync, and calls out that gRPC codegen is the only one not fixed automatically by a pre-commit hook. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- skills/cuopt-developer/SKILL.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index 5268dc1c8e..07191a0303 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -168,9 +168,16 @@ cuopt/ - All PRs must pass CI ### Never Hand-Edit Generated Files -- `cpp/src/grpc/codegen/generated/` is generated from `field_registry.yaml` -- Edit the registry, then run `./build.sh codegen`, then commit both -- Codegen is an explicit build target, never automatic — see [gRPC wire fields](#grpc-wire-fields-and-codegen) +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 From 43c11b51ba429230765b4a304de811107a7d32fa Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 11:05:32 -0500 Subject: [PATCH 4/6] Add cuopt_mcp: an MCP server for LP/MILP solving over gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes cuOpt to MCP clients (Claude Code, Cursor, Codex) as a stdio subprocess holding a gRPC channel to cuopt_grpc_server. The agent host needs no GPU and no HTTP is involved. Solves are asynchronous — submitting returns a job_id and nothing blocks, because a blocking tools/call exceeds the client timeout on any realistic MILP and makes cancellation impossible. Results are shaped rather than streamed: 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 and writes large solutions to a file. The settings catalogue is generated. generate_conversions.py emits cuopt_mcp_schema.json from field_registry.yaml as a 29th artifact, so a new solver parameter reaches the server in the same commit as the proto with no MCP-specific work, guarded by ci/verify_grpc_codegen.sh. Two registry attributes were needed to make that schema usable: * param_name — the proto field name is frequently not the CUOPT_* string parameter (relative_mip_gap vs mip_relative_gap, mir_cuts vs mip_mixed_integer_rounding_cuts). 44 fields diverge; without the mapping every such setting failed at solve time with "Invalid parameter". presolve_absolute_tolerance has no constant at all and is marked null so it is not advertised. * x-enum-values — set_parameter takes the integer for an enum setting, but an agent needs the name, so the schema carries both. test_parameter_names.py asserts every advertised setting exists in constants.h, parsed from the in-repo header so the check matches the registry rather than whatever cuOpt happens to be installed. It already caught a missed mapping on the second presolver entry. Verified end to end against a live cuopt_grpc_server on a GPU: an LP and a knapsack MILP submitted, polled, and retrieved with named solutions through the real MCP protocol over stdio. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 63 ++- cpp/src/grpc/codegen/generate_conversions.py | 138 ++++++ .../codegen/generated/cuopt_mcp_schema.json | 428 ++++++++++++++++++ .../codegen/generated/cuopt_remote_data.proto | 7 +- python/cuopt_mcp/LICENSE | 201 ++++++++ python/cuopt_mcp/README.md | 74 +++ python/cuopt_mcp/cuopt_mcp/VERSION | 1 + python/cuopt_mcp/cuopt_mcp/__init__.py | 13 + python/cuopt_mcp/cuopt_mcp/client.py | 86 ++++ python/cuopt_mcp/cuopt_mcp/schema.py | 97 ++++ python/cuopt_mcp/cuopt_mcp/server.py | 185 ++++++++ python/cuopt_mcp/cuopt_mcp/tools.py | 262 +++++++++++ python/cuopt_mcp/pyproject.toml | 56 +++ python/cuopt_mcp/setup.py | 46 ++ python/cuopt_mcp/tests/conftest.py | 14 + python/cuopt_mcp/tests/test_end_to_end.py | 183 ++++++++ .../cuopt_mcp/tests/test_parameter_names.py | 82 ++++ python/cuopt_mcp/tests/test_schema.py | 108 +++++ python/cuopt_mcp/tests/test_tools.py | 179 ++++++++ 19 files changed, 2216 insertions(+), 7 deletions(-) create mode 100644 cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json create mode 100644 python/cuopt_mcp/LICENSE create mode 100644 python/cuopt_mcp/README.md create mode 100644 python/cuopt_mcp/cuopt_mcp/VERSION create mode 100644 python/cuopt_mcp/cuopt_mcp/__init__.py create mode 100644 python/cuopt_mcp/cuopt_mcp/client.py create mode 100644 python/cuopt_mcp/cuopt_mcp/schema.py create mode 100644 python/cuopt_mcp/cuopt_mcp/server.py create mode 100644 python/cuopt_mcp/cuopt_mcp/tools.py create mode 100644 python/cuopt_mcp/pyproject.toml create mode 100644 python/cuopt_mcp/setup.py create mode 100644 python/cuopt_mcp/tests/conftest.py create mode 100644 python/cuopt_mcp/tests/test_end_to_end.py create mode 100644 python/cuopt_mcp/tests/test_parameter_names.py create mode 100644 python/cuopt_mcp/tests/test_schema.py create mode 100644 python/cuopt_mcp/tests/test_tools.py diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index e1bb9b202d..c81d638cba 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -49,6 +49,17 @@ # comment as "(default: ...)". Documentation only — # the generator neither derives nor validates it, and # it does not affect the wire format. +# 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 @@ -517,7 +528,7 @@ pdlp_settings: 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. Encoded on the wire as -1 for "no limit". + wins. default: "no limit (INT_MAX)" field_num: 10 type: int64 @@ -534,6 +545,7 @@ pdlp_settings: 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 @@ -575,6 +587,7 @@ pdlp_settings: 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 @@ -772,6 +785,7 @@ mip_settings: # 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 @@ -781,6 +795,7 @@ mip_settings: 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 @@ -789,22 +804,28 @@ mip_settings: 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. @@ -822,6 +843,7 @@ mip_settings: 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 @@ -845,6 +867,7 @@ mip_settings: 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. @@ -862,6 +885,7 @@ mip_settings: 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 @@ -890,8 +914,7 @@ mip_settings: 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. Encoded on the - wire as -1 for "no limit". + together with time_limit, the first limit reached wins. default: "no limit (INT_MAX)" field_num: 15 type: int32 @@ -900,6 +923,7 @@ 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 @@ -925,6 +949,7 @@ mip_settings: 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 @@ -940,6 +965,7 @@ mip_settings: # 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. @@ -948,6 +974,7 @@ mip_settings: 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. @@ -956,6 +983,7 @@ mip_settings: 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. @@ -964,6 +992,7 @@ mip_settings: 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)" @@ -971,6 +1000,7 @@ mip_settings: 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)" @@ -978,6 +1008,7 @@ mip_settings: 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)" @@ -985,6 +1016,7 @@ mip_settings: 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. @@ -993,6 +1025,7 @@ mip_settings: 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. @@ -1001,6 +1034,7 @@ mip_settings: 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 @@ -1010,6 +1044,7 @@ mip_settings: 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 @@ -1018,6 +1053,7 @@ mip_settings: 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; @@ -1028,6 +1064,7 @@ mip_settings: # 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 @@ -1037,6 +1074,7 @@ mip_settings: field_num: 27 type: int32 - seed: + param_name: random_seed description: >- Random seed. A fixed seed gives reproducible results when running in deterministic mode. @@ -1047,6 +1085,7 @@ mip_settings: # 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 @@ -1068,6 +1107,7 @@ 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 @@ -1090,12 +1130,14 @@ mip_settings: # 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 @@ -1105,32 +1147,38 @@ 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. @@ -1139,17 +1187,20 @@ mip_settings: 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. @@ -1158,6 +1209,7 @@ mip_settings: 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. @@ -1166,6 +1218,7 @@ mip_settings: 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. @@ -1174,18 +1227,21 @@ mip_settings: 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)" @@ -1193,6 +1249,7 @@ mip_settings: 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 diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index 9a681bc3a4..d127a33bf1 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 @@ -356,6 +357,12 @@ def _field_doc_comment(f, indent=" "): if not description and default is None: return [] body = " ".join(str(description).split()) if description else "" + if f.get("sentinel") == "max_as_negative_1": + # Rendered from `sentinel:` rather than written into `description:` + # so each consumer can phrase it correctly: the proto documents the + # wire encoding, while the MCP schema tells a client to omit the + # field instead of sending the reserved value. + body = f"{body} Wire encoding: -1 means no limit.".strip() if default is not None: suffix = f"(default: {default})" body = f"{body} {suffix}" if body else suffix @@ -374,6 +381,131 @@ def _field_doc_comment(f, indent=" "): return lines +_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 # ============================================================================ @@ -3870,6 +4002,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 0000000000..7218ad9140 --- /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/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 1f45454b16..d8ac7d5ce0 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -198,8 +198,7 @@ message PDLPSolverSettings { // 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. Encoded on the wire as -1 for "no limit". (default: no limit - // (INT_MAX)) + // wins. Wire encoding: -1 means no limit. (default: no limit (INT_MAX)) optional int64 iteration_limit = 10; // Whether the solver writes log output to the console. Logs may still be // written to a file when this is false. (default: true) @@ -358,8 +357,8 @@ message MIPSolverSettings { optional double work_limit = 14; // 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. Encoded on the wire as -1 - // for "no limit". (default: no limit (INT_MAX)) + // with time_limit, the first limit reached wins. Wire encoding: -1 means no + // limit. (default: no limit (INT_MAX)) optional int32 node_limit = 15; // 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 diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE new file mode 100644 index 0000000000..6ed9218d99 --- /dev/null +++ b/python/cuopt_mcp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 NVIDIA Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md new file mode 100644 index 0000000000..f8b42507bc --- /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 0000000000..6549ba6527 --- /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 0000000000..cadf4ee8c6 --- /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 0000000000..cf9724e074 --- /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 0000000000..63867fa7db --- /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 0000000000..9c51f5fcc7 --- /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 0000000000..ef62569836 --- /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 0000000000..8189880164 --- /dev/null +++ b/python/cuopt_mcp/pyproject.toml @@ -0,0 +1,56 @@ +# 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", +] +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 = [ + "mcp>=2.0", +] +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<9.0", +] + +[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 0000000000..ad8aafaec1 --- /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 0000000000..643a9a7f05 --- /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 0000000000..e865506309 --- /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 0000000000..0eb249fdf2 --- /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 0000000000..0e53b02a82 --- /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 0000000000..3e0c722252 --- /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) From ef6facacf2d4699009686809050a5593d9714c36 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 12:21:34 -0500 Subject: [PATCH 5/6] Drop proto comments; the MCP schema is the real consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description/default attributes were rendered into cuopt_remote_data.proto because they needed some consumer to exist at all — an attribute nothing emits is not covered by verify_grpc_codegen.sh and can rot unnoticed. cuopt_mcp_schema.json is now that consumer, and a better one: it is read by every MCP client rather than by a hypothetical third-party proto reader, and it is equally covered by verify_grpc_codegen.sh. The proto rendering no longer earns its 234 lines of churn on every prose edit, so it goes, along with the now-unused comment-wrapping helper. cuopt_remote_data.proto is byte-identical to main again. Also symlink python/cuopt_mcp/LICENSE to the repo root rather than copying it, matching cuopt_self_hosted and cuopt_server. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 18 +- cpp/src/grpc/codegen/generate_conversions.py | 50 +--- .../codegen/generated/cuopt_remote_data.proto | 234 ------------------ python/cuopt_mcp/LICENSE | 202 +-------------- .../references/grpc_codegen.md | 25 +- 5 files changed, 30 insertions(+), 499 deletions(-) mode change 100644 => 120000 python/cuopt_mcp/LICENSE diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index c81d638cba..d50be98cd8 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -38,17 +38,19 @@ # Attributes (all optional unless noted): # # Per-field: -# description – human-readable meaning of the field, emitted as a -# leading comment on the generated proto field so a -# third-party client reading only the .proto learns -# what it does (see GRPC_INTERFACE.md, "Custom -# Clients"). Currently consumed for settings messages. +# 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 generated proto -# comment as "(default: ...)". Documentation only — +# "-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. +# 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 — diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index d127a33bf1..bcf891c666 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -335,52 +335,6 @@ def _array_wire_type_comment(f): return f"raw bytes ({size} B/elem)" -_DOC_COMMENT_WIDTH = 78 - - -def _field_doc_comment(f, indent=" "): - """Render a field's `description:` / `default:` registry attributes as - proto leading comment lines. - - The generated proto is part of the public wire contract (see - GRPC_INTERFACE.md, "Custom Clients"), so a third-party client reading - only the .proto should learn what a settings field means and what it - does when omitted. Returns [] when the field carries neither - attribute, so undocumented fields emit exactly as before. - - `default:` is rendered verbatim from the registry — it is a string - describing the C++ member initializer, not a value the generator - derives or validates. - """ - description = f.get("description") - default = f.get("default") - if not description and default is None: - return [] - body = " ".join(str(description).split()) if description else "" - if f.get("sentinel") == "max_as_negative_1": - # Rendered from `sentinel:` rather than written into `description:` - # so each consumer can phrase it correctly: the proto documents the - # wire encoding, while the MCP schema tells a client to omit the - # field instead of sending the reserved value. - body = f"{body} Wire encoding: -1 means no limit.".strip() - if default is not None: - suffix = f"(default: {default})" - body = f"{body} {suffix}" if body else suffix - prefix = f"{indent}// " - width = max(_DOC_COMMENT_WIDTH - len(prefix), 20) - lines, current = [], "" - for word in body.split(): - candidate = f"{current} {word}" if current else word - if current and len(candidate) > width: - lines.append(f"{prefix}{current}") - current = word - else: - current = candidate - if current: - lines.append(f"{prefix}{current}") - return lines - - _JSON_SCHEMA_TYPES = { "double": "number", "float": "number", @@ -1436,9 +1390,7 @@ def generate_settings_message_proto(registry, message_name, obj): continue ptype = _settings_field_proto_type(registry, f) prefix = "optional " if f.get("optional") else "" - decl = f" {prefix}{ptype} {f['name']} = {num};" - doc = _field_doc_comment(f) - lines.append((num, "\n".join(doc + [decl]))) + lines.append((num, f" {prefix}{ptype} {f['name']} = {num};")) lines.extend(_iter_embeds(obj)) lines.sort(key=lambda x: x[0]) return "\n".join(item[1] for item in lines) diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index d8ac7d5ce0..4b1e36d134 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -161,328 +161,94 @@ message OptimizationProblem { } message PDLPSolverSettings { - // Absolute term in PDLP's duality gap check: duality_gap < - // absolute_gap_tolerance + relative_gap_tolerance * (|primal_objective| + - // |dual_objective|). (default: 1e-4) optional double absolute_gap_tolerance = 1; - // Relative term in PDLP's duality gap check; multiplies (|primal_objective| - // + |dual_objective|). Significant impact on accuracy and runtime. - // (default: 1e-4) optional double relative_gap_tolerance = 2; - // Tolerance used when PDLP declares the problem primal infeasible. Only - // consulted when detect_infeasibility is enabled. (default: 1e-10) optional double primal_infeasible_tolerance = 3; - // Tolerance used when PDLP declares the problem dual infeasible - // (unbounded). Only consulted when detect_infeasibility is enabled. - // (default: 1e-10) optional double dual_infeasible_tolerance = 4; - // Absolute term in PDLP's dual feasibility check: dual_feasibility < - // absolute_dual_tolerance + relative_dual_tolerance * l2_norm(c). (default: - // 1e-4) optional double absolute_dual_tolerance = 5; - // Relative term in PDLP's dual feasibility check; multiplies the objective - // vector L2 norm. (default: 1e-4) optional double relative_dual_tolerance = 6; - // Absolute term in PDLP's primal feasibility check: primal_feasibility < - // absolute_primal_tolerance + relative_primal_tolerance * l2_norm(b). - // (default: 1e-4) optional double absolute_primal_tolerance = 7; - // Relative term in PDLP's primal feasibility check; multiplies the - // right-hand-side vector L2 norm. (default: 1e-4) optional double relative_primal_tolerance = 8; - // 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)) optional double time_limit = 9; - // 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. Wire encoding: -1 means no limit. (default: no limit (INT_MAX)) optional int64 iteration_limit = 10; - // Whether the solver writes log output to the console. Logs may still be - // written to a file when this is false. (default: true) optional bool log_to_console = 11; - // 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) bool detect_infeasibility = 12; - // When true, PDLP stops if either the current or the average solution is - // detected infeasible. When false, both must be detected infeasible. - // (default: false) bool strict_infeasibility = 13; - // 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) optional PDLPSolverMode pdlp_solver_mode = 14; - // 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) LPMethod method = 15; - // 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)) optional int32 presolver = 16; - // 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) optional bool dual_postsolve = 17; - // 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) bool crossover = 18; - // 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) optional int32 num_gpus = 19; - // Whether PDLP computes primal and dual residuals per constraint instead of - // globally. (default: false) bool per_constraint_residual = 20; - // Whether cuDSS runs in deterministic mode. Deterministic mode makes - // results reproducible across runs but may be slower. (default: false) bool cudss_deterministic = 21; - // Barrier: whether to fold the LP, reducing problem size by exploiting - // symmetry. -1 automatic, 0 disabled, 1 forced. (default: -1 (automatic)) optional int32 folding = 22; - // 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)) optional int32 augmented = 23; - // 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)) optional int32 dualize = 24; - // 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)) optional int32 ordering = 25; - // 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)) optional int32 barrier_dual_initial_point = 26; - // 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) optional bool eliminate_dense_columns = 27; - // 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) bool save_best_primal_so_far = 28; - // 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) bool first_primal_feasible = 29; - // 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) optional int32 pdlp_precision = 30; - // Barrier: whether iterative refinement runs after each barrier solve to - // improve solution accuracy (see cpp/src/barrier/barrier.cu). (default: - // true) optional bool barrier_iterative_refinement = 31; - // 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) optional double barrier_step_scale = 32; - // (default: -1) optional int32 postsolve_info = 33; PDLPWarmStartData warm_start_data = 50; } message MIPSolverSettings { - // 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)) optional double time_limit = 1; - // 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) optional double relative_mip_gap = 2; - // Absolute gap at which the solve terminates: best_objective - dual_bound - // when minimizing, dual_bound - best_objective when maximizing. (default: - // 1e-10) optional double absolute_mip_gap = 3; - // How close to an integer a variable must be to count as integral. - // (default: 1e-5) optional double integrality_tolerance = 4; - // MIP absolute tolerance. (default: 1e-6) optional double absolute_tolerance = 5; - // MIP relative tolerance. (default: 1e-12) optional double relative_tolerance = 6; - // (default: 1e-6) optional double presolve_absolute_tolerance = 7; - // Whether the solver writes log output to the console. Logs may still be - // written to a file when this is false. (default: true) optional bool log_to_console = 8; - // 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) bool heuristics_only = 9; - // 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)) optional int32 num_cpu_threads = 10; - // Number of GPUs used for the solve. (default: 1) optional int32 num_gpus = 11; - // Which presolver performs presolve reductions: 0 disables presolve, 1 - // selects Papilo, 2 selects PSLP. MIP uses Papilo by default. (default: - // Default (Papilo for MIP)) optional int32 presolver = 12; - // Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied but - // not to the objective. (default: 2 (no objective scaling)) optional int32 mip_scaling = 13; - // 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)) optional double work_limit = 14; - // 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. Wire encoding: -1 means no - // limit. (default: no limit (INT_MAX)) optional int32 node_limit = 15; - // 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)) optional int32 reliability_branching = 16; - // 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)) int32 mip_batch_pdlp_strong_branching = 17; - // Maximum number of cut passes to run. 0 disables cuts entirely; larger - // values perform more passes. (default: 10) optional int32 max_cut_passes = 18; - // Whether mixed-integer rounding cuts are used. -1 automatic (the solver - // decides from problem characteristics), 0 disabled, 1 enabled. (default: - // -1 (automatic)) optional int32 mir_cuts = 19; - // Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 - // enabled. (default: -1 (automatic)) optional int32 mixed_integer_gomory_cuts = 20; - // Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 knapsack_cuts = 21; - // Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 clique_cuts = 22; - // Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 - // enabled. (default: -1 (automatic)) optional int32 strong_chvatal_gomory_cuts = 23; - // 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)) optional int32 reduced_cost_strengthening = 24; - // 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)) optional double cut_change_threshold = 25; - // 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) optional double cut_min_orthogonality = 26; - // 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)) int32 determinism_mode = 27; - // Random seed. A fixed seed gives reproducible results when running in - // deterministic mode. (default: -1 (chosen automatically)) optional int32 seed = 28; - // 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) optional bool probing = 29; - // 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)) optional int32 strong_branching_simplex_iteration_limit = 30; - // Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 implied_bound_cuts = 31; - // Whether reliability-branching candidates are evaluated simultaneously - // with a single batched PDLP solve. 0 disabled, 1 enabled. (default: 0 - // (disabled)) int32 mip_batch_pdlp_reliability_branching = 32; - // Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital - // fixing, 2 orbital fixing plus lexical reduction. (default: -1 - // (automatic)) optional int32 symmetry = 33; - // 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) optional double semi_continuous_big_m = 34; - // Maximum number of solutions held in the solution pool. (default: 32) optional int32 population_size = 35; - // Number of parallel CPU Feasibility Jump climbers. (default: 8) optional int32 num_cpufj_threads = 36; - // Fraction of the total time budget given to the root LP. (default: 0.1) optional double root_lp_time_ratio = 39; - // Hard cap in seconds on the root LP solve. (default: 15.0) optional double root_lp_max_time = 40; - // Per-call time budget in seconds for the RINS sub-MIP. (default: 3.0) optional double rins_time_limit = 41; - // Ceiling in seconds on the adaptive RINS time budget. (default: 20.0) optional double rins_max_time_limit = 42; - // Fraction of variables RINS fixes before solving the sub-MIP. (default: - // 0.5) optional double rins_fix_rate = 43; - // Number of Feasibility Pump loops without improvement before recombination - // is triggered. (default: 3) optional int32 stagnation_trigger = 44; - // Depth of the diversity step taken after stagnation. (default: 8) optional int32 max_iterations_without_improvement = 45; - // Seed value for the constraint violation penalty. (default: 1000.0) optional double initial_infeasibility_weight = 46; - // Number of local minima after which the Feasibility Jump baseline exits. - // (default: 7000) optional int32 n_of_minimums_for_exit = 47; - // Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, - // 4 local search, 8 sub-MIP. (default: 15 (all enabled)) optional int32 enabled_recombiners = 48; - // Size of the ring buffer used to detect Feasibility Pump assignment - // cycles. (default: 30) optional int32 cycle_detection_length = 49; - // Base time cap in seconds for relaxed LP solves inside the heuristics. - // (default: 1.0) optional double relaxed_lp_time_limit = 50; - // Time in seconds allowed for building the related-variable structure. - // (default: 30.0) optional double related_vars_time_limit = 51; - // Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 zero_half_cuts = 52; - // Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. - // (default: -1 (no override)) optional int32 presolve_max_rounds = 53; - // Ceiling on Papilo's probing.minbadgesize. (default: -1 (no override)) optional int32 papilo_probing_max_badgesize = 54; } diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE deleted file mode 100644 index 6ed9218d99..0000000000 --- a/python/cuopt_mcp/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 NVIDIA Corporation - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE new file mode 120000 index 0000000000..30cff7403d --- /dev/null +++ b/python/cuopt_mcp/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/skills/cuopt-developer/references/grpc_codegen.md b/skills/cuopt-developer/references/grpc_codegen.md index 02ebe99c61..9d63041ecb 100644 --- a/skills/cuopt-developer/references/grpc_codegen.md +++ b/skills/cuopt-developer/references/grpc_codegen.md @@ -57,13 +57,24 @@ that bite: 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, emitted as leading comments - on the generated proto field. The generated `.proto` is part of the public - wire contract (`cpp/src/grpc/GRPC_INTERFACE.md`, "Custom Clients"), so a - third-party client reading only the `.proto` should learn what a settings - field means and what omitting it does. `default` is a free-text string - describing the C++ member initializer (`"1e-4"`, `"-1 (automatic)"`); the - generator neither derives nor validates it. +- **`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 From 5094bf33c749612ddf41320e44247d5b6a41965d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 14:44:17 -0500 Subject: [PATCH 6/6] Declare cuopt_mcp dependencies in dependencies.yaml cuopt_mcp imports cuopt lazily so the MCP handshake stays fast, which meant the dependency was undeclared and pip install succeeded while the first tool call failed. Adds run_cuopt_mcp plus build/run/test file entries so rapids-dependency-file-generator emits the CUDA-suffixed cuopt requirement into pyproject.toml. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Ramakrishna Prabhu --- dependencies.yaml | 36 +++++++++++++++++++++++++++++++++ python/cuopt_mcp/pyproject.toml | 10 ++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/dependencies.yaml b/dependencies.yaml index b3d56c1717..8cea43c4fb 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/pyproject.toml b/python/cuopt_mcp/pyproject.toml index 8189880164..b6cec104b7 100644 --- a/python/cuopt_mcp/pyproject.toml +++ b/python/cuopt_mcp/pyproject.toml @@ -5,7 +5,7 @@ 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] @@ -20,8 +20,9 @@ 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", @@ -33,8 +34,11 @@ classifiers = [ [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"