R/255_zzz_R_specific_visualize.R

Defines functions visualize .viz_print_solver_data .viz_matrix_summaries .viz_block_structure .viz_cone_product_latex .viz_dims_summary .viz_var_mapping .viz_build_solver_data .viz_model_to_json .viz_require_jsonlite .viz_node_to_list .viz_print_dcp_violations .viz_print_dcp_analysis .viz_dcp_overlay .viz_dcp_walk .viz_print_stage .viz_print_tree .viz_curv_color .viz_build_model .viz_walk_tree .viz_format_scalar .dcp_fix_hints .dcp_diagnose_constraint .dcp_diagnose_expr .mono_label

Documented in visualize

#####
## DO NOT EDIT THIS FILE!! EDIT THE SOURCE INSTEAD: rsrc_tree/zzz_R_specific/visualize.R
#####

## R-SPECIFIC: visualize() -- Smith form visualization of CVXR problems
##
## Entry point: visualize(problem, output = "text")
## Tree walker: .viz_walk_tree(expr) -> nested list (VisualizationNode)
## Model builder: .viz_build_model(problem) -> full JSON-serializable data model
##
## Output formats:
##   "text"  -- console display with cli
##   "json"  -- JSON dump (for HTML/Python interop)
##   "html"  -- interactive D3+KaTeX (Phase 2)
##   "latex" -- align* environments (Phase 3)
##   "tikz"  -- forest tree diagrams (Phase 3)


# ==========================================================================
# DCP diagnosis
# ==========================================================================

## Monotonicity label for display
.mono_label <- function(incr, decr) {
  if (incr && decr) return("affine-monotone")
  if (incr) return("increasing")
  if (decr) return("decreasing")
  "non-monotone"
}

## Diagnose WHY an expression fails DCP.
## Returns NULL if the expression IS DCP (fast path).
## For non-DCP atoms, returns a list with:
##   $atom_convex, $atom_concave  -- logical (intrinsic atom properties)
##   $reason         -- human-readable string
##   $arg_details    -- list of per-arg diagnostics (curvature, monotonicity, pass/fail)
.dcp_diagnose_expr <- function(expr) {
  ## Leaves are always DCP
  if (S7_inherits(expr, Leaf)) return(NULL)

  ## Constant expressions are always DCP
  if (is_constant(expr)) return(NULL)

  ## If already DCP, nothing to diagnose
  if (is_convex(expr) || is_concave(expr)) return(NULL)

  ## -- Atom-level properties -------------------------
  atom_cvx <- tryCatch(is_atom_convex(expr), error = function(e) FALSE)
  atom_ccv <- tryCatch(is_atom_concave(expr), error = function(e) FALSE)
  cls_name <- .clean_class_name(expr)

  ## -- Per-argument diagnostics ----------------------
  arg_details <- list()
  for (idx in seq_along(expr@args)) {
    arg <- expr@args[[idx]]
    arg_curv <- .latex_curvature(arg)
    arg_aff  <- is_affine(arg)
    arg_cvx  <- is_convex(arg)
    arg_ccv  <- is_concave(arg)
    incr     <- tryCatch(is_incr(expr, idx), error = function(e) FALSE)
    decr     <- tryCatch(is_decr(expr, idx), error = function(e) FALSE)

    ## Check convex composition for this arg
    cvx_ok <- arg_aff || (arg_cvx && incr) || (arg_ccv && decr)
    ## Check concave composition for this arg
    ccv_ok <- arg_aff || (arg_ccv && incr) || (arg_cvx && decr)

    arg_details[[idx]] <- list(
      index       = idx,
      curvature   = arg_curv,
      is_affine   = arg_aff,
      is_convex   = arg_cvx,
      is_concave  = arg_ccv,
      monotonicity = .mono_label(incr, decr),
      convex_rule_ok  = cvx_ok,
      concave_rule_ok = ccv_ok
    )
  }

  ## -- Build reason string ---------------------------
  if (!atom_cvx && !atom_ccv) {
    reason <- sprintf(
      "%s is neither convex nor concave (atom-level property).",
      cls_name
    )
  } else if (atom_cvx && !atom_ccv) {
    ## Atom is convex but composition rule fails
    bad <- vapply(arg_details, function(a) !a$convex_rule_ok, logical(1))
    bad_idx <- which(bad)
    reason <- sprintf(
      "%s is convex, but DCP convex composition fails at arg%s %s.",
      cls_name,
      if (length(bad_idx) > 1L) "s" else "",
      paste(bad_idx, collapse = ", ")
    )
  } else if (!atom_cvx && atom_ccv) {
    ## Atom is concave but composition rule fails
    bad <- vapply(arg_details, function(a) !a$concave_rule_ok, logical(1))
    bad_idx <- which(bad)
    reason <- sprintf(
      "%s is concave, but DCP concave composition fails at arg%s %s.",
      cls_name,
      if (length(bad_idx) > 1L) "s" else "",
      paste(bad_idx, collapse = ", ")
    )
  } else {
    ## Both convex and concave (affine atom), but args aren't affine
    bad <- vapply(arg_details, function(a) !a$is_affine, logical(1))
    bad_idx <- which(bad)
    reason <- sprintf(
      "%s is affine, but arg%s %s %s not affine.",
      cls_name,
      if (length(bad_idx) > 1L) "s" else "",
      paste(bad_idx, collapse = ", "),
      if (length(bad_idx) > 1L) "are" else "is"
    )
  }

  list(
    atom_convex  = atom_cvx,
    atom_concave = atom_ccv,
    reason       = reason,
    arg_details  = arg_details,
    fix_hints    = .dcp_fix_hints(expr)
  )
}

