R/028_expressions_leaf.R

Defines functions .leaf_attr_str .leaf_bound_domain save_leaf_value .validate_leaf_value .reject_partial_mip_idx .mip_idx .build_leaf_attrs .attr_set

#####
## DO NOT EDIT THIS FILE!! EDIT THE SOURCE INSTEAD: rsrc_tree/expressions/leaf.R
#####

## CVXPY SOURCE: expressions/leaf.py
## PARTIAL PORT: in: the whole Leaf attribute surface for CVXR's 2D model --
##   construction and validation (incl. the dimension-reducing cardinality rule,
##   leaf.py:155-160), bounds validation and get_bounds() (leaf.py:836-1032),
##   _bound_domain and domain (leaf.py:350-430), project() including the
##   boolean/integer/sparsity index branches (leaf.py:460-514), and the
##   canonical index sets .boolean_idx / .integer_idx / .sparse_idx.
##   out: the SPARSE VALUE API -- `value_sparse` getter/setter, the
##   `sparse_path` argument of save_value/project, `_sparse_high_fill_in`, and
##   the two RuntimeWarnings about reaching a sparse leaf through its dense
##   representation (leaf.py:506-509, 536-545). CVXR stores leaf values
##   DENSELY, so `_value`-holds-only-the-nonzeros -- the thing all of that
##   machinery exists to manage -- has no counterpart, and the warnings would
##   fire on CVXR's ordinary path. The `sparsity` ATTRIBUTE itself is fully
##   enforced: CvxAttr2Constr lowers a sparse leaf to an nnz-vector plus a
##   scatter, so off-pattern entries are structural zeros in the cone program.
##   out: batched N-D leaves (CVXR is 2D-only).
## Leaf -- base class for Variable, Constant, Parameter
## CVXPY 1.9 parity notes:
##  - bounds validation and get_bounds() mirror leaf.py:836-927,929-1032 for
##    CVXR's 2D model, including numeric/sparse bounds, symbolic Expression
##    bounds, sign attributes, and boolean bounds.
##  - dimension-reducing leaf handling is implemented in CvxAttr2Constr for
##    CVXR's 2D symmetric/PSD/NSD/diag/sparsity attributes.


# -- Is an attribute set? ---------------------------------------------
## Python truthiness for a CVXPY leaf attribute, which is either a bool or an
## index set: `if self.attributes[k]` is TRUE for `True` and for any NON-EMPTY
## index sequence, FALSE for `False` and for an empty one.
##
## `isTRUE()` is NOT that test -- it is FALSE for every index spelling -- and
## using it is how `sparsity` came to be accepted, advertised by
## `convex_attributes()`, and enforced nowhere: each site that should have
## noticed the attribute asked `isTRUE()` and was told no.
#' @keywords internal
.attr_set <- function(v) !is.null(v) && !identical(v, FALSE) && length(v) > 0L

# -- Helper: build leaf attributes list with validation ----------------
# Called by Leaf, Variable, and Parameter constructors to avoid
# duplicating validation logic while each calling new_object() directly.

