R/259_reductions_cvx_attr2constr.R

Defines functions .cvxattr_preserve_bound_attrs .cvxattr_pass_through_attrs .cvxattr_build_dim_reduced_expression .cvxattr_reduce_grad .cvxattr_build_full_value .cvxattr_lower_value .cvxattr_reject_partial_mip_idx .cvxattr_sparse_coeff .cvxattr_reduced_size .cvxattr_has_dim_reducing_attr .cvxattr_has_symmetric_attr

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

## CVXPY SOURCE: reductions/cvx_attr2constr.py
## CVXPY 1.9 parity notes:
##  - `reduce_bounds` mirrors cvx_attr2constr.py:168-179,206-211,269-271.
##    When FALSE, bound-generating attributes are preserved for solvers that
##    consume native variable bounds.
##  - Dimension-reducing variables and parameters mirror
##    cvx_attr2constr.py:77-164,213-290 for CVXR's 2D symmetric/PSD/NSD/diag
##    attributes.
##  - R deviation: the default is TRUE here (CVXPY default is FALSE) so direct
##    calls remain conservative; solver chains opt into native bounds when safe.
##  - Deferred/N/A: CVXPY's batched N-D symmetric branch and sparse-leaf
##    reduction branch are outside CVXR's current 2D/sparsity surface; var_*
##    gradient transforms remain deferred.
##
## CvxAttr2Constr -- expand convex variable attributes into constraints
##
## Handles: nonneg, nonpos, PSD, NSD, symmetric, bounds, diag.
## Deferred: sparsity.


## `upper_tri_to_full()` is used below (lines ~137, ~155, ~378) but is NOT
## defined here. Its isomorphic home is atoms/affine/upper_tri.R (CVXPY defines
## it in atoms/affine/upper_tri.py), and it is implemented natively in
## src/RcppConv.cpp. An R copy lived in this file until 2026-08-14 and silently
## shadowed that native routine -- see the note in atoms/affine/upper_tri.R.


# -- Dimension-reducing leaf helpers --------------------------------

.cvxattr_has_symmetric_attr <- function(attrs) {
  isTRUE(attrs$symmetric) || isTRUE(attrs$PSD) || isTRUE(attrs$NSD)
}

## CVXPY SOURCE: cvx_attr2constr.py:127-164
## CVXR is 2D-only, so this ports the symmetric/PSD/NSD and diagonal branches.
## The CVXPY sparsity branch and batched symmetric branch remain deferred.
.cvxattr_has_dim_reducing_attr <- function(leaf) {
  attrs <- .attributes(leaf)
  ## CVXPY SOURCE: leaf.py:714-718 -- `self.sparse_idx is not None or
  ## self.attributes['diag'] or attributes_present([self], SYMMETRIC_ATTRIBUTES)`.
  ## `sparsity` was missing from this test, which is why nothing lowered it.
  .cvxattr_has_symmetric_attr(attrs) || isTRUE(attrs$diag) ||
    length(leaf@.sparse_idx) > 0L
}

.cvxattr_reduced_size <- function(leaf) {
  attrs <- .attributes(leaf)
  if (.cvxattr_has_symmetric_attr(attrs)) {
    n <- .shape(leaf)[1L]
    return(as.integer((n * (n + 1L)) %/% 2L))
  }
  ## CVXPY SOURCE: leaf.py:722-723 -- `len(self.sparse_idx[0])`, i.e. one
  ## variable entry per stored position.
  if (length(leaf@.sparse_idx) > 0L) return(length(leaf@.sparse_idx))
  if (isTRUE(attrs$diag)) return(as.integer(.shape(leaf)[1L]))
  as.integer(expr_size(leaf))
}

## The scatter matrix that lifts an nnz-vector back to the leaf's full shape:
## a (prod(shape) x nnz) 0/1 matrix with a single 1 per column, at the stored
## flat position. CVXPY SOURCE: cvx_attr2constr.py:155-161, which builds the
## same thing from `np.ravel_multi_index(leaf.sparse_idx, leaf.shape, order='F')`
## -- CVXR's `.sparse_idx` is ALREADY that flat column-major index, so there is
## nothing to ravel.
.cvxattr_sparse_coeff <- function(idx, n_full) {
  Matrix::sparseMatrix(i = as.integer(idx), j = seq_along(idx),
                       x = rep(1, length(idx)),
                       dims = c(as.integer(n_full), length(idx)))
}

## Backstop for the construction-time check in expressions/leaf.R
## (`.reject_partial_mip_idx`): a leaf whose replacement has a different shape
## must not carry a partial boolean/integer index list. Unreachable in normal
## use -- the leaf could not have been constructed -- and kept because this is
## the point where the shape actually changes.
.cvxattr_reject_partial_mip_idx <- function(attrs, reducing) {
  .reject_partial_mip_idx(attrs$boolean, attrs$integer, reducing)
}