## Diagnose a constraint for DCP compliance.
## Returns NULL if the constraint IS DCP, or a list with:
##   $requirement -- what DCP requires (e.g., "convex" or "affine")
##   $actual      -- what the expression actually is
##   $reason      -- human-readable string
.dcp_diagnose_constraint <- function(con) {
  if (is_dcp(con)) return(NULL)
  con_type <- .clean_class_name(con)

  ## Determine what DCP requires for this constraint type
  if (con_type %in% c("Zero", "Equality")) {
    requirement <- "affine"
    expr <- con@args[[1L]]
    actual <- .latex_curvature(expr)
    reason <- sprintf(
      "%s constraint requires affine expression, but got %s.",
      con_type, actual
    )
  } else if (con_type %in% c("NonPos", "Inequality")) {
    requirement <- "convex"
    expr <- con@args[[1L]]
    actual <- .latex_curvature(expr)
    reason <- sprintf(
      "%s constraint requires convex expression, but got %s.",
      con_type, actual
    )
  } else if (con_type == "NonNeg") {
    requirement <- "concave"
    expr <- con@args[[1L]]
    actual <- .latex_curvature(expr)
    reason <- sprintf(
      "%s constraint requires concave expression, but got %s.",
      con_type, actual
    )
  } else if (con_type == "PSD") {
    requirement <- "affine"
    expr <- con@args[[1L]]
    actual <- .latex_curvature(expr)
    reason <- sprintf(
      "PSD constraint requires affine matrix expression, but got %s.",
      actual
    )
  } else {
    requirement <- "DCP-compliant"
    actual <- "unknown"
    reason <- sprintf("%s constraint is not DCP.", con_type)
  }

  list(
    type        = con_type,
    requirement = requirement,
    actual      = actual,
    reason      = reason
  )
}


## Generate constructive fix hints for non-DCP expressions.
## Returns a character vector of suggestions, or NULL if no hints available.
## Two sources: (A) curvature-dependent atoms with known fixes,
## (B) domain constraints via atom_domain() introspection.
.dcp_fix_hints <- function(expr) {
  if (S7_inherits(expr, Leaf)) return(NULL)
  cls <- .clean_class_name(expr)
  hints <- character(0L)

  ## -- (A) Curvature-dependent atoms ------------------
  if (cls == "QuadForm") {
    P <- expr@args[[2L]]
    if (!is_constant(P)) {
      hints <- c(hints, "P (second argument) must be constant (use a Parameter or numeric matrix)")
    } else if (!is_psd(P) && !is_nsd(P)) {
      hints <- c(hints, "P must be PSD for convexity, or NSD for concavity")
    } else if (is_psd(P)) {
      hints <- c(hints, "P is PSD, so quad_form(x, P) is convex -- check that x is affine")
    } else if (is_nsd(P)) {
      hints <- c(hints, "P is NSD, so quad_form(x, P) is concave -- check that x is affine")
    }
  } else if (cls == "SymbolicQuadForm") {
    orig <- expr@original_expression
    orig_hints <- .dcp_fix_hints(orig)
    if (!is.null(orig_hints)) hints <- c(hints, orig_hints)
  } else if (cls == "Perspective") {
    if (!is_nonneg(expr@args[[1L]])) {
      hints <- c(hints, "The scalar argument s must be nonneg for Perspective to be DCP")
    }
    f_expr <- expr@.f
    if (!is_convex(f_expr) && !is_concave(f_expr)) {
      hints <- c(hints, "The function f must be convex or concave")
    }
  } else if (cls == "Xexp") {
    if (!is_nonneg(expr@args[[1L]])) {
      hints <- c(hints, "x must be nonneg for xexp(x) to be convex")
    }
  }

  ## -- (B) Domain constraints via atom_domain() -------
  dom <- tryCatch(atom_domain(expr), error = function(e) list())
  for (con in dom) {
    con_cls <- .clean_class_name(con)
    ## Try to name the argument involved: find variable names in constraint
    arg_desc <- tryCatch({
      vars <- variables(con)
      if (length(vars) > 0L) {
        paste(vapply(vars, expr_name, character(1)), collapse = ", ")
      } else {
        "argument"
      }
    }, error = function(e) "argument")
    if (con_cls == "PSD") {
      hint <- sprintf("%s must be PSD (positive semidefinite)", arg_desc)
    } else if (con_cls %in% c("NonNeg", "Inequality")) {
      hint <- sprintf("%s must be nonneg (>= 0)", arg_desc)
    } else if (con_cls %in% c("NonPos")) {
      hint <- sprintf("%s must be nonpos (<= 0)", arg_desc)
    } else if (con_cls %in% c("Zero", "Equality")) {
      hint <- sprintf("%s must satisfy an equality constraint", arg_desc)
    } else {
      next
    }
    ## Avoid duplicating hints already generated by (A)
    if (!any(grepl(arg_desc, hints, fixed = TRUE))) {
      hints <- c(hints, hint)
    }
  }

  if (length(hints) == 0L) NULL else unique(hints)
}


# ==========================================================================
# Tree walker
# ==========================================================================

## Walk an expression tree, assigning auxiliary variables t_1, t_2, ... to
## each internal node. Returns a nested list (VisualizationNode) with all
## annotation data for every stage of the Smith form pipeline.
##
## @param expr An Expression (the subtree root).
## @param counter An environment with $i (integer counter for t_i naming).
## @returns A list with class "VizNode".

## Format a scalar value for display: integers shown without decimals,
## non-integers use `digits` significant figures (default 4, user-settable).
.viz_format_scalar <- function(val, digits = 4L) {
  v <- val[1L]
  if (is.finite(v) && v == round(v)) {
    ## Integer-valued: show without decimals (1, 0, -3)
    as.character(as.integer(v))
  } else {
    formatC(v, format = "g", digits = digits)
  }
}