#' @keywords internal
.build_leaf_attrs <- function(shape, nonneg = FALSE, nonpos = FALSE,
                              complex = FALSE, imag = FALSE,
                              symmetric = FALSE, diag = FALSE,
                              PSD = FALSE, NSD = FALSE, hermitian = FALSE,
                              boolean = FALSE, integer = FALSE,
                              sparsity = FALSE, pos = FALSE, neg = FALSE,
                              bounds = NULL, allow_expr_bounds = TRUE) {
  ## Validate square constraint for matrix attributes
  ## CVXPY SOURCE: leaf.py line 118-121
  if ((PSD || NSD || symmetric || diag || hermitian) &&
      (length(shape) != 2L || shape[1L] != shape[2L])) {
    cli_abort("Invalid dimensions ({paste(shape, collapse = ', ')}). Must be a square matrix.")
  }

  ## At most ONE dimension-reducing attribute.
  ## CVXPY SOURCE: leaf.py:155-160
  ##     dim_reducing_attr = ['diag','symmetric','PSD','NSD','hermitian','sparsity']
  ##     if sum(1 for k in dim_reducing_attr if self.attributes[k]) > 1:
  ##         raise ValueError("A CVXPY Variable cannot have more than one of ...")
  ##
  ## This single check is what licenses every downstream if/elif chain to treat
  ## these as mutually exclusive -- `_has_dim_reducing_attr` (leaf.py:714-718),
  ## `_reduced_size` (:720-728), `build_dim_reduced_expression`
  ## (cvx_attr2constr.py:143-161), and on the CVXR side
  ## `cvx_attr2constr.R:358` (`has_sym || has_psd || has_nsd`) followed by
  ## `else if (has_diag)`. CVXR had no such check, so the second attribute was
  ## SILENTLY DROPPED and the solve answered a different question:
  ##   Variable(c(2,2), symmetric=TRUE, diag=TRUE); min sum(X) s.t. X >= -1
  ##     CVXR -4 (the full-matrix answer)   correct diagonal answer -2
  ##   Variable(c(2,2), PSD=TRUE, NSD=TRUE); min tr(X) s.t. X[1,1] >= 1
  ##     CVXR 1 (NSD dropped)               honoring both is infeasible
  ## CVXPY 1.9.2 raises ValueError for both, and for PSD+diag and
  ## symmetric+hermitian.
  dim_reducing <- c("diag", "symmetric", "PSD", "NSD", "hermitian", "sparsity")[
    c(.attr_set(diag), .attr_set(symmetric), .attr_set(PSD), .attr_set(NSD),
      .attr_set(hermitian), .attr_set(sparsity))]
  if (length(dim_reducing) > 1L) {
    cli_abort(c(
      "A CVXR Variable cannot have more than one of these attributes: {.val {dim_reducing}}.",
      "i" = "Each of {.val {c('diag', 'symmetric', 'PSD', 'NSD', 'hermitian', 'sparsity')}} reduces the leaf to a different smaller representation, so they cannot be combined."
    ))
  }

  ## Validate bounds (CVXPY SOURCE: leaf.py:836-927 _ensure_valid_bounds)
  if (!is.null(bounds)) {
    if (!is.list(bounds) || length(bounds) != 2L) {
      cli_abort("Bounds should be a list of two items.")
    }
    has_expr_bound <- any(vapply(bounds, function(v) .s7_is(v, Expression), logical(1)))
    ## `.attr_set`, not `isTRUE`: `sparsity` is an INDEX SET, so every spelling
    ## except the degenerate TRUE is a vector and `isTRUE` is FALSE for all of
    ## them -- which silently skipped the structural-zero bound rules below.
    has_structural_zeros <- .attr_set(sparsity) || isTRUE(diag)
    n_elem <- prod(shape)

    if (has_structural_zeros) {
      if (!is.null(bounds[[1L]]) && !.s7_is(bounds[[1L]], Expression) &&
          !inherits(bounds[[1L]], "Matrix")) {
        if (length(bounds[[1L]]) == 1L) {
          if (as.numeric(bounds[[1L]]) > 0) {
            cli_abort("Scalar lower bound must be <= 0 for sparse or diagonal variables.")
          }
        } else {
          cli_abort("Dense array bounds are not supported for sparse or diagonal variables.")
        }
      }
      if (!is.null(bounds[[2L]]) && !.s7_is(bounds[[2L]], Expression) &&
          !inherits(bounds[[2L]], "Matrix")) {
        if (length(bounds[[2L]]) == 1L) {
          if (as.numeric(bounds[[2L]]) < 0) {
            cli_abort("Scalar upper bound must be >= 0 for sparse or diagonal variables.")
          }
        } else {
          cli_abort("Dense array bounds are not supported for sparse or diagonal variables.")
        }
      }
    }

    if (has_expr_bound) {
      if (!isTRUE(allow_expr_bounds)) {
        cli_abort("Parametric bounds are only supported on Variables.")
      }
      if (has_structural_zeros) {
        cli_abort(c(
          "Expression bounds are not yet supported for sparse or diagonal variables.",
          "i" = "Use numeric scalar bounds or dense variables."
        ))
      }
      none_defaults <- list(-Inf, Inf)
      for (i in 1:2) {
        b <- bounds[[i]]
        if (.s7_is(b, Expression)) {
          if (length(variables(b)) > 0L) {
            cli_abort("Parametric bounds must not depend on Variables. Use Parameters or numeric values instead.")
          }
          if (!expr_is_scalar(b) && !identical(as.integer(.shape(b)), as.integer(shape))) {
            cli_abort("Expression bounds must be scalar or have the same dimensions as the variable.")
          }
        } else if (is.null(b)) {
          bounds[[i]] <- none_defaults[[i]]
        } else if (length(b) == 1L) {
          bounds[[i]] <- as.numeric(b)
        } else if (inherits(b, "Matrix")) {
          if (!identical(as.integer(dim(b)), as.integer(shape))) {
            cli_abort("Bounds should be NULL, scalars, arrays, or CVXR Expressions with matching dimensions.")
          }
        } else if (!identical(as.integer(dim(as.array(b))), as.integer(shape)) &&
                   length(b) != n_elem) {
          cli_abort("Bounds should be NULL, scalars, arrays, or CVXR Expressions with matching dimensions.")
        }
      }
    } else {
      ## Promote NULL to -Inf/Inf, scalars to arrays matching shape. Sparse
      ## Matrix bounds stay sparse, matching CVXPY's COO-preserving path.
      ##
      ## The DENSE non-scalar arm below is the one that was missing. Upstream's
      ## numeric path (leaf.py:909-921) computes
      ##     valid_array = isinstance(val, np.ndarray) and val.shape == self.shape
      ## and raises unless the bound is None, scalar-like, or exactly that
      ## shape. CVXR shape-checked only `Matrix`-class bounds, so a plain R
      ## matrix of the wrong size was accepted and silently mis-used later:
      ## `CvxAttr2Constr` rebuilds a symmetric/diag variable at a SMALLER shape
      ## and copies the attributes across (.cvxattr_preserve_bound_attrs,
      ## cvx_attr2constr.R:186 -- upstream's `**new_attr` at
      ## cvx_attr2constr.py:213, where this same validator re-runs and rejects),
      ## then `.extract_lower_bounds` (cone_matrix_stuffing.R:127) reshapes with
      ## `array(b, dim = .shape(v))`, TRUNCATING n^2 bounds to n(n+1)/2.
      ## Measured on a symmetric 3x3 with per-entry bounds, `min sum(X)`,
      ## sum(LB) = -31: CVXR returned -26 on HIGHS (feasible but suboptimal, so
      ## nothing downstream complains) while CLARABEL/SCS/OSQP gave -31. CVXPY
      ## raises here, and only on the HIGHS path, because the bound-consuming
      ## solvers are the ones that keep the attribute (reduce_bounds = FALSE).
      ##
      ## R deviation, deliberate: a dim-less numeric of the right total length
      ## is accepted, since `Variable(3, bounds = list(rep(-1, 3), rep(1, 3)))`
      ## is ordinary R and CVXR shapes vectors as c(n, 1). A bound that CARRIES
      ## a dim must match `shape` exactly, as upstream requires.
      none_defaults <- list(-Inf, Inf)
      bad_shape <- "Bounds should be NULL, scalars, or arrays with the same dimensions as the variable/parameter."
      for (i in 1:2) {
        b <- bounds[[i]]
        if (is.null(b)) {
          bounds[[i]] <- rep(none_defaults[[i]], n_elem)
        } else if (inherits(b, "Matrix")) {
          if (!identical(as.integer(dim(b)), as.integer(shape))) cli_abort(bad_shape)
        } else if (length(b) == 1L) {
          bounds[[i]] <- rep(b, n_elem)
        } else if (!is.null(dim(b))) {
          if (!identical(as.integer(dim(b)), as.integer(shape))) cli_abort(bad_shape)
        } else if (length(b) != n_elem) {
          cli_abort(bad_shape)
        }
      }
    }

    lb <- bounds[[1L]]
    ub <- bounds[[2L]]
    lb_data <- if (inherits(lb, "sparseMatrix")) lb@x else lb
    ub_data <- if (inherits(ub, "sparseMatrix")) ub@x else ub
    if (!.s7_is(lb, Expression) && !.s7_is(ub, Expression) &&
        (any(is.nan(lb_data)) || any(is.nan(ub_data)))) {
      cli_abort("NaN is not feasible as lower or upper bound.")
    }
    if (!.s7_is(lb, Expression) && any(lb_data == Inf)) {
      cli_abort("Inf is not feasible as a lower bound.")
    }
    if (!.s7_is(ub, Expression) && any(ub_data == -Inf)) {
      cli_abort("-Inf is not feasible as an upper bound.")
    }
    if (!.s7_is(lb, Expression) && !.s7_is(ub, Expression)) {
      lb_check <- if (inherits(lb, "sparseMatrix")) as.matrix(lb) else lb_data
      ub_check <- if (inherits(ub, "sparseMatrix")) as.matrix(ub) else ub_data
    }
    if (!.s7_is(lb, Expression) && !.s7_is(ub, Expression) && any(lb_check > ub_check)) {
      cli_abort("Invalid bounds: some upper bounds are less than corresponding lower bounds.")
    }
  }

  ## CVXPY v1.8.2 fix: reject combining sign attributes (pos/neg) with
  ## sparsity attributes (sparsity/diag). Sparsity forces zeros, which
  ## contradicts strict positivity/negativity.
  sign_attrs <- c(if (pos) "pos", if (neg) "neg")
  ## `if (sparsity)` on an index vector raised R's bare
  ## "the condition has length > 1" -- so EVERY index spelling of `sparsity`
  ## died here, before reaching any of the code that was supposed to use it.
  sparse_attrs <- c(if (.attr_set(sparsity)) "sparsity", if (isTRUE(diag)) "diag")
  if (length(sign_attrs) > 0L && length(sparse_attrs) > 0L) {
    cli_abort(c(
      "Cannot combine {.val {sign_attrs}} with {.val {sparse_attrs}}.",
      "i" = "Sparsity and diag attributes force zeros, which contradicts strict positivity/negativity."
    ))
  }

  ## A partial boolean/integer index list is incompatible with the attributes
  ## that rebuild the leaf at a smaller shape -- checked here, at construction,
  ## because it is invalid regardless of the problem the leaf ends up in.
  reducing <- c("symmetric", "PSD", "NSD", "diag", "sparsity")[
    c(isTRUE(symmetric), isTRUE(PSD), isTRUE(NSD), isTRUE(diag), .attr_set(sparsity))]
  if (length(reducing) > 0L) {
    .reject_partial_mip_idx(boolean, integer, paste(reducing, collapse = "/"))
  }

  ## Build attributes list (mirrors CVXPY leaf.py line 124-130)
  list(
    nonneg = nonneg, nonpos = nonpos,
    pos = pos, neg = neg,
    complex = complex, imag = imag,
    symmetric = symmetric, diag = diag,
    PSD = PSD, NSD = NSD,
    hermitian = hermitian, boolean = boolean,
    integer = integer, sparsity = sparsity,
    bounds = bounds
  )
}

