R/295_reductions_solvers_solving_chain.R

Defines functions .build_solving_chain construct_solving_chain resolve_and_build_chain .abort_enforce_dpp .solver_supports_cones_in .solver_supports_cones .required_cone_types .abort_cannot_solve .resolve_solver .no_solver_could_handle .fallback_solver .installed_solver_names .lookup_by_name .solver_installed_by_name .preserve_variable_bounds .require_solver_package .solver_package_available .solver_package_usable_uncached .solver_package_usable

#####
## DO NOT EDIT THIS FILE!! EDIT THE SOURCE INSTEAD: rsrc_tree/reductions/solvers/solving_chain.R
#####

## CVXPY SOURCE: reductions/solvers/solving_chain.py
## SolvingChain -- a Chain with a terminal solver reduction
##
## Dual-interface architecture matching CVXPY:
## - SOLVER_MAP_CONIC: conic path solvers (ConicSolver subclasses)
## - SOLVER_MAP_QP: QP path solvers (QpSolver subclasses)
## - resolve_and_build_chain(): resolve the solver from the problem's
##   structure (ProblemForm, problems/problem_form.R), then build the chain
##   for it; the solver's class decides QP or conic, as in CVXPY 1.9
##   (ADR D_19.8)


# -- Solver registries --------------------------------------------
## Two registries matching CVXPY defines.py:
## SOLVER_MAP_CONIC for conic path, SOLVER_MAP_QP for QP path.

## QP solver preference order (matches CVXPY defines.py QP_SOLVERS)
## Lazy-init via delayedAssign: solver objects constructed on first access.
SOLVER_MAP_QP <- new.env(hash = TRUE, parent = emptyenv())
delayedAssign(OSQP_SOLVER,   OSQP_QP_Solver(),   assign.env = SOLVER_MAP_QP)
delayedAssign(GUROBI_SOLVER, Gurobi_QP_Solver(),  assign.env = SOLVER_MAP_QP)
delayedAssign(CPLEX_SOLVER,  CPLEX_QP_Solver(),   assign.env = SOLVER_MAP_QP)
delayedAssign(HIGHS_SOLVER,  HiGHS_QP_Solver(),   assign.env = SOLVER_MAP_QP)
delayedAssign(PIQP_SOLVER,   PIQP_QP_Solver(),    assign.env = SOLVER_MAP_QP)
delayedAssign(XPRESS_SOLVER, XPRESS_QP_Solver(),  assign.env = SOLVER_MAP_QP)

## CVXPY SOURCE: defines.py:80-84,92 -- QP_SOLVERS is the SOLVER_MAP_QP order.
QP_SOLVER_PREFERENCE <- c(OSQP_SOLVER, GUROBI_SOLVER, CPLEX_SOLVER, XPRESS_SOLVER, HIGHS_SOLVER, PIQP_SOLVER)

## Conic solver preference order (matches CVXPY defines.py CONIC_SOLVERS)
## Lazy-init via delayedAssign: solver objects constructed on first access.
SOLVER_MAP_CONIC <- new.env(hash = TRUE, parent = emptyenv())
delayedAssign(CLARABEL_SOLVER, Clarabel_Solver(),     assign.env = SOLVER_MAP_CONIC)
delayedAssign(SCS_SOLVER,      SCS_Solver(),           assign.env = SOLVER_MAP_CONIC)
delayedAssign(MOSEK_SOLVER,    Mosek_Solver(),         assign.env = SOLVER_MAP_CONIC)
delayedAssign(GUROBI_SOLVER,   Gurobi_Conic_Solver(),  assign.env = SOLVER_MAP_CONIC)
delayedAssign(CPLEX_SOLVER,    CPLEX_Conic_Solver(),   assign.env = SOLVER_MAP_CONIC)
delayedAssign(HIGHS_SOLVER,    HiGHS_Conic_Solver(),   assign.env = SOLVER_MAP_CONIC)
delayedAssign(GLPK_SOLVER,     GLPK_Solver(),          assign.env = SOLVER_MAP_CONIC)
delayedAssign(GLPK_MI_SOLVER,  GLPK_MI_Solver(),       assign.env = SOLVER_MAP_CONIC)
delayedAssign(ECOS_SOLVER,     ECOS_Solver(),           assign.env = SOLVER_MAP_CONIC)
delayedAssign(ECOS_BB_SOLVER,  ECOS_BB_Solver(),        assign.env = SOLVER_MAP_CONIC)
delayedAssign(CVXOPT_SOLVER,   CVXOPT_Solver(),         assign.env = SOLVER_MAP_CONIC)
delayedAssign(SCIP_SOLVER,     SCIP_Solver(),           assign.env = SOLVER_MAP_CONIC)
delayedAssign(XPRESS_SOLVER,   XPRESS_Conic_Solver(),   assign.env = SOLVER_MAP_CONIC)
delayedAssign(DIFFCP_SOLVER,   DIFFCP_Solver(),         assign.env = SOLVER_MAP_CONIC)