.viz_walk_tree <- function(expr, counter) {
  ## -- Leaf nodes --------------------------------------------------

  if (S7_inherits(expr, Leaf)) {
    ## Leaves get their own name, not a t_i
    if (S7_inherits(expr, Variable)) {
      leaf_name <- expr_name(expr)
      ## Use custom latex_name if provided, otherwise bold variable name
      lnm <- expr@.latex_name
      leaf_latex <- if (nzchar(lnm)) lnm else sprintf("\\mathbf{%s}", leaf_name)
    } else if (S7_inherits(expr, Parameter)) {
      leaf_name <- expr_name(expr)
      lnm <- expr@.latex_name
      leaf_latex <- if (nzchar(lnm)) lnm else sprintf("\\theta_{\\text{%s}}", leaf_name)
    } else {
      ## Constant: try to show value
      val <- tryCatch(value(expr), error = function(e) NULL)
      if (!is.null(val) && length(val) == 1L) {
        leaf_name <- .viz_format_scalar(val, digits = counter$digits)
        leaf_latex <- leaf_name
      } else {
        leaf_name <- sprintf("C[%s]", paste(expr@shape, collapse = "x"))
        leaf_latex <- sprintf("C_{%s}", paste(expr@shape, collapse = "\\times "))
      }
    }

    ann <- smith_annotation(expr, aux_var = leaf_latex, child_vars = character(0))

    node <- list(
      id            = leaf_name,
      id_latex      = leaf_latex,
      expr_latex    = leaf_latex,   ## recursive expression = leaf name
      atom_class    = .clean_class_name(expr),
      is_leaf       = TRUE,
      shape         = as.integer(expr@shape),
      curvature     = .latex_curvature(expr),
      sign          = .latex_sign(expr),
      children      = list(),
      annotation    = ann
    )
    class(node) <- "VizNode"
    return(node)
  }

  ## -- Internal nodes (Atoms) -------------------------------------
  ## Recurse into children first (bottom-up)
  child_nodes <- lapply(expr@args, .viz_walk_tree, counter = counter)

  ## Assign auxiliary variable to this node
  counter$i <- counter$i + 1L
  aux_var <- sprintf("t_{%d}", counter$i)

  ## Collect child variable names for annotation.
  ## Brace-wrap each so that annotations can safely append subscripts
  ## (e.g. %s_i) without creating invalid double-subscripts like t_{1}_i.
  child_vars <- vapply(child_nodes, function(ch) {
    sprintf("{%s}", ch$id_latex)
  }, character(1))

  ## Get annotation from the atom (using t_i names for Smith form)
  ann <- smith_annotation(expr, aux_var = aux_var, child_vars = child_vars)

  ## Recursive expression: call annotation again with child expr_latex
  ## to get latex_definition in terms of the original variables
  child_exprs <- vapply(child_nodes, function(ch) {
    sprintf("{%s}", ch$expr_latex)
  }, character(1))
  expr_ann <- smith_annotation(expr, aux_var = aux_var, child_vars = child_exprs)
  expr_latex <- expr_ann$latex_definition

  node <- list(
    id            = sprintf("t_%d", counter$i),
    id_latex      = aux_var,
    expr_latex    = expr_latex,    ## full recursive expression
    atom_class    = .clean_class_name(expr),
    is_leaf       = FALSE,
    shape         = as.integer(expr@shape),
    curvature     = .latex_curvature(expr),
    sign          = .latex_sign(expr),
    children      = child_nodes,
    annotation    = ann
  )
  class(node) <- "VizNode"
  node
}


# ==========================================================================
# Model builder
# ==========================================================================

## Build the full visualization data model from a Problem.
## Returns a list with: problem metadata, objective tree, constraint trees,
## and flattened stage data (smith_form, relaxed_smith_form).

.viz_build_model <- function(problem, digits = 4L) {
  counter <- new.env(parent = emptyenv())
  counter$i <- 0L
  counter$digits <- digits

  ## Walk objective
  obj_expr <- problem@objective@args[[1L]]  # The expression inside Minimize/Maximize
  obj_tree <- .viz_walk_tree(obj_expr, counter)

  ## Walk each constraint
  constr_trees <- lapply(problem@constraints, function(con) {
    ## Constraints have args: for Inequality/Equality, walk the expression
    ## For cone constraints (SOC, PSD, etc.), walk args
    if (length(con@args) > 0L) {
      subtrees <- lapply(con@args, .viz_walk_tree, counter = counter)
    } else {
      subtrees <- list()
    }
    ## Build constraint expression in math form
    con_type <- .clean_class_name(con)
    sub_exprs <- vapply(subtrees, function(st) st$expr_latex, character(1))
    con_expr <- if (con_type == "Inequality" && length(sub_exprs) == 2L) {
      sprintf("%s \\leq %s", sub_exprs[[1L]], sub_exprs[[2L]])
    } else if (con_type == "Equality" && length(sub_exprs) == 2L) {
      sprintf("%s = %s", sub_exprs[[1L]], sub_exprs[[2L]])
    } else if (con_type == "Zero" && length(sub_exprs) >= 1L) {
      sprintf("%s = 0", sub_exprs[[1L]])
    } else if (con_type == "NonPos" && length(sub_exprs) >= 1L) {
      sprintf("%s \\leq 0", sub_exprs[[1L]])
    } else if (con_type == "NonNeg" && length(sub_exprs) >= 1L) {
      sprintf("%s \\geq 0", sub_exprs[[1L]])
    } else if (con_type == "PSD" && length(sub_exprs) >= 1L) {
      sprintf("%s \\succeq 0", sub_exprs[[1L]])
    } else if (con_type == "SOC" && length(sub_exprs) >= 2L) {
      sprintf("\\lVert %s \\rVert_2 \\leq %s", sub_exprs[[2L]], sub_exprs[[1L]])
    } else {
      paste(sub_exprs, collapse = ",\\; ")
    }

    list(
      type       = con_type,
      label      = if (nzchar(con@.label)) con@.label else NULL,
      subtrees   = subtrees,
      shape      = as.integer(con@shape),
      expr_latex = con_expr
    )
  })

  ## -- Flatten: collect all nodes in DFS order ----------------------
  all_nodes <- list()
  .collect_nodes <- function(node) {
    for (ch in node$children) .collect_nodes(ch)
    all_nodes[[length(all_nodes) + 1L]] <<- node
  }
  .collect_nodes(obj_tree)
  for (ct in constr_trees) {
    for (st in ct$subtrees) .collect_nodes(st)
  }

  ## -- Build stage data ---------------------------------------------
  sense <- if (S7_inherits(problem@objective, Minimize)) "minimize" else "maximize"

  ## Smith form: list of equations (one per non-leaf node)
  smith_eqs <- vapply(all_nodes, function(nd) {
    if (nd$is_leaf) return("")
    nd$annotation$smith
  }, character(1))
  smith_eqs <- smith_eqs[nzchar(smith_eqs)]

  ## Relaxed Smith form
  relaxed_eqs <- vapply(all_nodes, function(nd) {
    if (nd$is_leaf) return("")
    nd$annotation$relaxed
  }, character(1))
  relaxed_eqs <- relaxed_eqs[nzchar(relaxed_eqs)]

  ## Conic form: collect from nodes that have non-NULL conic
  conic_eqs <- list()
  for (nd in all_nodes) {
    if (nd$is_leaf) next
    ann_conic <- nd$annotation$conic
    if (!is.null(ann_conic)) {
      conic_eqs <- c(conic_eqs, ann_conic)
    } else {
      ## Stub: show relaxed form as placeholder
      ## Strip $ delimiters from relaxed to keep pure LaTeX
      relaxed_clean <- gsub("^\\$+|\\$+$", "", nd$annotation$relaxed)
      conic_eqs <- c(conic_eqs, list(
        sprintf("%s \\quad \\text{[conic form: see canonicalizer]}", relaxed_clean)
      ))
    }
  }

  list(
    problem = list(
      sense       = sense,
      n_vars      = length(variables(problem)),
      n_constrs   = length(problem@constraints)
    ),
    objective_tree = obj_tree,
    constraint_trees = constr_trees,
    stages = list(
      smith_form         = smith_eqs,
      relaxed_smith_form = relaxed_eqs,
      conic_form         = conic_eqs
    ),
    all_nodes = all_nodes
  )
}