# -- .mip_idx: canonical index set for a boolean/integer attribute -----
## CVXPY SOURCE: leaf.py lines 133-146 -- upstream keeps `attributes['boolean']`
## exactly as the user wrote it and derives a separate `boolean_idx` for every
## consumer. Same split here: the raw attribute stays in `attributes`, this
## returns the canonical form stored in the `.boolean_idx` / `.integer_idx`
## properties.
##
## Canonical form: a 1-BASED FLAT (column-major) integer vector, which is plain
## R `x[i]` indexing. `TRUE` expands to every position, matching upstream's
## `np.unravel_index(arange(prod(shape)), ...)`.
##
## DEVIATION (deliberate, documented): upstream's index form is a numpy
## multi-index -- a sequence of PER-DIMENSION arrays, so `boolean=[(1,1),(0,1)]`
## means entries (1,0) and (1,1), not the coordinate pairs it resembles. That
## idiom has no R counterpart, and its edges are sharp even in Python
## (`boolean=[0]` raises TypeError on a 1-D variable; `boolean=[(0,0)]` raises
## ValueError on a 2-D one). CVXR accepts the R spellings instead, all four
## unambiguous, and CVXR is 2-D only so a coordinate is always (row, col):
##
##   TRUE / FALSE                 whole variable / none
##   c(1, 4)                      flat column-major positions
##   cbind(c(1, 2), c(1, 2))      entries (1,1) and (2,2)  [2-column matrix]
##   matrix(c(TRUE, FALSE, ...))  logical mask, same shape as the variable
##
## Validation is stricter than upstream's, on purpose: numpy raises where R
## would recycle or silently mis-index, and silent mis-indexing is the failure
## mode this whole bug class keeps producing.

.mip_idx <- function(attr, shape, what) {
  if (is.null(attr) || identical(attr, FALSE)) return(integer(0))
  n <- as.integer(prod(shape))
  if (isTRUE(attr)) return(seq_len(n))

  nr <- shape[1L]
  nc <- if (length(shape) >= 2L) shape[2L] else 1L

  ## Logical mask, same shape as the variable.
  if (is.logical(attr)) {
    if (length(attr) != n) {
      cli_abort(c(
        "{.arg {what}} logical mask has {length(attr)} entries but the variable has {n}.",
        "i" = "A mask must have exactly the shape of the variable."
      ))
    }
    if (anyNA(attr)) cli_abort("{.arg {what}} logical mask must not contain {.val NA}.")
    return(which(attr))
  }

  if (!is.numeric(attr)) {
    cli_abort(c(
      "{.arg {what}} must be {.code TRUE}/{.code FALSE}, a vector of flat indices, \\
       a two-column matrix of (row, column) pairs, or a logical mask.",
      "x" = "Got {.cls {class(attr)[[1L]]}}."
    ))
  }
  if (anyNA(attr)) cli_abort("{.arg {what}} indices must not contain {.val NA}.")
  if (any(attr != trunc(attr))) {
    cli_abort("{.arg {what}} indices must be whole numbers; got {.val {attr[attr != trunc(attr)][1L]}}.")
  }

  if (is.matrix(attr) && ncol(attr) == 2L) {
    ## (row, column) pairs -- CVXR is 2-D, so this is the whole story.
    r <- as.integer(attr[, 1L]); cc <- as.integer(attr[, 2L])
    bad <- r < 1L | r > nr | cc < 1L | cc > nc
    if (any(bad)) {
      i <- which(bad)[1L]
      cli_abort(c(
        "{.arg {what}} index ({r[i]}, {cc[i]}) is outside the variable's {nr}x{nc} shape.",
        "i" = "Indices are 1-based, as everywhere else in R."
      ))
    }
    idx <- (cc - 1L) * nr + r          # column-major flat position
  } else {
    idx <- as.integer(attr)
    bad <- idx < 1L | idx > n
    if (any(bad)) {
      cli_abort(c(
        "{.arg {what}} index {.val {idx[which(bad)[1L]]}} is outside 1:{n}.",
        "i" = "Flat indices are 1-based and column-major."
      ))
    }
  }

  if (anyDuplicated(idx)) {
    cli_abort("{.arg {what}} indices must be unique; {.val {idx[anyDuplicated(idx)]}} is repeated.")
  }
  sort(idx)
}