## CVXPY SOURCE: cvx_attr2constr.py:102-125
.cvxattr_lower_value <- function(leaf, val = NULL) {
  if (is.null(val)) val <- value(leaf)
  if (is.null(val)) return(NULL)

  attrs <- .attributes(leaf)
  if (.cvxattr_has_symmetric_attr(attrs)) {
    n <- .shape(leaf)[1L]
    idx <- which(upper.tri(matrix(0, n, n), diag = TRUE), arr.ind = TRUE)
    idx <- idx[order(idx[, 1L], idx[, 2L]), , drop = FALSE]
    return(as.numeric(val[idx]))
  }
  ## CVXPY SOURCE: cvx_attr2constr.py:128-132 -- the sparse branch of
  ## lower_value. CVXR stores leaf values DENSELY (no `_value`-holds-only-nnz
  ## split, and no `value_sparse`), so the full-size / stored-size distinction
  ## upstream draws does not arise: extract at the stored positions either way.
  if (length(leaf@.sparse_idx) > 0L) {
    return(as.numeric(val)[leaf@.sparse_idx])
  }
  if (isTRUE(attrs$diag)) {
    return(as.numeric(diag(as.matrix(val))))
  }
  val
}

## CVXPY SOURCE: cvx_attr2constr.py:77-100
.cvxattr_build_full_value <- function(leaf, lowered_value) {
  attrs <- .attributes(leaf)
  if (.cvxattr_has_symmetric_attr(attrs)) {
    n <- .shape(leaf)[1L]
    idx <- which(upper.tri(matrix(0, n, n), diag = TRUE), arr.ind = TRUE)
    idx <- idx[order(idx[, 1L], idx[, 2L]), , drop = FALSE]
    full <- matrix(0, n, n)
    flat_val <- as.numeric(lowered_value)
    for (k in seq_len(nrow(idx))) {
      i <- idx[k, 1L]
      j <- idx[k, 2L]
      full[i, j] <- flat_val[k]
      full[j, i] <- flat_val[k]
    }
    return(full)
  }
  ## CVXPY SOURCE: cvx_attr2constr.py:93-97 -- scatter the reduced values back
  ## into a full-shape array of zeros.
  if (length(leaf@.sparse_idx) > 0L) {
    full <- numeric(prod(.shape(leaf)))
    full[leaf@.sparse_idx] <- as.numeric(lowered_value)
    dim(full) <- .shape(leaf)
    return(full)
  }
  if (isTRUE(attrs$diag)) {
    return(Matrix::Diagonal(x = as.numeric(lowered_value)))
  }
  lowered_value
}

## Adjoint of the full <- reduced reconstruction, for the dict-diff VARIABLE
## chain rule (#3147 part A). Maps a FULL n x n gradient to the reduced
## representation's gradient. Distinct from .cvxattr_lower_value (which merely
## extracts the upper triangle of a VALUE): the gradient adjoint of the
## symmetric fill `full = A %*% tri` is `t(A) %*% vec(full)`, which DOUBLES the
## off-diagonal contributions. For diag the adjoint is just the diagonal.
## CVXPY SOURCE: cvx_attr2constr.py:313-330 (var_backward, fill_mat.T @ ...).
.cvxattr_reduce_grad <- function(leaf, full_grad) {
  attrs <- .attributes(leaf)
  if (.cvxattr_has_symmetric_attr(attrs)) {
    n <- .shape(leaf)[1L]
    A <- upper_tri_to_full(n)                       # (n^2) x (n(n+1)/2)
    ## crossprod enforces length(vec) == n^2 (non-conformable error otherwise).
    tri <- as.numeric(Matrix::crossprod(A, as.numeric(full_grad)))
    return(array(tri, dim = c(length(tri), 1L)))
  }
  if (isTRUE(attrs$diag)) {
    n <- .shape(leaf)[1L]
    dg <- diag(matrix(as.numeric(full_grad), n, n))
    return(array(dg, dim = c(n, 1L)))
  }
  full_grad   # pass-through attrs (nonneg/nonpos/bounds): no reshape
}