# ==========================================================================
# Text renderer
# ==========================================================================

## Map curvature string to cli ANSI color for text-mode display
.viz_curv_color <- function(curv_str) {
  curv <- tolower(curv_str)
  if (grepl("convex", curv) && !grepl("concave", curv)) {
    cli::col_blue(curv_str)
  } else if (grepl("concave", curv)) {
    cli::col_red(curv_str)
  } else if (grepl("affine", curv)) {
    cli::col_green(curv_str)
  } else if (grepl("constant", curv)) {
    cli::col_grey(curv_str)
  } else {
    ## unknown / nondcp
    cli::col_yellow(curv_str)
  }
}

## Print a tree to console with indentation (box-drawing style)
.viz_print_tree <- function(node, prefix = "", child_prefix = "", is_root = TRUE) {
  ## Node label with curvature coloring
  colored_curv <- .viz_curv_color(node$curvature)
  if (node$is_leaf) {
    label <- paste0(node$id, "  [", colored_curv, ", ",
                    paste(node$shape, collapse = "x"), "]")
  } else {
    label <- paste0(node$id, " = ", node$atom_class, "(...)  [",
                    colored_curv, ", ", node$sign, ", ",
                    paste(node$shape, collapse = "x"), "]")
  }
  cat(paste0(prefix, label, "\n"))

  ## Recurse into children
  n_children <- length(node$children)
  for (i in seq_len(n_children)) {
    is_last_child <- (i == n_children)
    if (is_last_child) {
      .viz_print_tree(node$children[[i]],
                      prefix       = paste0(child_prefix, "\\-- "),
                      child_prefix = paste0(child_prefix, "    "),
                      is_root = FALSE)
    } else {
      .viz_print_tree(node$children[[i]],
                      prefix       = paste0(child_prefix, "|-- "),
                      child_prefix = paste0(child_prefix, "|   "),
                      is_root = FALSE)
    }
  }
}

## Print stage equations
.viz_print_stage <- function(title, eqs) {
  cli_rule(title)
  for (eq in eqs) {
    ## Strip $ delimiters for text display, show as-is otherwise
    clean <- gsub("\\$", "", eq)
    ## Simplify some LaTeX for text
    clean <- gsub("\\\\geq", ">=", clean)
    clean <- gsub("\\\\leq", "<=", clean)
    clean <- gsub("\\\\mathbf\\{([^}]*)\\}", "\\1", clean)
    clean <- gsub("\\\\mathcal\\{Q\\}", "Q", clean)
    clean <- gsub("\\\\mathcal\\{K\\}", "K", clean)
    clean <- gsub("\\\\lVert", "||", clean)
    clean <- gsub("\\\\rVert", "||", clean)
    clean <- gsub("\\\\varphi\\^\\{([^}]*)\\}", "phi^{\\1}", clean)
    clean <- gsub("\\\\text\\{([^}]*)\\}", "\\1", clean)
    clean <- gsub("\\\\texttt\\{([^}]*)\\}", "\\1", clean)
    clean <- gsub("\\\\operatorname\\{([^}]*)\\}", "\\1", clean)
    clean <- gsub("\\\\in", "in", clean)
    clean <- gsub("\\\\quad", "  ", clean)
    clean <- gsub("\\\\;", " ", clean)
    clean <- gsub("\\\\,", " ", clean)
    clean <- gsub("\\\\top", "'", clean)
    clean <- gsub("\\\\cdot", ".", clean)
    clean <- gsub("\\\\ldots", "...", clean)
    clean <- gsub("\\\\left|\\\\right", "", clean)
    clean <- gsub("\\\\left\\(", "(", clean)
    clean <- gsub("\\\\right\\)", ")", clean)
    clean <- gsub("\\\\frac\\{([^}]*)\\}\\{([^}]*)\\}", "(\\1)/(\\2)", clean)
    cat(sprintf("  %s\n", clean))
  }
}


## -- On-demand DCP analysis overlay --------------------------------------
## Computes DCP analysis as a SEPARATE pass -- never called from
## .viz_walk_tree() or .viz_build_model(). This ensures zero overhead
## on the normal visualization path. The cost of re-walking the expression
## tree is acceptable since this is only done when the user requests it
## (i.e., the problem is NOT DCP and we want to explain why).

## Walk the expression tree and VizNode tree in parallel, building a map
## from VizNode ID -> DCP violation info.
.viz_dcp_walk <- function(expr, viz_node, violations) {
  if (S7_inherits(expr, Leaf)) return()

  ## Diagnose this expression (returns NULL if DCP)
  diag <- .dcp_diagnose_expr(expr)
  if (!is.null(diag)) {
    violations[[viz_node$id]] <- list(
      id         = viz_node$id,
      atom_class = viz_node$atom_class,
      curvature  = viz_node$curvature,
      shape      = viz_node$shape,
      violation  = diag
    )
  }

  ## Recurse into children in parallel
  for (i in seq_along(expr@args)) {
    .viz_dcp_walk(expr@args[[i]], viz_node$children[[i]], violations)
  }
}