# -- .reject_partial_mip_idx ------------------------------------------
## A PARTIAL boolean/integer index list cannot coexist with an attribute that
## makes the reduction rebuild the variable at a SMALLER shape (symmetric /
## PSD / NSD -> upper-triangle vector, diag -> diagonal vector, sparsity ->
## stored entries): the indices are positions in the ORIGINAL variable and
## nothing carries them across the rebuild.
##
## On a 3x3 symmetric, `boolean = c(5)` means entry (2,2), but position 5 of the
## 6-element upper-triangle vector is entries (3,2) & (2,3) -- in range, wrong
## entries, no error. On a 2x2 the same request is out of range instead.
##
## PARITY: CVXPY refuses this combination too, incidentally rather than by
## design -- it carries the raw attribute onto the reduced variable and numpy
## raises `invalid entry in coordinates array` inside `ravel_multi_index`
## (matrix_stuffing.py:113). Measured on 1.9.2, the requests that survive there
## are exactly the entries of the matrix's FIRST COLUMN, where the untranslated
## row index coincides with the correct reduced position and the column index 0
## is the only one in range for the (k, 1) reduced shape. That is an artifact of
## numpy indexing, not documented behavior, and CVXR cannot reproduce it: its
## canonical index form is a flat 1-based vector with no column component.
## Rejecting uniformly is both the parity answer and the consistent one.

.reject_partial_mip_idx <- function(boolean, integer, reducing) {
  for (nm in c("boolean", "integer")) {
    val <- if (identical(nm, "boolean")) boolean else integer
    if (is.null(val) || identical(val, FALSE) || isTRUE(val)) next
    cli_abort(c(
      "A partial {.arg {nm}} index list cannot be combined with {.arg {reducing}}.",
      "x" = "{.arg {reducing}} replaces the variable with a smaller one, so the \\
             indices would refer to different entries.",
      "i" = "Use {.code {nm} = TRUE} to constrain the whole variable, or drop \\
             {.arg {reducing}} and state the structure with explicit constraints.",
      "i" = "CVXPY rejects this combination as well."
    ))
  }
  invisible(NULL)
}

Leaf <- new_class("Leaf", parent = Expression, package = "CVXR",
  properties = list(
    .value      = new_property(class = class_any, default = NULL),
    attributes  = new_property(class = class_list, default = list()),
    ## Canonical 1-based flat index sets derived from the raw attributes; see
    ## `.mip_idx`. Constraint 17: `.fast_new` skips defaults, so EVERY Leaf
    ## subclass constructor passes both explicitly.
    .boolean_idx = new_property(class = class_integer, default = integer(0)),
    .integer_idx = new_property(class = class_integer, default = integer(0)),
    ## CVXPY SOURCE: leaf.py:148-152 -- `self.sparse_idx =
    ## self._validate_indices(sparsity)`, the canonical form of the `sparsity`
    ## attribute. Same split as boolean/integer: the raw attribute stays in
    ## `attributes`, the canonical index lives here, and every consumer reads
    ## THIS rather than re-deriving (see the project() comment for what
    ## re-deriving cost boolean).
    .sparse_idx = new_property(class = class_integer, default = integer(0))
  ),
  constructor = function(shape = c(1L, 1L), value = NULL,
                         nonneg = FALSE, nonpos = FALSE,
                         complex = FALSE, imag = FALSE,
                         symmetric = FALSE, diag = FALSE,
                         PSD = FALSE, NSD = FALSE, hermitian = FALSE,
                         boolean = FALSE, integer = FALSE,
                         sparsity = FALSE, pos = FALSE, neg = FALSE,
                         bounds = NULL, id = NULL) {
    if (FALSE) new_object(S7_object())  ## S7 static-check guard
    shape <- validate_shape(shape)
    attrs <- .build_leaf_attrs(shape,
      nonneg = nonneg, nonpos = nonpos,
      complex = complex, imag = imag,
      symmetric = symmetric, diag = diag,
      PSD = PSD, NSD = NSD, hermitian = hermitian,
      boolean = boolean, integer = integer,
      sparsity = sparsity, pos = pos, neg = neg,
      bounds = bounds)

    if (is.null(id)) id <- next_expr_id()

    obj <- .fast_new(Leaf, S7_object(),
      id = as.integer(id),
      .cache = new.env(parent = emptyenv()),
      shape = shape,
      .value = NULL,
      attributes = attrs,
      .boolean_idx = .mip_idx(boolean, shape, "boolean"),
      .integer_idx = .mip_idx(integer, shape, "integer"),
      .sparse_idx  = .mip_idx(sparsity, shape, "sparsity"),
      args = list()
    )

    ## Assign value if provided (goes through validation)
    if (!is.null(value)) {
      value(obj) <- value
    }

    obj
  }
)

# -- is_pos: strictly positive (from attributes) -----------------------
## CVXPY SOURCE: leaf.py lines 271-273
method(is_pos, Leaf) <- function(x) isTRUE(.attributes(x)$pos)