## CVXPY SOURCE: defines.py:71-78,91 -- CONIC_SOLVERS is the SOLVER_MAP_CONIC
## order, restricted to the solvers CVXR has. The order matters in one place:
## `.fallback_solver` walks INSTALLED_SOLVERS, which lists the conic map's
## names first, then the QP-only ones (defines.py:120-129). DIFFCP is listed
## as upstream lists it, but `.solver_package_available()` is FALSE for it
## (it has no `.SOLVER_PACKAGES` entry), so it is never auto-selected; it is
## reached only through `requires_grad = TRUE`.
CONIC_SOLVER_PREFERENCE <- c(MOSEK_SOLVER, CLARABEL_SOLVER, SCS_SOLVER,
                              ECOS_SOLVER, CPLEX_SOLVER, GUROBI_SOLVER,
                              GLPK_SOLVER, GLPK_MI_SOLVER, CVXOPT_SOLVER,
                              XPRESS_SOLVER, DIFFCP_SOLVER, SCIP_SOLVER,
                              HIGHS_SOLVER, ECOS_BB_SOLVER)

## Package names for solver availability checks
.SOLVER_PACKAGES <- list(
  CLARABEL = "clarabel",
  SCS      = "scs",
  OSQP     = "osqp",
  HIGHS    = "highs",
  MOSEK    = "Rmosek",
  GUROBI   = "gurobi",
  GLPK     = "Rglpk",
  GLPK_MI  = "Rglpk",
  ECOS     = "ECOSolveR",
  ECOS_BB  = "ECOSolveR",
  CPLEX    = "Rcplex",
  CVXOPT   = "cccp",
  PIQP     = "piqp",
  SCIP     = "scip",
  XPRESS   = "xpress"
)

## Check if a solver package is installed and not excluded
## Is this solver's R package physically usable?
## (Real install with version >= required minimum, not a stub.)
## PERFORMANCE (2026-08-13). Memoised for the session. Which packages are
## installed cannot change under a running R process in any way this predicate
## should react to, and the MOSEK branch is expensive: `packageVersion()` reads
## and parses Rmosek's DESCRIPTION off disk on every chain construction
## (measured 85us) for users who have Rmosek installed. CVXPY resolves the same
## question exactly once per process -- `INSTALLED_SOLVERS = installed_solvers()`
## is evaluated at import time (defines.py:129) -- so caching here is closer to
## CVXPY's behavior than probing was, not further from it. The exclusion set is
## deliberately NOT part of the key: it is applied by the caller
## `.solver_package_available()`, which stays live.
.solver_package_usable <- function(solver_name) {
  cached <- .cvxr_env$solver_usable[[solver_name]]
  if (!is.null(cached)) return(cached)
  ans <- .solver_package_usable_uncached(solver_name)
  if (is.null(.cvxr_env$solver_usable)) .cvxr_env$solver_usable <- list()
  .cvxr_env$solver_usable[[solver_name]] <- ans
  ans
}

.solver_package_usable_uncached <- function(solver_name) {
  ## Rmosek on CRAN is an ancient stub; only versions >= 10 are usable.
  if (solver_name == "MOSEK") {
    if (!requireNamespace("Rmosek", quietly = TRUE)) return(FALSE)
    return(utils::packageVersion("Rmosek") >= "10")
  }
  pkg <- .SOLVER_PACKAGES[[solver_name]]
  if (is.null(pkg)) return(FALSE)
  requireNamespace(pkg, quietly = TRUE)
}

## Is this solver available for use? (Usable AND not excluded.)
.solver_package_available <- function(solver_name) {
  if (solver_name %in% .cvxr_env$excluded_solvers) return(FALSE)
  .solver_package_usable(solver_name)
}

## Runtime guard: abort if the named solver's package isn't usable.
.require_solver_package <- function(solver_name) {
  if (.solver_package_usable(solver_name)) return(invisible(TRUE))
  pkg <- .SOLVER_PACKAGES[[solver_name]]
  cli_abort("Solver {.code {solver_name}} unavailable: package {.pkg {pkg}} is missing or unusable.",
            class = "SolverError")   ## CVXPY: raise SolverError("... is not installed."), solving_chain.py:301
}

# -- SolvingChain class -------------------------------------------
## CVXPY SOURCE: solving_chain.py lines 456-567

SolvingChain <- new_class("SolvingChain", parent = Chain, package = "CVXR",
  properties = list(
    solver = class_any  # the terminal solver instance
  ),
  constructor = function(reductions = list()) {
    if (FALSE) new_object(S7_object())  ## S7 static-check guard
    solver_inst <- if (length(reductions) > 0L) {
      reductions[[length(reductions)]]
    } else {
      NULL
    }
    .fast_new(SolvingChain, S7_object(),
      .cache     = new.env(parent = emptyenv()),
      reductions = reductions,
      solver     = solver_inst
    )
  }
)

method(print, SolvingChain) <- function(x, ...) {
  names <- vapply(x@reductions, function(r) short_class_name(r),
                  character(1L))
  cat(sprintf("SolvingChain(%s)\n", paste(names, collapse = " -> ")))
  invisible(x)
}