## Build the complete DCP overlay for a problem. Returns a list with:
##   $is_dcp         -- logical
##   $objective_ok   -- logical
##   $objective_issue -- string or NULL
##   $constraint_issues -- list of per-constraint diagnostics
##   $violations     -- named list: VizNode ID -> violation info
.viz_dcp_overlay <- function(problem, model) {
  prob_is_dcp <- is_dcp(problem)
  result <- list(is_dcp = prob_is_dcp)
  if (prob_is_dcp) return(result)

  sense <- model$problem$sense
  obj_expr <- problem@objective@args[[1L]]

  ## Objective analysis
  obj_is_dcp <- is_dcp(problem@objective)
  result$objective_ok <- obj_is_dcp
  if (!obj_is_dcp) {
    if (sense == "minimize") {
      result$objective_issue <- sprintf(
        "Minimize requires a convex objective, but expression is %s.",
        .latex_curvature(obj_expr)
      )
    } else {
      result$objective_issue <- sprintf(
        "Maximize requires a concave objective, but expression is %s.",
        .latex_curvature(obj_expr)
      )
    }
  }

  ## Constraint analysis
  constr_issues <- list()
  for (ci in seq_along(problem@constraints)) {
    diag <- .dcp_diagnose_constraint(problem@constraints[[ci]])
    if (!is.null(diag)) {
      diag$index <- ci
      constr_issues <- c(constr_issues, list(diag))
    }
  }
  result$constraint_issues <- constr_issues

  ## Per-node violations: walk expression tree + VizNode tree in parallel
  violations <- new.env(hash = TRUE, parent = emptyenv())
  .viz_dcp_walk(obj_expr, model$objective_tree, violations)
  for (ci in seq_along(problem@constraints)) {
    con <- problem@constraints[[ci]]
    ct <- model$constraint_trees[[ci]]
    for (ai in seq_along(con@args)) {
      .viz_dcp_walk(con@args[[ai]], ct$subtrees[[ai]], violations)
    }
  }
  result$violations <- as.list(violations)

  result
}


## Print DCP analysis section (text mode)
.viz_print_dcp_analysis <- function(problem, model) {
  dcp <- .viz_dcp_overlay(problem, model)
  cli_rule("DCP ANALYSIS")
  cat("  \u2717 Problem is NOT DCP compliant.\n")

  ## -- Objective ------------------------------------
  sense <- model$problem$sense
  if (!isTRUE(dcp$objective_ok)) {
    req <- if (sense == "minimize") "convex" else "concave"
    cat(sprintf("  Objective (%s): requires %s expression\n",
                tools::toTitleCase(sense), req))
    ## Print violations in the objective tree
    .viz_print_dcp_violations(model$objective_tree, dcp$violations, indent = "    ")
  } else {
    cat(sprintf("  Objective (%s): \u2713 DCP OK\n",
                tools::toTitleCase(sense)))
  }

  ## -- Constraint issues ----------------------------
  issues <- dcp$constraint_issues
  if (length(issues) > 0L) {
    for (ci in issues) {
      cat(sprintf("  Constraint [%d] (%s): requires %s expression\n",
                  ci$index, ci$type, ci$requirement))
      ct <- model$constraint_trees[[ci$index]]
      for (st in ct$subtrees) {
        .viz_print_dcp_violations(st, dcp$violations, indent = "    ")
      }
    }
  }
}

## Print DCP violations for a VizNode subtree, using the violations map
.viz_print_dcp_violations <- function(node, violations, indent = "  ") {
  if (node$is_leaf) return()

  viol <- violations[[node$id]]
  if (!is.null(viol)) {
    v <- viol$violation
    cat(sprintf("%s\u2717 %s: %s  [%s]\n",
                indent, node$atom_class, node$curvature,
                paste(node$shape, collapse = "x")))

    ## Per-arg details
    for (ad in v$arg_details) {
      pass_cvx <- if (ad$convex_rule_ok) "\u2713" else "\u2717"
      pass_ccv <- if (ad$concave_rule_ok) "\u2713" else "\u2717"
      cat(sprintf("%s  arg %d: %s, %s  (cvx-rule %s, ccv-rule %s)\n",
                  indent, ad$index, ad$curvature, ad$monotonicity,
                  pass_cvx, pass_ccv))
    }

    ## Reason
    cat(sprintf("%s  => %s\n", indent, v$reason))

    ## Fix hints (if any)
    if (!is.null(v$fix_hints)) {
      for (hint in v$fix_hints) {
        cat(sprintf("%s  Hint: %s\n", indent, hint))
      }
    }
  }

  ## Recurse into children
  for (ch in node$children) {
    .viz_print_dcp_violations(ch, violations, indent = paste0(indent, "  "))
  }
}


# ==========================================================================
# JSON renderer
# ==========================================================================

## Convert a VizNode tree to a JSON-compatible list (strip R classes)
.viz_node_to_list <- function(node) {
  children <- lapply(node$children, .viz_node_to_list)
  list(
    id         = node$id,
    id_latex   = node$id_latex,
    expr_latex = node$expr_latex,
    atom_class = node$atom_class,
    is_leaf    = node$is_leaf,
    shape      = node$shape,
    curvature  = node$curvature,
    sign       = node$sign,
    children   = children,
    annotation = node$annotation
  )
}

.viz_require_jsonlite <- function() {
  if (!requireNamespace("jsonlite", quietly = TRUE)) {
    cli_abort(c(
      "The {.pkg jsonlite} package is required for {.code json} and {.code html} output.",
      "i" = 'Install it with {.run install.packages("jsonlite")}.'
    ))
  }
}

.viz_model_to_json <- function(model, dcp_overlay = NULL) {
  .viz_require_jsonlite()
  json_model <- list(
    problem          = model$problem,
    objective_tree   = .viz_node_to_list(model$objective_tree),
    constraint_trees = lapply(model$constraint_trees, function(ct) {
      list(
        type       = ct$type,
        label      = ct$label,
        shape      = ct$shape,
        expr_latex = ct$expr_latex,
        subtrees   = lapply(ct$subtrees, .viz_node_to_list)
      )
    }),
    stages = model$stages
  )
  ## Inject DCP overlay if provided (only for non-DCP problems)
  if (!is.null(dcp_overlay)) {
    json_model$dcp_analysis <- dcp_overlay
  }
  ## Inject solver data if provided (Stages 4-5)
  if (!is.null(model$solver_data)) {
    json_model$solver_data <- model$solver_data
  }
  jsonlite::toJSON(json_model, auto_unbox = TRUE, pretty = TRUE, null = "null")
}


# ==========================================================================
# Solver data extraction (Stages 4-5: Standard Form + Solver Data)
# ==========================================================================
## These helpers are only called when visualize(solver = ...) is specified.
## They run entirely off the hot path -- never from solve() or problem_data()
## in normal usage.