# -- Log-log DGP: Leaf is log-log convex/concave iff positive ---------
## CVXPY SOURCE: leaf.py lines 254-260
method(is_log_log_convex, Leaf) <- function(x) is_pos(x)
method(is_log_log_concave, Leaf) <- function(x) is_pos(x)

# -- Quadratic / PWL: leaves are always quadratic and piecewise-linear --
## CVXPY SOURCE: leaf.py lines 613-623
method(is_quadratic, Leaf) <- function(x) TRUE
method(has_quadratic_term, Leaf) <- function(x) FALSE
method(is_pwl, Leaf) <- function(x) TRUE

# -- Curvature: Leaves are always convex and concave (affine) ----------
## CVXPY SOURCE: leaf.py lines 246-252

method(is_convex, Leaf) <- function(x) TRUE
method(is_concave, Leaf) <- function(x) TRUE

# -- get_bounds: effective (lower, upper) from bounds + sign + boolean --
## CVXPY SOURCE: leaf.py lines 929-1032 (CVXPY v1.9.0)
## Numeric dense bounds are length-prod(shape) vectors already broadcast at
## construction; sparse Matrix bounds are preserved; symbolic
## Expression/Parameter bounds are skipped here and enforced when
## CvxAttr2Constr lowers bound attributes.
method(get_bounds, Leaf) <- function(x) {
  ## Cached (CVXPY Atom.get_bounds is @compute_once; bounds depend only on the
  ## leaf's immutable attributes, so caching is safe).
  cached <- cache_get(x, "get_bounds")
  if (!cache_miss(cached)) return(cached)

  a <- .attributes(x)
  n_elem <- prod(.shape(x))
  lb <- rep(-Inf, n_elem)
  ub <- rep(Inf, n_elem)

  ## bounds attribute (already length-n_elem vectors, or NULL)
  if (!is.null(a$bounds) && is.list(a$bounds)) {
    if (!.s7_is(a$bounds[[1L]], Expression)) {
      if (inherits(a$bounds[[1L]], "Matrix")) {
        lb <- a$bounds[[1L]]
      } else {
        lb <- pmax(lb, a$bounds[[1L]])
      }
    }
    if (!.s7_is(a$bounds[[2L]], Expression)) {
      if (inherits(a$bounds[[2L]], "Matrix")) {
        ub <- a$bounds[[2L]]
      } else {
        ub <- pmin(ub, a$bounds[[2L]])
      }
    }
  }

  ## sign attributes (CVXPY leaf.py:984-1000)
  if (isTRUE(a$nonneg) || isTRUE(a$pos)) {
    lb <- if (inherits(lb, "sparseMatrix")) pmax(as.matrix(lb), 0) else pmax(lb, 0)
  }
  if (isTRUE(a$nonpos) || isTRUE(a$neg)) {
    ub <- if (inherits(ub, "sparseMatrix")) pmin(as.matrix(ub), 0) else pmin(ub, 0)
  }

  ## boolean -> [0, 1] (CVXPY leaf.py:1002-1017)
  if (isTRUE(a$boolean)) {
    lb <- if (inherits(lb, "sparseMatrix")) pmax(as.matrix(lb), 0) else pmax(lb, 0)
    ub <- if (inherits(ub, "sparseMatrix")) pmin(as.matrix(ub), 1) else pmin(ub, 1)
  }

  ## Shape to the leaf's dims (matching CVXPY's shape-carrying bounds) via a
  ## dim-attribute re-tag -- O(1), no data copy. Composite-expression
  ## propagation (#3080) needs shaped bounds for matmul/transpose/reshape/index.
  if (!inherits(lb, "Matrix")) dim(lb) <- .shape(x)
  if (!inherits(ub, "Matrix")) dim(ub) <- .shape(x)
  result <- list(lb, ub)
  cache_set(x, "get_bounds", result)
  result
}

# -- Sign queries from attributes --------------------------------------
## CVXPY SOURCE: leaf.py lines 262-269

method(is_nonneg, Leaf) <- function(x) {
  a <- .attributes(x)
  isTRUE(a$nonneg) || isTRUE(a$pos) || isTRUE(a$boolean)
}

method(is_nonpos, Leaf) <- function(x) {
  a <- .attributes(x)
  isTRUE(a$nonpos) || isTRUE(a$neg)
}

# -- DNLP curvature: a leaf is trivially linearizable ------------------
## CVXPY SOURCE: leaf.py lines 267-273 (CVXPY v1.9.0)
method(is_linearizable_convex, Leaf) <- function(x) TRUE
method(is_linearizable_concave, Leaf) <- function(x) TRUE

# -- Matrix property queries -------------------------------------------

## is_symmetric: scalar or relevant attributes
## CVXPY SOURCE: leaf.py lines 284-287
method(is_symmetric, Leaf) <- function(x) {
  expr_is_scalar(x) ||
    any(vapply(c("diag", "symmetric", "PSD", "NSD"),
               function(k) isTRUE(.attributes(x)[[k]]), logical(1)))
}

## is_psd / is_nsd: directly from attributes
## CVXPY SOURCE: leaf.py lines 601-607
method(is_psd, Leaf) <- function(x) isTRUE(.attributes(x)$PSD)
method(is_nsd, Leaf) <- function(x) isTRUE(.attributes(x)$NSD)

## is_complex / is_imag: from attributes
## CVXPY SOURCE: leaf.py lines 289-295
method(is_complex, Leaf) <- function(x) {
  a <- .attributes(x)
  isTRUE(a$complex) || isTRUE(a$imag) || isTRUE(a$hermitian)
}

method(is_imag, Leaf) <- function(x) isTRUE(.attributes(x)$imag)

## is_hermitian: from attributes
## CVXPY SOURCE: leaf.py lines 279-282
method(is_hermitian, Leaf) <- function(x) {
  (is_real(x) && is_symmetric(x)) ||
    isTRUE(.attributes(x)$hermitian) ||
    is_psd(x) || is_nsd(x)
}

# -- value / value<- --------------------------------------------------

## Value getter: check .cache first (reference semantics for constraints),
## then fall back to @.value (copy semantics).
## .cache is an environment (R reference type), so when constraint objects
## hold references to Variables, value updates propagate through the shared
## .cache environment even though R has copy-on-modify semantics for S7 props.
method(value, Leaf) <- function(x) {
  if (exists("leaf_value", envir = x@.cache, inherits = FALSE))
    return(x@.cache$leaf_value)
  x@.value
}