## CVXPY SOURCE: cvx_attr2constr.py:127-164
.cvxattr_build_dim_reduced_expression <- function(leaf, reduced_leaf) {
  attrs <- .attributes(leaf)
  if (.cvxattr_has_symmetric_attr(attrs)) {
    n <- .shape(leaf)[1L]
    fill_coeff <- Constant(upper_tri_to_full(n))
    full_mat <- fill_coeff %*% reduced_leaf
    expr <- reshape_expr(full_mat, c(n, n))
    if (isTRUE(attrs$PSD)) {
      return(psd_wrap(expr))
    } else if (isTRUE(attrs$NSD)) {
      ## CVXPY SOURCE: cvx_attr2constr.py:151 -- `return nsd_wrap(expr)`.
      ## Was `-psd_wrap(-expr)` while nsd_wrap did not exist: curvature- and
      ## sign-equivalent, but two extra nodes and not the upstream tree.
      return(nsd_wrap(expr))
    } else {
      return(symmetric_wrap(expr))
    }
  }
  ## CVXPY SOURCE: cvx_attr2constr.py:155-161 -- the `leaf.sparse_idx is not
  ## None` branch, checked BEFORE diag (the two are mutually exclusive anyway,
  ## enforced at construction since leaf.py:155-160).
  if (length(leaf@.sparse_idx) > 0L) {
    n_full <- as.integer(prod(.shape(leaf)))
    coeff <- Constant(.cvxattr_sparse_coeff(leaf@.sparse_idx, n_full))
    return(reshape_expr(coeff %*% reduced_leaf, .shape(leaf)))
  }
  if (isTRUE(attrs$diag)) {
    return(DiagVec(reduced_leaf))
  }
  reduced_leaf
}

.cvxattr_pass_through_attrs <- function(attrs, reduction_attributes) {
  args <- list()
  ## CVXPY SOURCE: cvx_attr2constr.py:196-219 -- upstream copies the attribute
  ## dict verbatim (`new_attr = var.attributes.copy()`) and rebuilds the leaf
  ## with `**new_attr`, so an INDEX LIST survives unchanged. Coercing to TRUE
  ## here promoted a partial boolean/integer constraint to a full one whenever
  ## a leaf was rebuilt -- i.e. for variables that are also symmetric / PSD /
  ## NSD / diag / sparse -- silently solving a MORE constrained problem than
  ## the user asked for.
  for (key in c("nonneg", "nonpos", "pos", "neg", "boolean", "integer")) {
    val <- attrs[[key]]
    keep <- !(key %in% reduction_attributes) &&
      !is.null(val) && !identical(val, FALSE) && length(val) > 0L
    if (keep) {
      args[[key]] <- val
    }
  }
  if (!("bounds" %in% reduction_attributes) && !is.null(attrs$bounds)) {
    args$bounds <- attrs$bounds
  }
  args
}


# -- Helper: preserve un-reduced bound attributes ------------------
## CVXPY SOURCE: cvx_attr2constr.py:144-148 -- the `**new_attr` pass-through
## that carries un-reduced attributes onto the replacement Variable.
## When reduce_bounds is FALSE the bound-generating attributes survive the
## reduction; copy them onto the replacement variable's constructor args so
## that get_bounds() (leaf.R) reads them on the reduced problem.
.cvxattr_preserve_bound_attrs <- function(args, attrs, reduce_bounds) {
  if (reduce_bounds) return(args)
  for (k in c("nonneg", "nonpos", "pos", "neg")) {
    if (isTRUE(attrs[[k]])) args[[k]] <- TRUE
  }
  if (!is.null(attrs$bounds) && is.list(attrs$bounds)) args$bounds <- attrs$bounds
  args
}

## `.cvxattr_add_bound_constraints()` used to live here, holding the bounds half
## of leaf.py's `_bound_domain` while the sign half was inlined in
## reduction_apply below. Both halves now live in ONE function in the file that
## owns them upstream -- `.leaf_bound_domain()` in expressions/leaf.R -- and
## this reduction calls it, exactly as cvx_attr2constr.py:271 calls
## `var._bound_domain(obj, constr)`. See the comment there for why the split
## was not merely untidy.


# -- CvxAttr2Constr reduction class -------------------------------
## CVXPY SOURCE: cvx_attr2constr.py lines 105-215

CvxAttr2Constr <- new_class("CvxAttr2Constr", parent = Reduction,
  package = "CVXR",
  properties = list(
    reduce_bounds = class_logical
  ),
  constructor = function(reduce_bounds = TRUE) {
    if (FALSE) new_object(S7_object())  ## S7 static-check guard
    .fast_new(CvxAttr2Constr, S7_object(),
      .cache        = new.env(parent = emptyenv()),
      reduce_bounds = isTRUE(reduce_bounds)
    )
  }
)

method(reduction_accepts, CvxAttr2Constr) <- function(x, problem, ...) TRUE