## Build solver data summary for visualization.
## Calls problem_data() once and extracts a JSON-serializable summary.
##
## @param problem A Problem object.
## @param solver NULL (default solver) or character solver name.
## @param digits Significant digits for numeric display.
## @returns A list with solver_name, interface, dims, cone_product,
##   var_map, blocks, matrices.
.viz_build_solver_data <- function(problem, solver = NULL, digits = 4L) {
  pd <- problem_data(problem, solver = solver)
  data <- pd$data
  chain <- pd$chain
  inv_data <- pd$inverse_data

  ## Determine solver interface (conic vs QP)
  solver_reduction <- chain@reductions[[length(chain@reductions)]]
  interface <- if (S7_inherits(solver_reduction, QpSolver)) "qp" else "conic"
  slv_name <- solver_name(solver_reduction)

  ## Find the ConeMatrixStuffing inverse data (second-to-last reduction)
  cms_idx <- length(inv_data) - 1L
  cms_inv <- if (cms_idx >= 1L) inv_data[[cms_idx]] else NULL

  ## Build variable mapping table
  var_map <- .viz_var_mapping(cms_inv, variables(problem))

  ## Build dimension summary
  dims <- .viz_dims_summary(data, interface)

  ## Build cone product LaTeX
  cone_product <- .viz_cone_product_latex(data[["dims"]])

  ## Build block structure description
  blocks <- .viz_block_structure(data[["dims"]])

  ## Matrix summaries
  matrices <- .viz_matrix_summaries(data, interface, digits)

  list(
    solver_name  = slv_name,
    interface    = interface,
    dims         = dims,
    cone_product = cone_product,
    var_map      = var_map,
    blocks       = blocks,
    matrices     = matrices
  )
}

## Map solver variable indices back to user variables.
## Returns a list of lists, each with: name, offset, size, shape, origin.
.viz_var_mapping <- function(cms_inv, orig_vars) {
  if (is.null(cms_inv)) return(list())

  ## Build set of user variable IDs
  user_ids <- vapply(orig_vars, function(v) as.character(v@id), character(1))
  user_names <- vapply(orig_vars, expr_name, character(1))
  names(user_names) <- user_ids

  var_offsets <- cms_inv@var_offsets
  var_shapes <- cms_inv@var_shapes
  all_ids <- names(var_offsets)

  result <- vector("list", length(all_ids))
  for (i in seq_along(all_ids)) {
    vid <- all_ids[[i]]
    offset <- var_offsets[[vid]]
    shape <- var_shapes[[vid]]
    sz <- prod(shape)
    is_user <- vid %in% user_ids
    nm <- if (is_user) user_names[[vid]] else sprintf("aux_%s", vid)
    origin <- if (is_user) {
      sprintf("user variable (%s)", paste(shape, collapse = "x"))
    } else {
      "auxiliary (canonicalization)"
    }
    result[[i]] <- list(
      name   = nm,
      offset = offset,
      size   = sz,
      shape  = as.integer(shape),
      origin = origin
    )
  }
  result
}

## Build dimension summary from solver data.
.viz_dims_summary <- function(data, interface) {
  A <- data[["A"]]
  if (is.null(A)) {
    ## QP interface may use different field names
    A <- data[["A_eq"]]
  }

  n_vars <- length(data[["c"]])
  if (n_vars == 0L) n_vars <- length(data[["q"]])
  n_constraints <- if (!is.null(A)) nrow(A) else 0L
  nnz_A <- if (!is.null(A)) Matrix::nnzero(A) else 0L
  total <- as.double(n_vars) * as.double(n_constraints)
  sparsity <- if (total > 0) nnz_A / total else 0

  result <- list(
    n_vars        = n_vars,
    n_constraints = n_constraints,
    nnz_A         = nnz_A,
    sparsity      = sparsity
  )

  ## QP: also report nnz(P)
  P <- data[["P"]]
  if (interface == "qp" && !is.null(P)) {
    result$nnz_P <- Matrix::nnzero(P)
  }
  result
}

## Build LaTeX cone product string.
.viz_cone_product_latex <- function(cone_dims) {
  if (is.null(cone_dims)) return("")
  parts <- character(0)

  if (cone_dims@zero > 0L)
    parts <- c(parts, sprintf("\\{0\\}^{%d}", cone_dims@zero))
  if (cone_dims@nonneg > 0L)
    parts <- c(parts, sprintf("\\mathbb{R}_+^{%d}", cone_dims@nonneg))
  for (s in cone_dims@soc)
    parts <- c(parts, sprintf("\\mathcal{Q}^{%d}", s))
  for (s in cone_dims@psd)
    parts <- c(parts, sprintf("\\mathcal{S}_+^{%d}", s))
  if (cone_dims@exp > 0L)
    parts <- c(parts, sprintf("\\mathcal{K}_{\\exp}^{%d}", cone_dims@exp))
  for (alpha in cone_dims@p3d)
    parts <- c(parts, sprintf("\\mathcal{K}_{\\mathrm{pow}}^{%.2g}", alpha))
  for (alphas in cone_dims@pnd) {
    a_str <- paste(sprintf("%.2g", alphas), collapse = ",")
    parts <- c(parts, sprintf("\\mathcal{K}_{\\mathrm{pow}}^{(%s)}", a_str))
  }

  if (length(parts) == 0L) return("\\mathbb{R}^{0}")
  paste(parts, collapse = " \\times ")
}

## Build block structure description from cone dimensions.
## Returns a list of lists, each with: type, label, n_rows, latex.
.viz_block_structure <- function(cone_dims) {
  if (is.null(cone_dims)) return(list())
  blocks <- list()
  row <- 0L

  if (cone_dims@zero > 0L) {
    nr <- cone_dims@zero
    blocks <- c(blocks, list(list(
      type = "zero", label = "equalities",
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\{0\\}^{%d}", nr)
    )))
    row <- row + nr
  }

  if (cone_dims@nonneg > 0L) {
    nr <- cone_dims@nonneg
    blocks <- c(blocks, list(list(
      type = "nonneg", label = "inequalities",
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\mathbb{R}_+^{%d}", nr)
    )))
    row <- row + nr
  }

  for (i in seq_along(cone_dims@soc)) {
    nr <- cone_dims@soc[i]
    blocks <- c(blocks, list(list(
      type = "soc", label = sprintf("SOC(%d)", nr),
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\mathcal{Q}^{%d}", nr)
    )))
    row <- row + nr
  }

  for (i in seq_along(cone_dims@psd)) {
    n <- cone_dims@psd[i]
    nr <- as.integer(n * (n + 1L) / 2L)
    blocks <- c(blocks, list(list(
      type = "psd", label = sprintf("PSD(%d)", n),
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\mathcal{S}_+^{%d}", n)
    )))
    row <- row + nr
  }

  if (cone_dims@exp > 0L) {
    nr <- 3L * cone_dims@exp
    blocks <- c(blocks, list(list(
      type = "exp", label = sprintf("ExpCone x%d", cone_dims@exp),
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\mathcal{K}_{\\exp}^{%d}", cone_dims@exp)
    )))
    row <- row + nr
  }

  if (length(cone_dims@p3d) > 0L) {
    nr <- 3L * length(cone_dims@p3d)
    blocks <- c(blocks, list(list(
      type = "pow3d", label = sprintf("PowCone3D x%d", length(cone_dims@p3d)),
      start = row + 1L, n_rows = nr,
      latex = sprintf("\\mathcal{K}_{\\mathrm{pow}}^{%d}", length(cone_dims@p3d))
    )))
    row <- row + nr
  }

  blocks
}