# -- Shared value validation (used by Leaf and Parameter value<-) ------
## Validates shape, projects onto attribute domain, checks tolerance.
## Returns the validated (converted) value or NULL.
.validate_leaf_value <- function(x, value) {
  if (is.null(value)) return(NULL)
  value <- intf_convert(value)
  val_shape <- intf_shape(value)
  if (!identical(as.integer(val_shape), as.integer(.shape(x)))) {
    cli_abort("Invalid dimensions ({paste(val_shape, collapse = ', ')}) for {.cls {class(x)[[1L]]}} value.")
  }
  ## Project and validate
  projected <- project(x, value)
  delta <- suppressWarnings(abs(value - projected))
  if (inherits(delta, "sparseMatrix")) {
    if (length(delta@x) > 0L) {
      nan_mask <- is.nan(delta@x)
      if (any(nan_mask)) {
        val_dense <- as.matrix(value)
        proj_dense <- as.matrix(projected)
        nz <- summary(delta)
        equal_inf <- val_dense[cbind(nz$i, nz$j)] == proj_dense[cbind(nz$i, nz$j)]
        ## `%in% TRUE` rather than the bare comparison: `NaN == NaN` is NA in R,
        ## and an NA in a subscript is an error, not a no-op. Only genuinely
        ## equal entries (the Inf - Inf case this exists for) are zeroed.
        delta@x[nan_mask & (equal_inf %in% TRUE)] <- 0
      }
    }
    close_enough <- all(abs(delta@x) < SPARSE_PROJECTION_TOL)
  } else {
    nan_mask <- is.nan(delta)
    if (any(nan_mask)) {
      equal_inf <- as.array(value) == as.array(projected)
      ## See the sparse branch: `NaN == NaN` is NA, and NA is not a legal
      ## subscript. This is the Inf - Inf case only.
      delta[nan_mask & (equal_inf %in% TRUE)] <- 0
    }
    if (isTRUE(.attributes(x)$PSD) || isTRUE(.attributes(x)$NSD)) {
      close_enough <- norm(delta, type = "2") <= PSD_NSD_PROJECTION_TOL
    } else {
      close_enough <- all(abs(delta) < GENERAL_PROJECTION_TOL)
    }
  }
  ## `!isTRUE`, not `!`: a NaN in the value makes every comparison above NA, and
  ## `if (NA)` raises R's internal "missing value where TRUE/FALSE needed"
  ## instead of the message this cascade exists to produce. Upstream has no
  ## such case -- `np.allclose` returns False for NaN (leaf.py:640-651) -- so
  ## treating a non-TRUE result as "not close" IS the upstream behavior.
  ## Measured: `value(Parameter(2)) <- c(NaN, 1)` gave the raw R error in CVXR
  ## and `ValueError: Parameter value must be real.` in CVXPY 1.9.2.
  if (!isTRUE(close_enough)) {
    attr_str <- .leaf_attr_str(x)
    cli_abort("{.cls {class(x)[[1L]]}} value must be {attr_str}.")
  }
  value
}

method(`value<-`, Leaf) <- function(x, value) {
  validated <- .validate_leaf_value(x, value)
  x@.cache$leaf_value <- validated   # Reference-semantic store (shared with constraint refs)
  x@.value <- validated              # Copy-semantic store (for direct access)
  x
}

# -- save_leaf_value --------------------------------------------------
## CVXPY SOURCE: leaf.py lines 454-462
## Stores solver output WITHOUT validation/projection.
## Used by problem_unpack() after solving.

save_leaf_value <- function(x, val) {
  x@.cache$leaf_value <- val
  invisible(x)
}

# -- project ----------------------------------------------------------
## CVXPY SOURCE: leaf.py lines 373-451