method(reduction_apply, CvxAttr2Constr) <- function(x, problem, ...) {
  reduce_bounds <- isTRUE(x@reduce_bounds)
  vars_ <- variables(problem)
  params_ <- parameters(problem)
  has_var_attrs <- length(convex_attributes(vars_)) > 0L
  has_param_attrs <- any(vapply(params_, .cvxattr_has_dim_reducing_attr, logical(1)))
  if (!has_var_attrs && !has_param_attrs) {
    return(list(problem, list()))
  }

  reduction_attributes <- c("symmetric", "PSD", "NSD", "diag", "sparsity")
  if (reduce_bounds) {
    reduction_attributes <- c(reduction_attributes, "nonneg", "nonpos", "pos", "neg", "bounds")
  }
  x@.cache$parameters <- new.env(hash = TRUE, parent = emptyenv())
  ## Original parameter objects keyed by id, for the dict-diff param hooks
  ## (#3147 part A); they need the original leaf's attributes/shape to lower
  ## or rebuild a value. reduction_invert uses inverse_data instead.
  x@.cache$id2old_param <- new.env(hash = TRUE, parent = emptyenv())

  ## R-specific DPP-cache correctness: the reduced Parameter objects must keep
  ## STABLE identity across re-applications of this reduction. The DPP fast
  ## path caches a `param_prog` that holds direct references to these reduced
  ## Parameters and re-syncs them via update_parameters(); CVXR's problem_data()
  ## always re-runs the full chain (unlike CVXPY's cache-aware get_problem_data),
  ## so re-creating a fresh reduced Parameter here would orphan the one the
  ## cached param_prog points at -- a later solve would then read a stale value
  ## (e.g. problem_data() followed by changing a diag/symmetric Parameter and
  ## re-solving). Persist the reduced Parameters in a store that survives
  ## re-application and reuse them below.
  if (is.null(x@.cache$reduced_param_store)) {
    x@.cache$reduced_param_store <- new.env(hash = TRUE, parent = emptyenv())
  }

  ## For each unique variable, create replacement and constraints
  id2new_var <- new.env(hash = TRUE, parent = emptyenv())
  id2new_obj <- new.env(hash = TRUE, parent = emptyenv())
  id2old_var <- new.env(hash = TRUE, parent = emptyenv())
  ## Collect attribute constraint chunks per variable (up to ~4 each: PSD/NSD + nonneg + nonpos + bounds)
  attr_constr_chunks <- vector("list", length(vars_))
  n_attr_chunks <- 0L

  for (var in vars_) {
    vid <- as.character(.id(var))
    if (exists(vid, envir = id2new_var, inherits = FALSE)) next
    assign(vid, var, envir = id2old_var)

    attrs <- .attributes(var)
    has_sym <- !is.null(attrs$symmetric) && attrs$symmetric
    has_psd <- !is.null(attrs$PSD) && attrs$PSD
    has_nsd <- !is.null(attrs$NSD) && attrs$NSD
    has_diag <- !is.null(attrs$diag) && attrs$diag
    ## Read the CANONICAL index, never the raw attribute -- it is an index set,
    ## so `isTRUE(attrs$sparsity)` is FALSE for every real pattern.
    has_sparsity <- length(var@.sparse_idx) > 0L
    has_nonneg <- !is.null(attrs$nonneg) && attrs$nonneg
    has_nonpos <- !is.null(attrs$nonpos) && attrs$nonpos
    ## `pos` and `neg` generate the SAME constraints as `nonneg` / `nonpos`.
    ## CVXPY SOURCE: leaf.py:358-361 (_bound_domain)
    ##   if self.attributes['nonneg'] or self.attributes['pos']:
    ##       constraints.append(term >= 0)
    ##   if self.attributes['nonpos'] or self.attributes['neg']:
    ##       constraints.append(term <= 0)
    ## and cvx_attr2constr.py:32-52, where CONVEX_ATTRIBUTES and
    ## BOUND_ATTRIBUTES both list all FOUR sign attributes.
    ##
    ## CVXR handled only nonneg/nonpos here, so `Variable(pos = TRUE)` and
    ## `Variable(neg = TRUE)` produced NO constraint: the attribute reached
    ## get_bounds() (leaf.R:590 already treats pos as a lower bound of 0) but
    ## only solvers with BOUNDED_VARIABLES = TRUE ever read those bounds, so on
    ## CLARABEL/SCS/ECOS/OSQP/MOSEK the sign was silently dropped and a bounded
    ## problem was reported UNBOUNDED. Present in every release since 1.8.2.
    has_pos <- !is.null(attrs$pos) && isTRUE(attrs$pos)
    has_neg <- !is.null(attrs$neg) && isTRUE(attrs$neg)
    has_bounds <- !is.null(attrs$bounds) && is.list(attrs$bounds)

    ## CVXPY approach: copy all attributes, then clear only the
    ## "reduction_attributes". Preserve boolean, integer, nonneg, nonpos,
    ## bounds on the new variable.
    ##
    ## CVXPY SOURCE: cvx_attr2constr.py:207-211, which iterates
    ## `self.reduction_attributes()` -- derived from CONVEX_ATTRIBUTES, so
    ## `sparsity` is in it. This loop hardcoded four keys and OMITTED
    ## "sparsity", while `reduction_attributes` twenty lines up listed five.
    ## Two lists for one concept, disagreeing: the attribute was declared
    ## reducible and then never cleared, so the replacement variable kept it and
    ## the reduction had no effect.
    new_attrs <- attrs
    needs_new_var <- FALSE
    for (key in c("symmetric", "PSD", "NSD", "diag", "sparsity")) {
      if (.attr_set(new_attrs[[key]])) {
        needs_new_var <- TRUE
        new_attrs[[key]] <- FALSE
      }
    }
    ## Also mark as needing new var if nonneg/nonpos/bounds present -- but only
    ## when we are lowering bounds. When reduce_bounds = FALSE the attributes
    ## stay on the variable, so a pure-bounds variable is kept untouched
    ## (CVXPY cvx_attr2constr.py:114-119 -- bound attrs are absent from
    ## reduction_attributes, so `new_var` is never set on their account).
    if (reduce_bounds && (has_nonneg || has_nonpos || has_pos || has_neg ||
                          has_bounds)) needs_new_var <- TRUE

    if (has_sym || has_psd || has_nsd) {
      ## Create upper-triangular variable and fill to full matrix
      n <- .shape(var)[1L]
      tri_size <- (n * (n + 1L)) %/% 2L
      ## New variable with same id but stripped symmetric/PSD/NSD attrs
      ## Preserve boolean/integer on the upper-tri variable
      upper_tri_args <- list(c(tri_size, 1L), var_id = .id(var))
      ## Shape changes here (n x n -> tri_size x 1), so only a WHOLE-variable
      ## boolean/integer attribute survives; an index list is rejected.
      .cvxattr_reject_partial_mip_idx(new_attrs, "symmetric/PSD/NSD")
      if (isTRUE(new_attrs$boolean)) upper_tri_args$boolean <- TRUE
      if (isTRUE(new_attrs$integer)) upper_tri_args$integer <- TRUE
      upper_tri_args <- .cvxattr_preserve_bound_attrs(upper_tri_args, attrs, reduce_bounds)
      upper_tri_var <- do.call(Variable, upper_tri_args)
      assign(vid, upper_tri_var, envir = id2new_var)

      ## Fill coefficient: n^2 x tri_size sparse matrix
      fill_coeff <- Constant(upper_tri_to_full(n))
      full_mat <- fill_coeff %*% upper_tri_var
      obj <- reshape_expr(full_mat, c(n, n))

      ## Map original var's python id to the new expression
      assign(as.character(.id(var)), obj, envir = id2new_obj)

      ## Add PSD/NSD constraint on the full matrix
      var_constrs <- list()
      if (has_psd) {
        var_constrs <- list(PSD(obj))
      } else if (has_nsd) {
        var_constrs <- list(PSD(-obj))
      }
    } else if (has_sparsity) {
      ## Sparse variable: replace with an nnz-vector and scatter it back into
      ## the full shape, so every off-pattern entry is a STRUCTURAL ZERO rather
      ## than a free variable the solver may set.
      ## CVXPY SOURCE: cvx_attr2constr.py:236-252 (the _has_dim_reducing_attr
      ## branch) + build_dim_reduced_expression's sparse arm (:155-161).
      idx <- var@.sparse_idx
      sp_args <- list(c(length(idx), 1L), var_id = .id(var))
      ## Shape changes here (n_full -> nnz); see the upper-tri branch above.
      .cvxattr_reject_partial_mip_idx(new_attrs, "sparsity")
      if (isTRUE(new_attrs$boolean)) sp_args$boolean <- TRUE
      if (isTRUE(new_attrs$integer)) sp_args$integer <- TRUE
      ## Bound attributes are NOT carried over: they are indexed by the FULL
      ## shape and would be the W2 truncation bug in another guise. Upstream
      ## transforms sparse bounds explicitly (cvx_attr2constr.py:241-256) and
      ## rejects a dense array bound on a sparse leaf; CVXR rejects dense
      ## bounds on a sparse leaf at construction (leaf.R, has_structural_zeros),
      ## so only a scalar bound can reach here, and a scalar needs no transform.
      if (!reduce_bounds) {
        for (k in c("nonneg", "nonpos", "pos", "neg")) {
          if (isTRUE(attrs[[k]])) sp_args[[k]] <- TRUE
        }
        if (!is.null(attrs$bounds) && is.list(attrs$bounds) &&
            all(vapply(attrs$bounds, function(b) length(b) <= 1L, logical(1)))) {
          sp_args$bounds <- attrs$bounds
        }
      }
      sparse_var <- do.call(Variable, sp_args)
      if (!is.null(value(var))) value(sparse_var) <- .cvxattr_lower_value(var)
      assign(vid, sparse_var, envir = id2new_var)
      obj <- .cvxattr_build_dim_reduced_expression(var, sparse_var)
      assign(as.character(.id(var)), obj, envir = id2new_obj)
      var_constrs <- list()
    } else if (has_diag) {
      ## Diagonal n×n variable: replace with n-vector + DiagMat() lift.
      ## CVXPY SOURCE: cvx_attr2constr.py:167-171
      n <- .shape(var)[1L]
      diag_var_args <- list(c(n, 1L), var_id = .id(var))
      ## Shape changes here (n x n -> n x 1); see the upper-tri branch above.
      .cvxattr_reject_partial_mip_idx(new_attrs, "diag")
      if (isTRUE(new_attrs$boolean)) diag_var_args$boolean <- TRUE
      if (isTRUE(new_attrs$integer)) diag_var_args$integer <- TRUE
      diag_var_args <- .cvxattr_preserve_bound_attrs(diag_var_args, attrs, reduce_bounds)
      diag_var <- do.call(Variable, diag_var_args)
      ## Propagate the original variable's value to the lowered n-vector so the
      ## NLP initial point survives the reduction. CVXPY SOURCE:
      ## cvx_attr2constr.py:251-252 (the _has_dim_reducing_attr branch).
      if (!is.null(value(var))) value(diag_var) <- .cvxattr_lower_value(var)
      assign(vid, diag_var, envir = id2new_var)
      ## DiagVec lifts an n-vector to an n×n diagonal matrix.
      ## (DiagMat goes the other way: square matrix → diagonal vector.)
      obj <- DiagVec(diag_var)             # n×n diagonal expression
      assign(as.character(.id(var)), obj, envir = id2new_obj)
      var_constrs <- list()
    } else if (needs_new_var) {
      ## Create replacement variable preserving boolean/integer
      new_var_args <- list(.shape(var), var_id = .id(var))
      ## SAME shape as the original (this branch only lowers bounds), so an
      ## index list still denotes the same entries and is carried over as-is.
      ## `&& new_attrs$boolean` used to coerce it: a length-1 list was silently
      ## promoted to TRUE (constraining the WHOLE variable) and a longer one
      ## raised "'length = 2' in coercion to 'logical(1)'".
      for (key in c("boolean", "integer")) {
        val <- new_attrs[[key]]
        if (!is.null(val) && !identical(val, FALSE) && length(val) > 0L) {
          new_var_args[[key]] <- val
        }
      }
      new_var_args <- .cvxattr_preserve_bound_attrs(new_var_args, attrs, reduce_bounds)
      obj <- do.call(Variable, new_var_args)
      assign(vid, obj, envir = id2new_var)
      assign(as.character(.id(var)), obj, envir = id2new_obj)
      var_constrs <- list()
    } else {
      ## No attribute to reduce -- keep the variable as-is
      obj <- var
      assign(vid, var, envir = id2new_var)
      assign(as.character(.id(var)), var, envir = id2new_obj)
      var_constrs <- list()
    }

    ## Add nonneg/nonpos/bounds constraints independently, but only when we are
    ## lowering bounds. When reduce_bounds = FALSE the attributes stay on the
    ## variable (see .cvxattr_preserve_bound_attrs) for the solver to enforce.
    ## CVXPY SOURCE: cvx_attr2constr.py:168-171 (only calls var._bound_domain when
    ## self.reduce_bounds); leaf.py _bound_domain() -- each is a separate `if`.
    if (reduce_bounds) {
      obj <- get(as.character(.id(var)), envir = id2new_obj)
      ## CVXPY SOURCE: cvx_attr2constr.py:270-271 --
      ##     if self.reduce_bounds:
      ##         var._bound_domain(obj, constr)
      ## The sign pair, the bounds pair and their finite-entry masking all live
      ## in `.leaf_bound_domain()` (expressions/leaf.R), whose other caller is
      ## `domain(Leaf)`. `cone = TRUE` keeps the NonNeg/NonPos cone form this
      ## reduction has always emitted; see that function's header.
      var_constrs <- .leaf_bound_domain(var, obj, var_constrs, cone = TRUE)
    }
    if (length(var_constrs) > 0L) {
      n_attr_chunks <- n_attr_chunks + 1L
      attr_constr_chunks[[n_attr_chunks]] <- var_constrs
    }
  }

  ## For each unique parameter with 2D dimension-reducing attributes, create a
  ## reduced parameter and a reconstruction expression.
  ## CVXPY SOURCE: cvx_attr2constr.py:278-290
  store <- x@.cache$reduced_param_store
  for (param in params_) {
    pid <- as.character(.id(param))
    if (!.cvxattr_has_dim_reducing_attr(param)) next
    if (exists(pid, envir = id2new_obj, inherits = FALSE)) next

    lowered <- .cvxattr_lower_value(param)
    if (exists(pid, envir = store, inherits = FALSE)) {
      ## Reuse the reduced Parameter from a prior application (stable identity)
      ## and re-lower the current value into it.
      reduced_param <- get(pid, envir = store, inherits = FALSE)
      if (!is.null(lowered)) value(reduced_param) <- lowered
    } else {
      n <- .cvxattr_reduced_size(param)
      if (n != expr_size(param)) {
        .cvxattr_reject_partial_mip_idx(
          .attributes(param),
          if (.cvxattr_has_symmetric_attr(.attributes(param))) "symmetric/PSD/NSD" else "diag")
      }
      param_args <- c(
        list(shape = c(n, 1L), name = expr_name(param)),
        .cvxattr_pass_through_attrs(.attributes(param), reduction_attributes)
      )
      reduced_param <- do.call(Parameter, param_args)
      if (!is.null(lowered)) value(reduced_param) <- lowered
      assign(pid, reduced_param, envir = store)
    }
    obj <- .cvxattr_build_dim_reduced_expression(param, reduced_param)

    assign(pid, reduced_param, envir = x@.cache$parameters)
    assign(pid, param, envir = x@.cache$id2old_param)
    assign(pid, obj, envir = id2new_obj)
  }

  ## Substitute variables in the objective and constraints via tree_copy
  new_obj <- tree_copy(problem@objective, id_objects = id2new_obj)
  n_pcons <- length(problem@constraints)
  tree_copy_chunks <- vector("list", n_pcons)
  cons_id_map <- list()
  for (i in seq_len(n_pcons)) {
    con <- problem@constraints[[i]]
    new_con <- tree_copy(con, id_objects = id2new_obj)
    tree_copy_chunks[[i]] <- list(new_con)
    cons_id_map[[as.character(con@id)]] <- .id(new_con)
  }

  ## Combine all constraint chunks: attribute constraints + tree-copied constraints
  all_constr_chunks <- c(attr_constr_chunks[seq_len(n_attr_chunks)], tree_copy_chunks)
  new_constrs <- unlist(all_constr_chunks, recursive = FALSE)
  if (is.null(new_constrs)) new_constrs <- list()

  ## Stash the variable maps on the instance so the dict-diff hooks
  ## (var_backward/var_forward) can reach them (#3147 part A); reduction_invert
  ## reads its own copy from inverse_data.
  x@.cache$id2old_var <- id2old_var
  x@.cache$id2new_var <- id2new_var

  new_problem <- Problem(new_obj, new_constrs)
  inverse_data <- list(id2new_var = id2new_var,
                       id2old_var = id2old_var,
                       cons_id_map = cons_id_map)
  list(new_problem, inverse_data)
}