## Summarize each matrix/vector in the solver data.
.viz_matrix_summaries <- function(data, interface, digits) {
  .summarize_mat <- function(M, name) {
    if (is.null(M)) return(NULL)
    if (is.numeric(M) && !is.matrix(M)) {
      ## Vector
      list(
        name    = name,
        type    = "vector",
        length  = length(M),
        nnz     = sum(M != 0),
        range   = if (length(M) > 0L) c(min(M), max(M)) else c(0, 0)
      )
    } else {
      ## Matrix (possibly sparse)
      nr <- nrow(M)
      nc <- ncol(M)
      nnz <- if (inherits(M, "sparseMatrix")) Matrix::nnzero(M) else sum(M != 0)
      total <- as.double(nr) * as.double(nc)
      density <- if (total > 0) nnz / total else 0

      res <- list(
        name    = name,
        type    = "matrix",
        nrow    = nr,
        ncol    = nc,
        nnz     = nnz,
        density = density
      )

      ## For small problems, include sparse triplet for sparsity pattern
      if (total > 0 && total < 2000 && inherits(M, "sparseMatrix")) {
        trip <- as(M, "TsparseMatrix")
        res$triplet <- list(
          i = as.integer(trip@i + 1L),  # 1-based
          j = as.integer(trip@j + 1L),
          x = round(trip@x, digits)
        )
      }
      res
    }
  }

  result <- list()
  if (interface == "conic") {
    result$A <- .summarize_mat(data[["A"]], "A")
    result$b <- .summarize_mat(data[["b"]], "b")
    result$c <- .summarize_mat(data[["c"]], "c")
    if (!is.null(data[["P"]])) result$P <- .summarize_mat(data[["P"]], "P")
  } else {
    ## QP interface
    result$P <- .summarize_mat(data[["P"]], "P")
    result$q <- .summarize_mat(data[["q"]], "q")
    result$A_eq <- .summarize_mat(data[["A_eq"]], "A_eq")
    result$b_eq <- .summarize_mat(data[["b_eq"]], "b_eq")
    result$F_ineq <- .summarize_mat(data[["F_ineq"]], "F_ineq")
    result$g_ineq <- .summarize_mat(data[["g_ineq"]], "g_ineq")
  }
  result
}


# ==========================================================================
# Text renderer for Stages 4-5
# ==========================================================================

## Print solver data for text output
.viz_print_solver_data <- function(sd) {
  cli_rule("STANDARD CONE FORM ({sd$solver_name})")

  if (sd$interface == "conic") {
    cat("  minimize  c'x\n")
    cat("  s.t.      Ax + s = b,  s in K\n\n")
  } else {
    cat("  minimize  (1/2)x'Px + q'x\n")
    cat("  s.t.      A_eq x = b_eq\n")
    cat("            F x <= g\n\n")
  }

  ## Dimension table
  d <- sd$dims
  cat(sprintf("  Variables (n):     %d\n", d$n_vars))
  cat(sprintf("  Constraints (m):   %d\n", d$n_constraints))
  cat(sprintf("  nnz(A):           %d\n", d$nnz_A))
  cat(sprintf("  Density:          %.1f%%\n", d$sparsity * 100))
  if (!is.null(d$nnz_P))
    cat(sprintf("  nnz(P):           %d\n", d$nnz_P))

  ## Cone product (LaTeX-to-text)
  if (sd$interface == "conic" && nzchar(sd$cone_product)) {
    kstr <- sd$cone_product
    kstr <- gsub("\\\\times", "x", kstr)
    kstr <- gsub("\\\\\\{0\\\\\\}", "{0}", kstr)
    kstr <- gsub("\\\\mathbb\\{R\\}_\\+", "R+", kstr)
    kstr <- gsub("\\\\mathcal\\{Q\\}", "Q", kstr)
    kstr <- gsub("\\\\mathcal\\{S\\}_\\+", "S+", kstr)
    kstr <- gsub("\\\\mathcal\\{K\\}_\\{\\\\exp\\}", "K_exp", kstr)
    kstr <- gsub("\\\\mathcal\\{K\\}_\\{\\\\mathrm\\{pow\\}\\}", "K_pow", kstr)
    kstr <- gsub("\\^\\{([^}]*)\\}", "^\\1", kstr)
    cat(sprintf("\n  K = %s\n", kstr))
  }

  ## Variable mapping
  if (length(sd$var_map) > 0L) {
    cat("\n  Variable Mapping:\n")
    for (vm in sd$var_map) {
      idx_lo <- vm$offset + 1L
      idx_hi <- vm$offset + vm$size
      cat(sprintf("    x[%d:%d]  %-12s  %s\n",
                  idx_lo, idx_hi, vm$name, vm$origin))
    }
  }

  ## Block structure
  if (length(sd$blocks) > 0L) {
    cat("\n  Block Structure of A:\n")
    for (blk in sd$blocks) {
      cat(sprintf("    rows %d-%d  (%d rows)  %s\n",
                  blk$start, blk$start + blk$n_rows - 1L,
                  blk$n_rows, blk$label))
    }
  }

  ## Matrix summaries
  if (length(sd$matrices) > 0L) {
    cli_rule("SOLVER DATA MATRICES")
    for (ms in sd$matrices) {
      if (is.null(ms)) next
      if (ms$type == "vector") {
        cat(sprintf("  %s: vector, length %d, nnz %d, range [%s, %s]\n",
                    ms$name, ms$length, ms$nnz,
                    formatC(ms$range[1], format = "g", digits = 4L),
                    formatC(ms$range[2], format = "g", digits = 4L)))
      } else {
        cat(sprintf("  %s: %d x %d, nnz %d (%.1f%%)\n",
                    ms$name, ms$nrow, ms$ncol,
                    ms$nnz, ms$density * 100))
      }
    }
  }
}