method(project, Leaf) <- function(x, val, ...) {
  a <- .attributes(x)
  ## Real projection (skip if complex).
  ## Bridge a base-R / Matrix-package gap: CVXPY uses np.real() which
  ## handles numpy sparse arrays transparently, but base R's Re() errors
  ## on sparse Matrix objects ("non-numeric argument to function").
  ## When val is a real numeric (or sparse real Matrix), Re() is a no-op
  ## by design, so guard with is.complex(val) — only invoke Re() when
  ## the input is actually complex, in which case Re() works (numpy and
  ## base R agree on complex semantics).
  ##
  ## The predicate must be is_complex(), NOT a hand-rolled complex/imag test:
  ## leaf.py:318 defines is_complex() as complex || is_imag() || hermitian, so a
  ## `hermitian = TRUE` leaf IS complex.  Testing only complex/imag stripped the
  ## imaginary part of a Hermitian value BEFORE the hermitian branch below could
  ## run, which made `value(Parameter(c(2, 2), hermitian = TRUE)) <- H` abort with
  ## "value must be real" for every genuinely complex Hermitian H, and made
  ## project() silently return Re(H).
  if (!is_complex(x) && is.complex(val)) {
    val <- Re(val)
  }
  ## Count active attributes (skip projection for >1 attribute)
  n_attr <- sum(vapply(a, function(v) !identical(v, FALSE) && !is.null(v), logical(1)))
  if (n_attr > 1L) return(val)

  if (isTRUE(a$nonpos) && isTRUE(a$nonneg)) {
    return(0 * val)
  } else if (isTRUE(a$nonpos) || isTRUE(a$neg)) {
    return(pmin(val, 0))
  } else if (isTRUE(a$nonneg) || isTRUE(a$pos)) {
    return(pmax(val, 0))
  } else if (length(x@.boolean_idx) > 0L) {
    ## CVXPY SOURCE: leaf.py:465-470 -- upstream indexes with `self.boolean_idx`,
    ## the CANONICAL index set computed once in __init__ (leaf.py:131-139), not
    ## with the raw `attributes['boolean']` the user wrote.
    ##
    ## CVXR has that canonical form too -- `.mip_idx()` -> `@.boolean_idx` -- and
    ## the SOLVER path already reads it (cone_matrix_stuffing.R:234-238,
    ## problems/problem.R:247). This method re-derived from the raw attribute
    ## instead, so two enumerations of "which entries are boolean" disagreed
    ## inside CVXR and two of the four documented spellings broke, in opposite
    ## directions (all on a 2x2 with the DIAGONAL boolean, i.e. entries 1 and 4):
    ##
    ##   boolean = cbind(c(1,2), c(1,2))   as.integer() of the coordinate matrix
    ##     is 1,2,1,2, so project() rounded entries 1 and 2 -- the WRONG ones --
    ##     and `value(v) <- matrix(c(1, 0.7, 0.7, 0), 2, 2)` was REJECTED even
    ##     though that value is valid.
    ##   boolean = matrix(c(TRUE,FALSE,FALSE,TRUE), 2, 2)   a logical mask is
    ##     neither `isTRUE` nor `is.numeric`, so every branch fell through and
    ##     project() was a NO-OP: `value(v) <- matrix(c(0.7,0,0,0.7), 2, 2)` was
    ##     SILENTLY ACCEPTED with non-integral entries on the boolean diagonal.
    ##
    ## `_validate_value` routes every `.value =` through project (leaf.py:608),
    ## which is why a projection bug is a validation bug.
    ##
    ## `boolean = TRUE` needs no separate arm: .mip_idx expands it to every
    ## position, so this branch reproduces the old whole-variable behavior.
    idx <- x@.boolean_idx
    new_val <- as.numeric(val)
    new_val[idx] <- round(pmin(pmax(new_val[idx], 0), 1))
    if (!is.null(dim(val))) dim(new_val) <- dim(val)
    return(new_val)
  } else if (length(x@.integer_idx) > 0L) {
    ## CVXPY SOURCE: leaf.py:471-475. Mirror of the boolean branch above.
    idx <- x@.integer_idx
    new_val <- as.numeric(val)
    new_val[idx] <- round(new_val[idx])
    if (!is.null(dim(val))) dim(new_val) <- dim(val)
    return(new_val)
  } else if (length(x@.sparse_idx) > 0L) {
    ## CVXPY SOURCE: leaf.py:506-512 -- project onto the sparsity pattern by
    ## zeroing every entry outside it.
    ##
    ## Upstream additionally emits a RuntimeWarning here, because a sparse leaf
    ## stores only its nonzero data and reaching this branch means someone came
    ## in through the dense representation. CVXR stores leaf values densely, so
    ## that warning would fire on the ordinary path and is deliberately not
    ## ported; `value_sparse` has no CVXR counterpart either. See the
    ## `## PARTIAL PORT:` sentinel at the top of this file.
    new_val <- as.numeric(val)
    keep <- logical(length(new_val))
    keep[x@.sparse_idx] <- TRUE
    new_val[!keep] <- 0
    if (!is.null(dim(val))) dim(new_val) <- dim(val)
    return(new_val)
  } else if (isTRUE(a$symmetric) || isTRUE(a$PSD) || isTRUE(a$NSD)) {
    if (is.integer(val)) val <- as.double(val)
    val <- (val + t(val)) / 2
    if (isTRUE(a$symmetric)) return(val)
    ev <- .eigvalsh(val, only_values = FALSE)
    w <- ev$values
    V <- ev$vectors
    if (isTRUE(a$PSD)) {
      bad <- w < 0
      if (!any(bad)) return(val)
      w[bad] <- 0
    } else {
      ## NSD
      bad <- w > 0
      if (!any(bad)) return(val)
      w[bad] <- 0
    }
    return((V %*% diag(w, nrow = length(w))) %*% t(V))
  } else if (isTRUE(a$diag)) {
    d <- diag(val)
    return(Matrix::Diagonal(x = d))
  } else if (isTRUE(a$hermitian)) {
    return((val + t(Conj(val))) / 2)
  } else if (isTRUE(a$imag)) {
    ## CVXPY: np.imag(val) * 1j -- project onto purely imaginary
    return(Im(val) * 1i)
  } else if (isTRUE(a$complex)) {
    ## CVXPY: val.astype(complex) -- ensure complex type
    if (!is.complex(val)) val <- as.complex(val)
    return(val)
  }
  ## Bounds clamping
  ## CVXPY SOURCE: leaf.py lines 440-445 (project -> np.clip)
  if (!is.null(a$bounds) && is.list(a$bounds)) {
    if (any(vapply(a$bounds, function(b) .s7_is(b, Expression), logical(1)))) {
      return(val)
    }
    lb <- a$bounds[[1L]]
    ub <- a$bounds[[2L]]
    val <- pmax(val, lb)
    val <- pmin(val, ub)
  }
  val
}

# -- Leaf collections default to empty --------------------------------
## CVXPY SOURCE: leaf.py lines 234-244

method(variables, Leaf) <- function(x) list()
method(parameters, Leaf) <- function(x) {
  b <- .attributes(x)$bounds
  if (is.null(b) || !is.list(b)) return(list())
  unique_params <- list()
  seen <- character(0)
  for (bound in b) {
    if (.s7_is(bound, Expression)) {
      for (p in parameters(bound)) {
        pid <- as.character(.id(p))
        if (!(pid %in% seen)) {
          unique_params[[length(unique_params) + 1L]] <- p
          seen <- c(seen, pid)
        }
      }
    }
  }
  unique_params
}
method(constants, Leaf) <- function(x) list()
method(atoms, Leaf) <- function(x) list()