method(update_parameters, CvxAttr2Constr) <- function(x, problem, ...) {
  params_map <- x@.cache$parameters
  if (is.null(params_map)) return(invisible(NULL))
  for (param in parameters(problem)) {
    pid <- as.character(.id(param))
    if (!exists(pid, envir = params_map, inherits = FALSE)) next
    reduced_param <- get(pid, envir = params_map, inherits = FALSE)
    lowered <- .cvxattr_lower_value(param)
    if (!is.null(lowered)) value(reduced_param) <- lowered
  }
  invisible(NULL)
}

## Dict-in/dict-out param hooks (#3147 part A). For each lowered parameter
## (dim-reducing attribute), map its delta/gradient between the full and reduced
## (upper-tri / diagonal) representations. Pass-through params are untouched.
## Reduced-leaf values are kept dimensioned to the reduced param's shape.
method(param_forward, CvxAttr2Constr) <- function(x, param_deltas) {
  params_map <- x@.cache$parameters
  id2old     <- x@.cache$id2old_param
  if (is.null(params_map)) return(param_deltas)
  for (pid in ls(params_map, all.names = TRUE)) {
    if (is.null(param_deltas[[pid]])) next
    reduced_param <- get(pid, envir = params_map, inherits = FALSE)
    orig_param    <- get(pid, envir = id2old,     inherits = FALSE)
    lowered <- .cvxattr_lower_value(orig_param, param_deltas[[pid]])
    param_deltas[[as.character(reduced_param@id)]] <-
      array(as.numeric(lowered), dim = .shape(reduced_param))
  }
  param_deltas
}