# ==========================================================================
# Entry point: visualize()
# ==========================================================================

#' Visualize the Canonicalization Pipeline of a CVXR Problem
#'
#' Displays the Smith form decomposition of a convex optimization problem,
#' showing each stage of the DCP canonicalization pipeline:
#' expression tree, Smith form, relaxed Smith form, conic form, and
#' (optionally) standard cone form and solver data.
#'
#' @param problem A [Problem] object.
#' @param output Character: output format.
#'   \describe{
#'     \item{\code{"text"}}{Console display (default).}
#'     \item{\code{"json"}}{JSON data model (for interop with HTML/Python).}
#'     \item{\code{"html"}}{Interactive D3+KaTeX HTML (Phase 2).}
#'     \item{\code{"latex"}}{LaTeX align* environments (Phase 3).}
#'     \item{\code{"tikz"}}{TikZ forest tree diagrams (Phase 3).}
#'   }
#' @param solver Solver specification for matrix stuffing stages (4-5).
#'   \code{NULL} (default) shows only Stages 0-3 with zero overhead.
#'   \code{TRUE} uses the default solver (same as [psolve()]).
#'   A character string (e.g., \code{"Clarabel"}) uses that specific solver.
#' @param digits Integer: significant digits for displaying scalar constants.
#'   Integer-valued constants (0, 1, -3) always display without decimals
#'   regardless of this setting.  Defaults to 4.
#' @param file Character: path for HTML output file.  If \code{NULL}
#'   (default), a temporary file is used.
#' @param open Logical: whether to open the HTML file in a browser.
#'   Defaults to \code{TRUE} in interactive sessions.
#' @param doc_base Character: base URL for atom documentation links.
#'   Defaults to the CVXR pkgdown site.
#' @returns For \code{"text"}: invisible model list.
#'   For \code{"json"}: a JSON string (or list if jsonlite not available).
#'   For \code{"html"}: the file path (invisibly).
#'   For other formats: the rendered output (Phase 2+).
#'
#' @examples
#' \dontrun{
#' x <- Variable(3, name = "x")
#' prob <- Problem(Minimize(p_norm(x, 2)), list(x >= 1))
#' visualize(prob)                          # Stages 0-3 only
#' visualize(prob, solver = TRUE)           # Stages 0-5, default solver
#' visualize(prob, solver = "Clarabel")     # Stages 0-5, specific solver
#' visualize(prob, output = "html", solver = TRUE)
#' }
#'
#' @export
visualize <- function(problem,
                      output = c("text", "json", "html", "latex", "tikz"),
                      solver = NULL,
                      digits = 4L,
                      file = NULL,
                      open = interactive(),
                      doc_base = "https://cvxr.rbind.io/reference/") {
  if (!S7_inherits(problem, Problem)) {
    cli_abort("{.fn visualize} requires a {.cls Problem} object.")
  }
  output <- match.arg(output)

  ## Build the data model (Stages 0-3)
  model <- .viz_build_model(problem, digits = as.integer(digits))

  ## Solver data (Stages 4-5) -- only when solver is specified
  if (!is.null(solver)) {
    if (!is_dcp(problem)) {
      cli_warn("Solver data requires a DCP-compliant problem. Skipping Stages 4-5.")
    } else {
      effective_solver <- if (isTRUE(solver)) NULL else solver
      model$solver_data <- tryCatch(
        .viz_build_solver_data(problem, solver = effective_solver,
                               digits = as.integer(digits)),
        error = function(e) {
          cli_warn("Failed to extract solver data: {conditionMessage(e)}")
          NULL
        }
      )
    }
  }

  switch(output,
    text = {
      sense <- model$problem$sense

      ## Expression tree
      cli_rule("Expression Tree ({toupper(sense)})")
      .viz_print_tree(model$objective_tree)

      if (length(model$constraint_trees) > 0L) {
        cli_rule("Constraints")
        for (i in seq_along(model$constraint_trees)) {
          ct <- model$constraint_trees[[i]]
          con <- problem@constraints[[i]]
          con_type <- sub("^CVXR::", "", ct$type)
          lbl <- if (!is.null(ct$label)) paste0(" (", ct$label, ")") else ""
          dcp_mark <- if (is_dcp(con)) cli::col_green("\u2713") else cli::col_red("\u2717")
          cli_text("  {dcp_mark} [{i}] {con_type} {paste(ct$shape, collapse = 'x')}{lbl}")
          for (st in ct$subtrees) {
            .viz_print_tree(st, prefix = "      ", child_prefix = "      ")
          }
        }
      }

      ## DCP analysis (only when problem is NOT DCP -- computed on demand)
      if (!is_dcp(problem)) {
        .viz_print_dcp_analysis(problem, model)
      }

      ## Smith form
      .viz_print_stage("SMITH FORM", model$stages$smith_form)

      ## Relaxed Smith form
      .viz_print_stage("RELAXED SMITH FORM", model$stages$relaxed_smith_form)

      ## Conic form
      .viz_print_stage("CONIC FORM", model$stages$conic_form)

      ## Stages 4-5: solver data (only when solver specified)
      if (!is.null(model$solver_data)) {
        .viz_print_solver_data(model$solver_data)
      }

      invisible(model)
    },

    json = {
      dcp_ov <- if (!is_dcp(problem)) .viz_dcp_overlay(problem, model)
      .viz_model_to_json(model, dcp_overlay = dcp_ov)
    },

    html = {
      dcp_ov <- if (!is_dcp(problem)) .viz_dcp_overlay(problem, model)
      .viz_render_html(model, file = file, open = open, doc_base = doc_base,
                       dcp_overlay = dcp_ov)
    },

    latex = {
      cli_inform("LaTeX output will be available in Phase 3.")
      invisible(model)
    },

    tikz = {
      cli_inform("TikZ output will be available in Phase 3.")
      invisible(model)
    }
  )
}

Try the CVXR package in your browser

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

CVXR documentation built on March 6, 2026, 9:10 a.m.