# -- _bound_domain: the ONE place a leaf's attributes become constraints
## CVXPY SOURCE: leaf.py:350-414 (Leaf._bound_domain), with its two callers at
## cvx_attr2constr.py:271 (`var._bound_domain(obj, constr)`) and leaf.py:424
## (`Leaf.domain`).
##
## Why this function exists in CVXR now: it did not, and the rule it encodes had
## been transcribed THREE times, none of them complete -- the sign pair inlined
## in CvxAttr2Constr, the bounds pair in a private helper beside it, and the
## PSD/NSD pair in a third branch -- while the fourth caller, `domain(Leaf)`,
## was a stub returning `list()`. That split is not a style question: it is the
## mechanism that let `Variable(pos = TRUE)` emit no constraint for six
## releases, because "add the sign constraint" existed in a copy that nobody
## updated when `pos`/`neg` were added to the attribute list. One function, two
## callers, as upstream.
##
## DELIBERATE DEVIATION, single-site and documented: upstream emits the ordinary
## inequalities `term >= 0` / `term <= 0`; CVXR's reduction has always emitted
## the CONE constraints NonNeg(term) / NonPos(term) at this point, which is
## equivalent here (term is affine) and skips a canonicalization step. `cone`
## selects the form so that consolidating these copies changes no solve. The
## `domain()` caller uses upstream's inequality form, which is what a user
## reading `domain(x)` expects to see.
.leaf_bound_domain <- function(leaf, term, constraints = list(), cone = FALSE) {
  a <- .attributes(leaf)

  ## CVXPY SOURCE: leaf.py:358-361 -- each sign PAIR shares one constraint.
  if (isTRUE(a$nonneg) || isTRUE(a$pos)) {
    constraints <- c(constraints, list(if (cone) NonNeg(term) else term >= 0))
  }
  if (isTRUE(a$nonpos) || isTRUE(a$neg)) {
    constraints <- c(constraints, list(if (cone) NonPos(term) else term <= 0))
  }

  ## CVXPY SOURCE: leaf.py:362-414 -- bounds. Build only the FINITE numeric
  ## bound constraints; symbolic Expression bounds are appended whole.
  if (is.null(a$bounds) || !is.list(a$bounds)) return(constraints)

  add_lower <- function(constrs, bound) {
    if (.s7_is(bound, Expression)) {
      return(c(constrs, list(term >= bound)))
    }
    if (inherits(bound, "sparseMatrix")) {
      sb <- Matrix::summary(bound)
      keep <- sb$x != -Inf
      if (any(keep)) {
        idx <- cbind(sb$i[keep], sb$j[keep])
        constrs <- c(constrs, list(term[idx] >= sb$x[keep]))
      }
      return(constrs)
    }
    if (!any(bound != -Inf)) return(constrs)
    if (length(bound) == 1L) {
      c(constrs, list(term >= as.numeric(bound)))
    } else {
      b <- bound
      dim(b) <- .shape(term)
      mask <- b != -Inf
      c(constrs, list(term[mask] >= b[mask]))
    }
  }

  add_upper <- function(constrs, bound) {
    if (.s7_is(bound, Expression)) {
      return(c(constrs, list(term <= bound)))
    }
    if (inherits(bound, "sparseMatrix")) {
      sb <- Matrix::summary(bound)
      keep <- sb$x != Inf
      if (any(keep)) {
        idx <- cbind(sb$i[keep], sb$j[keep])
        constrs <- c(constrs, list(term[idx] <= sb$x[keep]))
      }
      return(constrs)
    }
    if (!any(bound != Inf)) return(constrs)
    if (length(bound) == 1L) {
      c(constrs, list(term <= as.numeric(bound)))
    } else {
      b <- bound
      dim(b) <- .shape(term)
      mask <- b != Inf
      c(constrs, list(term[mask] <= b[mask]))
    }
  }

  constraints <- add_lower(constraints, a$bounds[[1L]])
  add_upper(constraints, a$bounds[[2L]])
}

# -- domain -----------------------------------------------------------
## CVXPY SOURCE: leaf.py:416-430
##
## This was `function(x) list()`. `method(domain, Atom)` (atoms/atom.R:541-547)
## recurses into arguments correctly, so the loss was confined to leaves -- but
## it meant `domain(Variable(2, nonneg = TRUE))` returned nothing where CVXPY
## returns `[x >= 0]`, and `domain()` of a partial_optimize
## (transforms/partial_optimize.R:290, the one live consumer in CVXR) came back
## with 1 constraint where CVXPY gives 3.
method(domain, Leaf) <- function(x) {
  a <- .attributes(x)
  domain <- .leaf_bound_domain(x, x, list())
  ## CVXPY SOURCE: leaf.py:426-429 -- semidefiniteness, `if`/`elif`.
  if (isTRUE(a$PSD)) {
    domain <- c(domain, list(PSD(x)))
  } else if (isTRUE(a$NSD)) {
    domain <- c(domain, list(PSD(-x)))
  }
  domain
}

# -- grad: default empty list -----------------------------------------
method(grad, Leaf) <- function(x) list()

# -- Helper to construct attribute error string ------------------------

.leaf_attr_str <- function(x) {
  a <- .attributes(x)
  if (isTRUE(a$nonneg)) "nonnegative"
  else if (isTRUE(a$pos)) "positive"
  else if (isTRUE(a$nonpos)) "nonpositive"
  else if (isTRUE(a$neg)) "negative"
  ## CVXPY SOURCE: leaf.py:661-662 -- the sparsity arm of the same cascade.
  else if (length(x@.sparse_idx) > 0L) "zero outside of sparsity pattern"
  else if (isTRUE(a$diag)) "diagonal"
  else if (isTRUE(a$PSD)) "positive semidefinite"
  else if (isTRUE(a$NSD)) "negative semidefinite"
  else if (isTRUE(a$imag)) "imaginary"
  ## CVXPY SOURCE: leaf.py:670-671 -- `elif self.attributes['bounds']:
  ## attr_str = 'in bounds'`. Without this arm the cascade fell through to
  ## "real", so rejecting an out-of-bounds assignment reported
  ## "value must be real." -- true of the number, and nothing to do with why it
  ## was rejected. Same defect, same cascade, as the boolean arm below, which
  ## was fixed separately; this is its neighbour.
  else if (!is.null(a$bounds) && is.list(a$bounds)) "in bounds"
  else if (isTRUE(a$symmetric)) "symmetric"
  ## `isTRUE` alone reported "real" for a PARTIAL index list, so rejecting 0.7
  ## on a `boolean = c(1)` leaf said "value must be real" -- true of 0.7, and
  ## nothing to do with why it was rejected. CVXPY says "value must be boolean"
  ## for the same assignment (measured, 1.9.2), which is what these two lines
  ## now produce for both spellings of the attribute.
  else if (length(x@.boolean_idx) > 0L) "boolean"
  else if (length(x@.integer_idx) > 0L) "integer"
  else "real"
}

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.