method(param_backward, CvxAttr2Constr) <- function(x, dparams) {
  params_map <- x@.cache$parameters
  id2old     <- x@.cache$id2old_param
  if (is.null(params_map)) return(dparams)
  for (pid in ls(params_map, all.names = TRUE)) {
    reduced_param <- get(pid, envir = params_map, inherits = FALSE)
    rpid <- as.character(.id(reduced_param))
    if (is.null(dparams[[rpid]])) next
    orig_param <- get(pid, envir = id2old, inherits = FALSE)
    full <- .cvxattr_build_full_value(orig_param, dparams[[rpid]])
    dparams[[rpid]] <- NULL                       # pop reduced id
    dparams[[pid]]  <- full                        # full value at original id
  }
  dparams
}

## Dict-in/dict-out VARIABLE chain rule (#3147 part A). For a symmetric/PSD/NSD
## or diag Variable, lowered to an upper-tri (resp. diagonal) reduced variable
## that SHARES the original id, reshape gradients/deltas between the full and
## reduced representations. Pass-through variables (nonneg/nonpos/bounds) keep
## their full shape and are untouched. CVXPY SOURCE: cvx_attr2constr.py:313-346.
## (This closes the symmetric-Variable derivative gap flagged in ADR D_19.1.)
method(var_backward, CvxAttr2Constr) <- function(x, del_vars) {
  id2old <- x@.cache$id2old_var
  if (is.null(id2old)) return(del_vars)
  for (vid in ls(id2old, all.names = TRUE)) {
    if (is.null(del_vars[[vid]])) next
    ov <- get(vid, envir = id2old, inherits = FALSE)
    if (!.cvxattr_has_dim_reducing_attr(ov)) next      # pass-through: unchanged
    del_vars[[vid]] <- .cvxattr_reduce_grad(ov, del_vars[[vid]])   # full -> reduced
  }
  del_vars
}