# -- .preserve_variable_bounds() ------------------------------------
## CVXPY SOURCE: solving_chain.py:227-228 wires
## `CvxAttr2Constr(reduce_bounds = not solver_instance.BOUNDED_VARIABLES)`.
##
## The solver's BOUNDED_VARIABLES flag alone decides, as upstream. A sparse
## bound can only sit on a sparsity variable (leaf.py:746-772, ported in
## leaf.R `.validate_sparse_bound`), and CvxAttr2Constr turns it into the
## reduced variable's dense bound (cvx_attr2constr.py:229-247,
## `.cvxattr_sparse_bound_attr`), so matrix stuffing never sees a Matrix bound
## (matrix_stuffing.py:48-52,84-88 treats one as an internal error).
##
## Until 1.9.3 this scanned the problem and returned FALSE when any variable
## carried a Matrix bound, lowering EVERY bound to constraints -- a leftover
## from when CVXR accepted sparse bounds on dense variables. Measured on
## HiGHS with Variable(2, sparsity = 1, bounds = list(<sparse -1>, 5)): CVXPY
## 1.9.3 passes lower_bounds [-1], upper_bounds [5], no nonneg rows; the scan
## gave NULL tensors and three nonneg rows.
.preserve_variable_bounds <- function(problem, solver_inst) {
  isTRUE(solver_inst@BOUNDED_VARIABLES)
}

# -- .lookup_by_name() --------------------------------------------
## CVXPY SOURCE: solving_chain.py:52-66
## The installed QP and conic instances for a solver name; gp suppresses QP.
## "Installed" here ignores exclude_solvers(): a solver the user NAMES is used
## even when excluded from auto-selection (the exclusion API is CVXR's; CVXPY
## has none). DIFFCP has no `.SOLVER_PACKAGES` entry, so its package is
## checked directly (CVXPY DIFFCP.import_solver imports diffcp).
.solver_installed_by_name <- function(name) {
  if (identical(name, DIFFCP_SOLVER)) return(requireNamespace("diffcp", quietly = TRUE))
  .solver_package_usable(name)
}

.lookup_by_name <- function(solver_name, gp) {
  upper <- toupper(solver_name)
  qp <- SOLVER_MAP_QP[[upper]]
  if (!is.null(qp) && !.solver_installed_by_name(upper)) qp <- NULL
  conic <- SOLVER_MAP_CONIC[[upper]]
  if (!is.null(conic) && !.solver_installed_by_name(upper)) conic <- NULL
  if (gp) qp <- NULL
  list(qp = qp, conic = conic)
}

# -- .installed_solver_names() ------------------------------------
## CVXPY SOURCE: defines.py:120-129 -- INSTALLED_SOLVERS: the names of the
## conic map, then the QP map, keeping those installed. Here "installed" is
## `.solver_package_available()`, which honors exclude_solvers().
.installed_solver_names <- function() {
  nms <- unique(c(CONIC_SOLVER_PREFERENCE, QP_SOLVER_PREFERENCE))
  nms[vapply(nms, .solver_package_available, logical(1L), USE.NAMES = FALSE)]
}

# -- .fallback_solver() -------------------------------------------
## CVXPY SOURCE: solving_chain.py:69-103
## Last resort when no solver was named and `pick_default_solver` found none:
## the first installed non-commercial solver that can solve the problem, with
## a warning, or an error.
## RECORDED DIVERGENCE (user, 2026-10-14): ECOS_BB is skipped, so it is never
## auto-selected. CVXPY's fallback can reach it, and it returns 1 at x = 0
## with status "optimal" for min exp(x), x >= 1.5, x integer, in CVXPY 1.9.3
## and CVXR alike (docsreg_9204/ecosbb_probe.{R,py}).
## RECORDED DIVERGENCE (ADR D_PORT.14): upstream's texts name pip packages and
## a cvxpy.org URL; CVXR names the R packages and drops the URL. The warnings
## are shown once per session (per solver, for the first), as Python's
## default filter shows each text once per call site.
.fallback_solver <- function(problem_form, use_quad_obj = TRUE) {
  for (name in .installed_solver_names()) {
    if (name %in% .COMMERCIAL_CONIC_SOLVERS) next
    if (identical(name, ECOS_BB_SOLVER)) next
    for (inst in list(SOLVER_MAP_CONIC[[name]], SOLVER_MAP_QP[[name]])) {
      if (!is.null(inst) && can_solve(inst, problem_form, use_quad_obj)) {
        cli_warn(c(
          "The default solvers are not available. Using {.val {name}}.",
          "i" = "Consider specifying a solver explicitly via the {.arg solver} argument."),
          .frequency = "once",
          .frequency_id = paste0("cvxr_default_solvers_unavailable_", name))
        return(inst)
      }
    }
  }
  .no_solver_could_handle(problem_form)
}