method(var_forward, CvxAttr2Constr) <- function(x, dvars) {
  id2old <- x@.cache$id2old_var
  if (is.null(id2old)) return(dvars)
  for (vid in ls(id2old, all.names = TRUE)) {
    if (is.null(dvars[[vid]])) next
    ov <- get(vid, envir = id2old, inherits = FALSE)
    if (!.cvxattr_has_dim_reducing_attr(ov)) next      # pass-through: unchanged
    full <- .cvxattr_build_full_value(ov, dvars[[vid]])           # reduced -> full
    if (inherits(full, "Matrix")) full <- as.matrix(full)
    dvars[[vid]] <- full
  }
  dvars
}

method(reduction_invert, CvxAttr2Constr) <- function(x, solution, inverse_data, ...) {
  if (length(inverse_data) == 0L) return(solution)

  id2new_var <- inverse_data$id2new_var
  id2old_var <- inverse_data$id2old_var
  cons_id_map <- inverse_data$cons_id_map

  pvars <- list()
  old_ids <- ls(id2old_var, all.names = TRUE)
  for (vid in old_ids) {
    old_var <- get(vid, envir = id2old_var)
    new_var <- get(vid, envir = id2new_var)
    new_vid <- as.character(.id(new_var))
    if (!is.null(solution@primal_vars[[new_vid]])) {
      raw_val <- solution@primal_vars[[new_vid]]
      ## Recover full matrix for symmetric/PSD/NSD
      attrs <- .attributes(old_var)
      has_sym <- !is.null(attrs$symmetric) && attrs$symmetric
      has_psd <- !is.null(attrs$PSD) && attrs$PSD
      has_nsd <- !is.null(attrs$NSD) && attrs$NSD
      has_diag <- !is.null(attrs$diag) && attrs$diag
      if (has_sym || has_psd || has_nsd) {
        n <- .shape(old_var)[1L]
        val <- numeric(n * n)
        ## Indices for upper triangle (row-major, 0-based)
        idx <- which(upper.tri(matrix(0, n, n), diag = TRUE), arr.ind = TRUE)
        idx <- idx[order(idx[, 1L], idx[, 2L]), , drop = FALSE]
        flat_val <- as.numeric(raw_val)
        ## Fill upper triangle and mirror
        full <- matrix(0, n, n)
        for (k in seq_len(nrow(idx))) {
          i <- idx[k, 1L]
          j <- idx[k, 2L]
          full[i, j] <- flat_val[k]
          full[j, i] <- flat_val[k]
        }
        pvars[[vid]] <- full
      } else if (length(old_var@.sparse_idx) > 0L) {
        ## Scatter the nnz-vector solution back into the full shape, zeros
        ## everywhere off the pattern.
        ## CVXPY SOURCE: cvx_attr2constr.py:93-97 (recover_value_for_leaf).
        pvars[[vid]] <- .cvxattr_build_full_value(old_var, raw_val)
      } else if (has_diag) {
        ## Lift n-vector solution back to a sparse n×n diagonal matrix.
        ## Mirrors CVXPY recover_value_for_variable (cvx_attr2constr.py:77-80).
        pvars[[vid]] <- Matrix::Diagonal(x = as.numeric(raw_val))
      } else {
        pvars[[vid]] <- raw_val
      }
    }
  }

  ## Remap dual variables. `cons_id_map` is a NAMED LIST here, so the loop this
  ## replaces paid a linear scan on BOTH sides -- three O(n) terms per iteration.
  ## O(n) overall via one vectorized match; see `.remap_by_id_map`.
  dvars <- .remap_by_id_map(solution@dual_vars, cons_id_map)

  Solution(solution@status, solution@opt_val, pvars, dvars, solution@attr)
}

Try the CVXR package in your browser

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

CVXR documentation built on Aug. 24, 2026, 9:10 a.m.