## CVXPY SOURCE: solving_chain.py:84-103 -- the raise tail of _fallback_solver.
.no_solver_could_handle <- function(problem_form) {
  if (.pf_is_mixed_integer(problem_form)) {
    if (!.pf_is_lp(problem_form)) {
      cli_warn(c(
        "Your problem is mixed-integer but not an LP.",
        "i" = "If your problem is nonlinear, consider installing SCIP (R package {.pkg scip}) to solve it."),
        .frequency = "once", .frequency_id = "cvxr_mixed_integer_not_lp")
    }
    cli_abort(c(
      "You need a mixed-integer solver for this model.",
      "i" = "Refer to the documentation {.url https://cvxr.rbind.io/examples/solvers/using-other-solvers.html#solver-capabilities} for discussion on this topic.",
      "i" = "Install the SCIP solver (R package {.pkg scip}) for mixed-integer nonlinear problems, or HiGHS (R package {.pkg highs}) for mixed-integer LPs."
    ), class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:91
  }
  cli_abort("No installed solver could handle this problem.",
            class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:101
}

# -- .resolve_solver() --------------------------------------------
## CVXPY SOURCE: solving_chain.py:248-353
## The solver instance to build the chain for. NULL picks a default from the
## problem's structure; a name is looked up in both maps. CVXR's `solver`
## argument is a name, so upstream's custom-Solver-instance branch
## (solving_chain.py:279-295) has no counterpart.
##
## gp: `.lookup_by_name` drops the QP instance ("GP mode suppresses QP";
## problem_form.py:289 "There is no QP path for GP"), and a named solver with
## no conic interface is refused (solving_chain.py:305-310). CVXPY's own
## message there is unreachable (the "not installed" branch at 300-303 fires
## first); CVXR checks the name against the maps before installation, so it
## emits the message CVXPY intends and its test names
## (test-dgp-cvxpy-parity.R).
##
## `use_quad_obj` is R-only and reaches every capability question
## (RECORDED DIVERGENCE at `can_solve`, solver.R).
.resolve_solver <- function(solver, problem_form, gp, use_quad_obj = TRUE) {
  constant <- length(variables(problem_form@problem)) == 0L

  if (is.character(solver)) {
    name <- toupper(solver)
    if (gp && is.null(SOLVER_MAP_CONIC[[name]]) && !is.null(SOLVER_MAP_QP[[name]])) {
      cli_abort(c(
        "When {.code gp = TRUE}, {.arg solver} must be a conic solver (received {.val {name}}).",
        "i" = "Try calling {.fn psolve} with {.code solver = \"CLARABEL\"}."
      ), class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:306
    }
    found <- .lookup_by_name(name, gp)
    qp_inst <- found$qp
    conic_inst <- found$conic

    if (is.null(qp_inst) && is.null(conic_inst)) {
      ## A known name whose R package is missing gets the package named
      ## (ADR D_PORT.14); an unknown name gets upstream's text.
      if (!is.null(.SOLVER_PACKAGES[[name]])) .require_solver_package(name)
      cli_abort("The solver {.val {name}} is not installed.",
                class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:301
    }

    ## Constant problems are handled by ConstantSolver; any installed
    ## solver is acceptable.
    if (constant) return(if (!is.null(conic_inst)) conic_inst else qp_inst)

    ## Prefer QP when the objective is quadratic; otherwise conic first and
    ## QP last.
    quadratic <- .pf_has_quadratic_objective(problem_form)
    candidates <- list()
    if (!is.null(qp_inst) && quadratic) candidates <- c(candidates, list(qp_inst))
    if (!is.null(conic_inst)) candidates <- c(candidates, list(conic_inst))
    if (!is.null(qp_inst) && !quadratic) candidates <- c(candidates, list(qp_inst))

    for (inst in candidates) {
      if (can_solve(inst, problem_form, use_quad_obj)) return(inst)
    }
    .abort_cannot_solve(name, candidates, problem_form, use_quad_obj)
  }

  if (!is.null(solver)) {
    cli_abort("The {.arg solver} argument must be a solver name or {.code NULL}.",
              class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:335
  }

  ## No solver named.
  if (constant) {
    ## Constant problems are handled by ConstantSolver; return any installed
    ## solver to satisfy the chain construction.
    for (name in .installed_solver_names()) {
      for (inst in list(SOLVER_MAP_CONIC[[name]], SOLVER_MAP_QP[[name]])) {
        if (!is.null(inst)) return(inst)
      }
    }
  }

  default <- pick_default_solver(problem_form, use_quad_obj)
  if (!is.null(default)) return(default)

  .fallback_solver(problem_form, use_quad_obj)
}

## CVXPY SOURCE: solving_chain.py:330-332 -- "The solver %s cannot solve this
## problem." CVXR says why when it can: no mixed-integer support (with the
## installed MIP-capable solvers to use instead), or the cones the solver
## lacks; tests pin both texts (test-partial-optimize.R,
## test-problem-form-parity-v19.R). Upstream's text is the last resort.
.abort_cannot_solve <- function(name, candidates, problem_form, use_quad_obj = TRUE) {
  mixed_integer <- .pf_is_mixed_integer(problem_form)
  if (mixed_integer &&
      !any(vapply(candidates, function(i) isTRUE(i@MIP_CAPABLE), logical(1L)))) {
    all_s <- unique(c(QP_SOLVER_PREFERENCE, CONIC_SOLVER_PREFERENCE))
    mip_solvers <- character(length(all_s))
    mi <- 0L
    for (s in all_s) {
      if (!.solver_package_available(s)) next
      qp_i <- SOLVER_MAP_QP[[s]]
      conic_i <- SOLVER_MAP_CONIC[[s]]
      if ((!is.null(qp_i) && qp_i@MIP_CAPABLE) ||
          (!is.null(conic_i) && conic_i@MIP_CAPABLE)) {
        mi <- mi + 1L
        mip_solvers[[mi]] <- s
      }
    }
    mip_solvers <- mip_solvers[seq_len(mi)]
    suggest <- if (length(mip_solvers) > 0L) {
      paste0("Use ", paste0("{.val ", mip_solvers, "}", collapse = " or "), " instead.")
    } else {
      "Install a MIP-capable solver such as {.val HIGHS} or {.val GUROBI}."
    }
    cli_abort(c(
      "Solver {.val {name}} does not support mixed-integer problems.",
      "i" = suggest
    ), class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:330
  }
  ## A mixed-integer QP whose QP interface is not MIP-capable (HiGHS): say
  ## so, and name the installed solvers that can take the problem.
  if (mixed_integer && .pf_has_quadratic_objective(problem_form) &&
      any(vapply(candidates, function(i) .s7_is(i, QpSolver) && !isTRUE(i@MIP_CAPABLE),
                 logical(1L)))) {
    able <- Filter(function(s) {
      any(vapply(list(SOLVER_MAP_CONIC[[s]], SOLVER_MAP_QP[[s]]), function(i) {
        !is.null(i) && can_solve(i, problem_form, use_quad_obj)
      }, logical(1L)))
    }, .installed_solver_names())
    suggest <- if (length(able) > 0L) {
      paste0("Try solver: ", paste(able, collapse = ", "), ".")
    } else {
      "No installed solver can solve this mixed-integer QP."
    }
    cli_abort(c(
      "Mixed-integer QP (MIQP) is not supported by solver {.val {name}}.",
      "i" = suggest
    ), class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:330
  }
  ## The cones the LAST candidate tried still lacks (the conic interface when
  ## there is one, since the QP interface supports fewer cones).
  inst <- candidates[[length(candidates)]]
  conic_cand <- Filter(function(i) .s7_is(i, ConicSolver), candidates)
  if (length(conic_cand) > 0L) inst <- conic_cand[[1L]]
  supported <- if (mixed_integer) mi_supported_constraints(inst) else
    inst@SUPPORTED_CONSTRAINTS
  quad_obj <- isTRUE(use_quad_obj) && supports_quad_obj(inst) &&
    .pf_has_quadratic_objective(problem_form)
  cones <- .nonpos_as_nonneg(.pf_cones(problem_form, quad_obj = quad_obj))
  check <- .solver_supports_cones_in(supported, cones)
  if (!check$ok) {
    cone_str <- paste(vapply(check$unsupported, .cone_name, character(1L)),
                      collapse = ", ")
    cli_abort(c(
      "Solver {.val {name}} does not support the required cone types: {cone_str}.",
      "i" = "This problem requires {cone_str} cones. Choose a solver that supports them."
    ), class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:330
  }
  cli_abort("The solver {.val {name}} cannot solve this problem.",
            class = "SolverError")   ## CVXPY: raise SolverError, solving_chain.py:330
}

# -- .required_cone_types() -------------------------------------
## R-only: the non-LP cones (all but Zero, NonNeg, NonPos) of the problem's
## full ProblemForm cone set (problem_form.R), as a list of S7 class objects.
## Selection no longer calls this; it stays as a test-facing summary
## (test-problem-form-parity-v19.R, test-partial-optimize.R).
.required_cone_types <- function(problem) {
  Filter(function(cone) !(.cone_name(cone) %in% c("Zero", "NonNeg", "NonPos")),
         .pf_cones(ProblemForm(problem)))
}

# -- .solver_supports_cones() ----------------------------------
## Check if a conic solver supports the required cone types.

.solver_supports_cones <- function(solver_inst, required_cones) {
  .solver_supports_cones_in(solver_inst@SUPPORTED_CONSTRAINTS, required_cones)
}

.solver_supports_cones_in <- function(supported, required_cones) {
  ## CVXPY SOURCE: solving_chain.py:222-223 -- a cone the solver does not take
  ## directly still counts as supported when an EXACT conversion reaches one it
  ## does (PSD -> SvecPSD for every packed-triangle solver). `expand_cones`
  ## rewrites the requirement set; what survives it must be natively supported.
  expanded <- expand_cones(required_cones, supported)
  ## By class name, as `.cone_has` (solver.R) -- see the note there.
  missing <- !vapply(expanded$cones, function(cone) .cone_has(supported, cone),
                     logical(1L), USE.NAMES = FALSE)
  list(ok = !any(missing), unsupported = expanded$cones[missing],
       exact_targets = expanded$exact_targets)
}

# -- COMMERCIAL_SOLVERS --------------------------------------------
## CVXPY SOURCE: defines.py:115-117, in upstream's order, restricted to the
## solvers CVXR has. Read by `pick_default_solver` (problem_form.R; tried
## first unless the opt-out is set) and `.fallback_solver` (always skipped).
.COMMERCIAL_CONIC_SOLVERS <- c(MOSEK_SOLVER, GUROBI_SOLVER, CPLEX_SOLVER,
                               XPRESS_SOLVER)

# -- resolve_and_build_chain --------------------------------------
## The enforce_dpp refusal (CVXPY: raise DPPError(DPP_ERROR_MSG),
## solving_chain.py:204). One function, two callers: the chain builder below
## on a fresh build, and `.compile()` (problems/problem.R) on a cache hit whose
## chain was built for a DPP-violating problem.
.abort_enforce_dpp <- function() {
  cli_abort(c(
    "Problem does not follow DPP rules but {.code enforce_dpp = TRUE}.",
    "i" = "Set {.code enforce_dpp = FALSE} to canonicalize it as a non-DPP problem."
  ), class = "DPPError")
}

## CVXPY SOURCE: solving_chain.py:356-413
## Resolve the solver from the problem's structure, then build the chain for
## it. The DCP/DGP check runs first (make_problem_form), then the solver is
## chosen, then the DPP check, which needs to know the solver (#3142).
resolve_and_build_chain <- function(problem, solver = NULL, gp = FALSE,
                                    opts = solver_opts(),
                                    enforce_dpp = FALSE, ignore_dpp = FALSE) {
  problem_form <- make_problem_form(problem, gp, ignore_dpp)
  solver_instance <- .resolve_solver(solver, problem_form, gp,
                                     use_quad_obj = !isFALSE(opts$use_quad_obj))
  .build_solving_chain(problem, solver_instance, problem_form = problem_form,
                       gp = gp, enforce_dpp = enforce_dpp,
                       ignore_dpp = ignore_dpp, opts = opts)
}

## CVXPY 1.8.2's name for the same entry point (solving_chain.py:179 at
## v1.8.2), kept because CVXR's callers and tests use it.
construct_solving_chain <- function(problem, solver = NULL, gp = FALSE,
                                     opts = solver_opts(),
                                     enforce_dpp = FALSE, ignore_dpp = FALSE) {
  resolve_and_build_chain(problem, solver = solver, gp = gp, opts = opts,
                          enforce_dpp = enforce_dpp, ignore_dpp = ignore_dpp)
}

# -- .build_solving_chain -----------------------------------------
## CVXPY SOURCE: solving_chain.py:106-245
## The reduction chain for one chosen solver: solver context, then the
## pre-canonicalization reductions (problem + gp only), then the
## canonicalization reductions (problem_form + solver context).
.build_solving_chain <- function(problem, solver_instance, problem_form = NULL,
                                 gp = FALSE, enforce_dpp = FALSE,
                                 ignore_dpp = FALSE, opts = solver_opts()) {
  ## CVXPY SOURCE: solving_chain.py:154-155
  ## Zero-variable problems: use ConstantSolver (evaluates constraints directly)
  if (length(variables(problem)) == 0L) {
    return(SolvingChain(reductions = list(ConstantSolver())))
  }

  if (is.null(problem_form)) problem_form <- ProblemForm(problem)

  ## CVXPY SOURCE: solving_chain.py:160-178 -- SolverInfo up front, with the
  ## MIP-aware constraint set; SOC-approx canonicalizers read it to warn when
  ## the solver actually supports power cones.
  supported <- if (.pf_is_mixed_integer(problem_form)) {
    mi_supported_constraints(solver_instance)
  } else {
    solver_instance@SUPPORTED_CONSTRAINTS
  }
  solver_context <- SolverInfo(
    solver_name = solver_name(solver_instance),
    solver_supported_constraints = supported,
    solver_supports_bounds = isTRUE(solver_instance@BOUNDED_VARIABLES),
    psd_triangle_kind = solver_instance@PSD_TRIANGLE_KIND,
    psd_sqrt2_scaling = solver_instance@PSD_SQRT2_SCALING
  )

  reductions <- list()

  ## 0. DPP-aware parameter handling
  ## CVXPY SOURCE: solving_chain.py lines 192-212
  has_params <- length(parameters(problem)) > 0L
  obj_expr <- problem@objective@args[[1L]]

  ## CVXPY SOURCE: solving_chain.py:200 (#3142) --
  ## `quad_form_dpp = 'qp' if solver_instance.supports_quad_obj() else None`.
  quad_obj_capable <- supports_quad_obj(solver_instance)
  ## "qp" relaxation applies only to a quadratic objective that a quad-obj
  ## solver will take directly (so parametric P in the OBJECTIVE is DPP;
  ## constraint quad_forms stay non-DPP -- see .problem_is_dpp).
  ## PERF (v1.9): gate on has_params. The relaxation only makes a PARAMETRIC P
  ## objective DPP, so it is meaningless without parameters. Without this gate a
  ## quadratic-objective, parameter-FREE problem (e.g. the Kalman cells) ran a
  ## redundant SECOND .problem_is_dpp full-tree curvature pass (the since-removed
  ## dpp_ok_std + dpp_ok), a per-node S7-dispatch cost that scales with
  ## expression size (+10-19% on node-dense problems vs 1.8.2-1). See
  ## notes/v19_perf_regression_dpp_rootcause.md. #3142 is preserved: parametric
  ## quad_form(x,P) always has has_params = TRUE.
  quad_form_dpp <- if (!gp && opts$use_quad_obj && quad_obj_capable &&
                       has_quadratic_term(obj_expr) && has_params) {
    "qp"
  } else {
    NULL
  }

  ## DPP context depends on gp flag (CVXPY: dpp_context = 'dgp' if gp else 'dcp')
  ## CVXPY SOURCE: solving_chain.py:200-201 -- ONE DPP pass, under the quad_form
  ## relaxation when a quad-obj solver takes the objective directly (#3142);
  ## `quad_form_dpp` is NULL otherwise. (Until 1.9.2.9161 a second, standard-
  ## rules pass computed `relaxation_enabled` to exempt the parametric quad_form
  ## from the EvalParams bake below; the bake is gone, so is the pass.)
  ## The gp branch is problem.is_dpp('dgp') too, so a parametric variable bound
  ## that is not log-log affine makes the problem non-DPP (problem.py:393-400);
  ## until 1.9.2.9169 it called .is_dgp_dpp, i.e. is_dgp(dpp=True), which skips
  ## the bounds (claims audit decision 29 side finding 1).
  if (gp) {
    dpp_ok <- .problem_is_dpp(problem, "dgp")
  } else {
    dpp_ok <- .problem_is_dpp(problem, "dcp", quad_form_dpp)
  }
  ## CVXPY SOURCE: solving_chain.py:202-207 -- ignore_dpp forces the non-DPP
  ## path; enforce_dpp raises; otherwise a non-DPP parameterized problem WARNS
  ## instead of silently falling back:
  ##
  ##     if ignore_dpp or not is_dpp:
  ##         if not ignore_dpp and enforce_dpp: raise DPPError(DPP_ERROR_MSG)
  ##         if not ignore_dpp:                 warn(DPP_ERROR_MSG)
  ##         reductions = [EvalParams()] + reductions
  ##
  ## Both branches read the TRUE DPP status, and the ignore_dpp line below
  ## OVERWRITES `dpp_ok`, so capture it first.
  dpp_violated <- has_params && !dpp_ok
  if (isTRUE(ignore_dpp)) dpp_ok <- FALSE
  ## The abort tests `dpp_violated`, NOT `dpp_ok`: the line above OVERWRITES
  ## `dpp_ok` to FALSE for `ignore_dpp`, so testing it here asks the wrong
  ## question. Upstream's guard is `if not ignore_dpp and enforce_dpp`
  ## (solving_chain.py:203) -- ignore_dpp short-circuits the enforce check --
  ## and `dpp_violated` is `has_params && !dpp_ok` captured before the
  ## overwrite, i.e. upstream's `not is_dpp`.
  ##
  ## No psolve() path reaches here with both flags set: `.compile()`
  ## (problems/problem.R:470-475) rejects the contradictory pair first, as upstream
  ## does at problem.py:817-818, and for the same stated reason (before the
  ## cache key). This matters only for a direct call to the chain constructor,
  ## where CVXR now behaves as CVXPY's does.
  if (isTRUE(enforce_dpp) && !isTRUE(ignore_dpp) && dpp_violated) {
    .abort_enforce_dpp()   ## CVXPY: raise DPPError, solving_chain.py:204
  }
  ## CVXPY v1.9.2 parity (audit finding #5): warn on the ordinary path. Without
  ## this a user whose re-solves are NOT being accelerated is never told -- the
  ## problem solves correctly, just without the DPP fast path, which is exactly
  ## the silent-underperformance case upstream's message exists to prevent.
  ## `ignore_dpp = TRUE` means the user already knows, so it stays silent.
  ## The abort above returns first, so at most one of the two ever fires.
  if (dpp_violated && !isTRUE(ignore_dpp)) {
    cli_warn(c(
      "Solving a parameterized problem that is not DPP.",
      "i" = "Subsequent solves will not be faster than the first one.",
      "i" = "Check with {.fn is_dpp}; see {.url https://www.cvxpy.org/tutorial/dpp/index.html}.",
      "i" = "Set {.code ignore_dpp = TRUE} to silence this."
    ), class = "DPPWarning")   ## CVXPY: warn(DPP_ERROR_MSG), solving_chain.py:206
  }
  ## CVXPY SOURCE: solving_chain.py:202-207 -- EvalParams is prepended only when
  ## `ignore_dpp or not is_dpp`. Through 1.9.2.9161 CVXR also baked every
  ## parametric quadratic objective that was DPP by the standard rules (the
  ## Phase 3d `qp_with_params` rule, "QP tensor path not yet implemented"),
  ## which dropped the parameter program and recanonicalized on every re-solve:
  ## measured 3.9 ms against 1.6 ms for a 50-variable sum_squares(y - q) under
  ## CLARABEL, 4.0 ms against 1.5 ms under OSQP. The tensor path handles these
  ## problems: the quad canonicalizers route a non-Variable argument through an
  ## auxiliary variable (quad_over_lin_canon.R), so a parameter only ever enters
  ## affine constraint data, and twelve problem/solver pairs agreed with the
  ## baked path and with CVXPY 1.9.3 to twelve digits. Removed at 1.9.2.9162
  ## (claims audit C0919, decision 26), together with the `relaxation_enabled`
  ## second DPP pass that existed only to exempt #3142's parametric quad_form.
  ##
  ## Deliberate feature gap: complex parameters use EvalParams before
  ## Complex2Real. Ordinary complex solves are supported, but the DPP fast path
  ## would need complex sparse parameter tensors; R's Matrix package does not
  ## provide the required complex sparse representation here. The
  ## complex-dpp-parity tests document the deferred CVXPY fast-path behavior
  ## (notes/revisit_complex_params_evalparams.md).
  has_complex_params <- has_params &&
    any(vapply(parameters(problem), function(p) is_complex(p) || is_imag(p),
               logical(1L)))
  if (has_params && (!dpp_ok || has_complex_params)) {
    reductions <- c(reductions, list(EvalParams()))
  }

  ## 1. Complex2Real: if any leaf is complex, reduce to real
  if (complex2real_accepts(problem)) {
    reductions <- c(reductions, list(Complex2Real()))
  }

  ## 2. DGP path: insert Dgp2Dcp reduction (G7). DCP/DGP compliance was
  ## checked by make_problem_form (problem_form.R) before the solver was
  ## resolved, as upstream checks it (problem_form.py:414-432).
  ## Chain order: [EvalParams] -> [Complex2Real] -> [Dgp2Dcp] -> [FlipObjective]
  ##              -> [Dcp2Cone] -> [CvxAttr2Constr] -> [ConeMatrixStuffing] -> [Solver]
  if (gp) {
    reductions <- c(reductions, list(Dgp2Dcp()))
  }

  ## 3. Flip objective if Maximize -> Minimize
  if (.s7_is(problem@objective, Maximize)) {
    reductions <- c(reductions, list(FlipObjective()))
  }

  ## 3a. FiniteSet -> MIP reduction (used by both QP and conic pathways)
  ## CVXPY SOURCE: solving_chain.py lines 188-190
  if (any(vapply(problem@constraints, function(c) .s7_is(c, FiniteSet), logical(1)))) {
    reductions <- c(reductions, list(Valinvec2mixedint()))
  }

  ## 4. Canonicalization reductions (problem_form + solver_context)
  ## CVXPY SOURCE: solving_chain.py:214-244. A QP solver always needs
  ## quad_obj = TRUE in matrix stuffing, because its apply expects the P
  ## matrix; a conic solver gets it only for a quadratic objective it can take.
  use_quad <- !isFALSE(opts$use_quad_obj)
  is_qp_solver <- .s7_is(solver_instance, QpSolver)
  quad_obj <- use_quad && supports_quad_obj(solver_instance) &&
    (is_qp_solver || .pf_has_quadratic_objective(problem_form))
  ## The exact cone conversions the chosen solver needs (solver.py:26-67).
  cones <- .nonpos_as_nonneg(.pf_cones(problem_form, quad_obj = quad_obj))
  exact_targets <- expand_cones(cones, supported)$exact_targets

  reductions <- c(reductions, list(
    Dcp2Cone(quad_obj = quad_obj, solver_context = solver_context),
    CvxAttr2Constr(reduce_bounds = !.preserve_variable_bounds(problem, solver_instance))
  ))
  if (length(exact_targets) > 0L) {
    reductions <- c(reductions, list(
      ExactCone2Cone(target_cones = exact_targets,
                     solver_context = solver_context)))
  }
  ## CVXPY's ApproxCone2Cone, EliminateZeroSized, SOCDim3 and
  ## ExtractDirectCones (solving_chain.py:233-243) have no CVXR counterpart
  ## in this chain; the chain is otherwise the one CVXR built before the port.
  reductions <- c(reductions, list(
    ConeMatrixStuffing(quad_obj = quad_obj),
    solver_instance
  ))

  chain <- SolvingChain(reductions = reductions)
  ## R-only carrier: upstream's `not is_dpp` for a parameterized problem, as
  ## computed above (has_params && !dpp_ok, before the ignore_dpp overwrite).
  ## `.compile()` (problems/problem.R) reads it on a cache hit under
  ## `enforce_dpp = TRUE` so the DPPError above is raised again without a
  ## rebuild. The cache key omits enforce_dpp, as CVXPY's does (problem.py:118-119);
  ## claims audit C0540, decision 25, ADR D_PORT.13.
  chain@.cache$dpp_violated <- dpp_violated
  chain
}

# -- solve_via_data (SolvingChain) ---------------------------------
## Delegates to the terminal solver, managing the solver_cache.
## When `problem` is supplied, uses the problem's solver_cache
## (for warm-start state persistence across repeated solves).

## S7 method (registered via S7::methods_register() in .onLoad; not exported and
## not separately documented -- it carries an extra `problem` arg beyond the
## generic, which roxygen 8 would emit as a bare `solve_via_data(..., problem=)`
## usage that R's codoc cannot distinguish from the generic. The generic's
## `@param ...` documents `problem`. See the generic in generics.R.
method(solve_via_data, SolvingChain) <- function(x, data, warm_start = FALSE,
                                                  verbose = FALSE,
                                                  solver_opts = list(), ...,
                                                  problem = NULL) {
  ## CVXPY SOURCE: solving_chain.py:564 -- upstream calls
  ## `_validate_problem_data(data)` HERE as well as in `SolvingChain.solve`
  ## (:516), with the comment naming why: "These are the two possible entry
  ## points for executing the solving chain." CVXR had only the psolve() site
  ## (problem.R), so the documented decomposed API --
  ## problem_data() -> solve_via_data() -> problem_unpack_results() -- accepted
  ## poisoned data: a NaN planted in `c` reached Clarabel, which returned status
  ## 10 and garbage that propagated to the caller. CVXPY raises ValueError.
  .validate_problem_data(data)

  solver_cache <- if (!is.null(problem)) {
    .get_solver_cache(problem)
  } else {
    new.env(parent = emptyenv())
  }
  solve_via_data(x@solver, data, warm_start, verbose, solver_opts,
                 solver_cache = solver_cache)
}

Try the CVXR package in your browser

Any scripts or data that you put into this service are public.

CVXR documentation built on Oct. 19, 2026, 5:07 p.m.