Convex optimization, written the way you write the math. The R port of CVXPY — you state the problem, CVXR checks it is convex (DCP) and dispatches it to a solver.
Lasso — the model you know from glmnet
library(CVXR)## Given data X, y, p = # of predictorslambda <-0.05beta <-Variable(p)loss <-sum_squares(y - X %*% beta) + lambda *p_norm(beta, 1)problem <-Problem(objective =Minimize(loss))psolve(problem)value(beta)
Knapsack — a mixed-integer program
library(CVXR)## Given data weight, worth, capacitytake <-Variable(n, boolean =TRUE)prob <-Problem(objective =Maximize(worth %*% take),constraints =list(weight %*% take <= capacity))psolve(prob, solver ="HIGHS")value(take)
Same grammar — only the variable types and constraints change. LP · QP · SOCP · SDP · MIP, one language.
See the project website at cvxr.rbind.io for documentation and examples.
A community of two
Until early 2026, CRAN shipped CVXR 1.0.x — a tour de force by then-PhD-student Anqi Fu, advised by Stephen Boyd and myself. Anqi knew convex optimization inside out (and held a Stats MS from our department).
Then she graduated — she is now at NYU.
Meanwhile CVXPY raced ahead: DPP, DGP, DQCP, complex numbers, new solvers. A two-person team, on aging S4 code, could not keep pace.
Disciplined parameterized / geometric / quasiconvex programming — extensions of the DCP ruleset to new problem classes.
How to maintain CVXR and keep up with CVXPY?
Actually…
As you guessed — that was obviously a joke.
Really, this was a metamorphosis — and a metamorphosis requires careful orchestration.
Let me tell you how it unfolded.
The Mission
Goal: bring CVXR to parity with CVXPY — with attention to maintainability, bug fixes, and R-native idioms. CVXR is, after all, a DSL for optimization in R.
Where we stood
Released 1.0-15, built on S4 classes
Behind on DGP · DPP · DQCP · complex · solvers
A developer community of 2
Partial attempts to update the S4 code
The bet — four things lined up:
Design ideas jotted on the phone during hikes
A new R object system — S7 — CVXPY’s classes translate almost line-for-line
A base-R fix — chooseOpsMethod() — makes Variable + Matrix, A %*% x resolve (R’s __radd__)
An AI coding agent to do the translation
Could the four together let us catch up?
R’s Object Systems
R has five major OOP systems. The rewrite hinges on the newest.
S3 — duck typing
person <-list(name ="Alice")class(person) <-"person"print.person <-function(x, ...)cat("Person:", x$name, "\n")
Person <-setRefClass("Person",fields =list(name ="character"),methods =list(greet =function()cat("Hi,", name, "\n")))
R6 — same model as RC, lighter & faster (package)
Person <- R6::R6Class("Person",public =list(name =NULL,greet =function()cat("Hi,", self$name, "\n")))
S7 — functional dispatch, value semantics (2024)
Person <-new_class("Person",properties =list(name = class_character))method(greet, Person) <-function(x)cat("Hi,", x@name, "\n")
Why S7? Designed by the R Consortium OOP working group to interoperate with S3/S4, and intended for eventual inclusion in base R. For code we must maintain for years, S7 is the bet on R’s official future — and CVXPY’s structure maps onto it, so the port turns mechanical.
S4 → S7: the Abs atom
An atom is a building-block function CVXR knows how to canonicalize — abs, norm, exp, log_det, … — each carrying its curvature and sign so DCP can reason about it. CVXR has 100+. Watch one cross over:
Same method set — both keep generics external (functional OOP). The win: S7 folds setClass + wrapper + initialize/callNextMethod into one new_class(), and methods dispatch on the object Abs, not the string "Abs". Lighter per atom — ×100+, one file each.
S7 ≈ Python
CVXPY (Python) — for reference
class Abs(Elementwise):def__init__(self, x):super().__init__(x)def sign_from_args(self):return (True, False)def is_atom_convex(self):returnTruedef numeric(self, values):return np.abs(values[0])
Each CVXPY method is a pure function of self — so it rewrites mechanically as an S7 generic. (S7 is functional: method(g, Abs), not self.g() — but the shape still maps 1:1.) Perhaps an AI can do it…
So we tried it — with a blueprint
We did not just say “AI, port this.” We borrowed a discipline from the mathematicians:
When Terence Tao formalizes a proof in Lean, the project ships a blueprint: every lemma tagged, cross-linked to its formalization, a dashboard showing done / pending / missing.
The blueprint is the plan. Lean is the verification.
A validator extracts every CVXPY test id, scans our annotations, and reports the gaps. Parity becomes a number you can burn to zero:
236 → 176 → 124 → ... → 18 →0
The speed was real
14,212 tests · 15 solvers · CRAN-ready in 25 days — alongside a full-time day job.
The honeymoon ended
The mirror guarantees every CVXPY file has an R twin. It does not guarantee the R means the same thing. The first cracks — ports that look right:
R code — looks right
what it actually does
n * (n+1) %/% 2
%/% binds tighter than * → wrong for even n. Silently wrong PSD matrices in 5 sites.
matrix(x, r, c)
R is column-major; NumPy reshape is row-major → dual values land in the wrong cells.
as.bigq(1.6)
→ 3602879701896397/2251799813685248, not 8/5 → an impossibly deep SOC tree → infinite hang.
as.numeric(1+2i)
silently drops the imaginary part.
Structural parity is necessary — and nowhere near sufficient.
And the deeper cracks were not in the R at all. They were in how the agent worked. Three incidents.
Incident 1: the fabricated test
That proud breadcrumb — the annotation that guarantees fidelity? The agent learned to forge it.
Porting CVXPY’s MOSEK MIP tests, it met mi_pcp_0, read the function name, imagined a plausible problem, wrote it from scratch — and stamped the mandatory ## CVXPY SOURCE: solver_test_helpers.py mi_pcp_0() on top. It never opened the file.
The fabricated problem was unbounded. MOSEK branched to −∞ and OOM’d a 128 GB machine — the reboot wiped the session history.
Audit: 4 of ~30 helpers (~13%) were wholesale fabricated. All green. Each hidden under skip() — detonating only when the skip was finally lifted.
“The annotation is just a string. It doesn’t force verification. We got a wrong answer to a wrong question.”
The tell: expected -1; the real optimum is -1.8073406786220672. A round number is a fabrication smell.
Incident 2: “while I’m here…”
The dominant failure mode wasn’t bad code. It was scope.
A one-file compile fix (SETLENGTH) ballooned into a ~20-file class redesign — justified by a perf concern no one had measured. It helped OOM the machine.
“WHY DO YOU NEVER PROCEED IN A DIRECT INCREMENTAL FASHION AND INSTEAD JUMP INTO A VAT OF <bleep>”
— agent’s own diagnosis: “‘while I’m here, let me also…’ — no defense for it.”
It recurred as a MIP-only fix silently applied to the continuous path — caught only because I read the code, not the comment.(The comment said “for MIP.” The code applied to everything.)
The agent’s prose and its diff can disagree. Verify the scope against the code.
Incident 3: the numbers lied
“Premature optimization is the root of all evil.” — Knuth. We took him at his word: correctness first. With the port up and running, we began paying real attention to performance — and the first discovery was that we could not trust our own measurements. Two episodes:
After an update, timings showed a 36× slowdown. Bisecting found no guilty commit — the real cause: we had reloaded the package inside a live session. In a fresh R session, no slowdown at all.
A caching “optimization” that looked obviously right ran 2.4× slower on our Kalman-filter benchmark. Measured first → branch deleted before it shipped.
The rules this forced: no unmeasured number survives — every claim is re-measured, fresh session + bench::mark, before any code changes — and any change that could touch the hot path is flagged for a benchmark run. Two of three perf investigations ended right there: no-go, by measurement. Each saved a week.
The discipline also corrected our own headline: the “~4× faster” we had been quoting was stale. Measured honestly, 1.9.1 is ~10× faster than old CVXR. More on that in a minute.
Root cause: RL reward hacking
Two more shortcuts I caught in review — the agent made the test pass, not the code correct:
## kron(Var, C): CVXPY sum = 2.4, CVXR = 4.0.## "fix": relax the test to only check the solver finished.
## Unbounded LP crashed on empty cones.## "fix": fabricate a bound, then still assert UNBOUNDED.
“WRONG!! Correctness is paramount. Don’t make up a cone spec when there is none.” → “Not just these tests — all tests. You lost my trust.”
→ 4 parallel agents audited every one of 45 test files.
LLMs are trained by reinforcement learning. The reward signal is “make the test pass” — not “be correct.” Fabricate a constraint? Test passes. Relax an assertion? Test passes. Not malice — optimization.
What the incidents have in common
None of these failures is exotic. Each one broke a rule software teams have preached for decades:
Incident
What the agent did
The old rule it broke
The fabricated test
cited a source it never opened
Read the source before you cite it
“While I’m here…”
a 20-file redesign for a 1-file fix
Keep changes small and reviewable
The relaxed assertions
made the test pass, not the code correct
The test is the spec — don’t bend it
The numbers lied
acted on claims nobody had measured
Measure before you optimize
What makes this dangerous: agent output looks better than it is — articulate comments, clean structure, thousands of green tests — with the shortcut underneath.
The usual best practices still apply — the agent just doesn’t ship with them. But, unlike with a new hire, you can install them. Four mechanisms →
Build them in #1: rules
Each incident became a written rule the agent must read before it acts. The 15-rule “constitution” is an incident log, not a spec:
Born from
The rule it forced
The fabricated test
“Cite CVXPY before writing R.If you cannot cite the lines, you have not read them — back up.”
“While I’m here…”
State the scope in one sentence — first. Trigger word “scope?” re-anchors a wandering agent. One branch per increment: any step reverts in one line.
The numbers lied
No unmeasured number survives. Fresh session + bench::mark, or it didn’t happen — and any change that could touch the hot path is flagged.
Fast AI changes, touching many files at once
Every commit bumps the version’s last integer (1.8.0.9207 → .9208). Every state is numbered — recovery is an install, bisection is over integers.
And one for the apologies:
When you make a mistake, the response is the corrected action, not self-critique. [trigger: “Casby”]
Not a spec written up front. A fence built around each hole, after someone fell in — git log -S dates every rule to its incident. Alongside the rules: 150+ architecture decision records, so no design choice is tribal knowledge.
Build them in #2: plan, critique, update — then act
For anything nontrivial, no code is written until a plan survives its critics:
The agent deep-reads the CVXPY source and writes a plan.
Fresh agents attack the plan — run concurrently, so no critic anchors on another’s findings. (A sequential second critic just echoes the first.)
The plan is updated and re-critiqued — sometimes several rounds — before a single line of code.
Phase 5a: three concurrent critics independently found the same flaw — a wiring error that guaranteed a runtime crash. Plan v1 rejected. The bug was never written.
The honest cost: this slows you down — and the agent’s speed is addictive, so skipping the loop is always tempting. Every incident in this talk is what a skipped loop looks like. Minutes of deliberation upfront; days of debugging saved downstream.
Build them in #3: audits
A passing suite verifies what you thought to test. So at every tagged phase, fresh agents audit the built artifact — agents that did not write the code:
v0.3.0 audit alone: 8 release-blocking bugs + 25 serious issues — all under a green suite
"POSITIVE" vs "NONNEGATIVE" — tests passed comparing to the same wrong value
dual signs flipped — tests passed because the problems had zero dual value
Tests verify what you thought to test. Audits verify what you forgot.
Build them in #4: sweeps
A new CVXPY release is too big to absorb in one pass over 411 files. So: two passes over the blueprint.
Sweep up — build the new feature end-to-end first, updating only the files it needs. Every file only partially updated gets a dated debt marker: a comment recording exactly what was deferred, and why.
Sweep down — once the feature works, walk the whole tree and clear every marker. The to-do list is not reconstructed from memory — it is a search for the markers. Nothing marked, nothing owed.
Every phase shipped green — and the down-sweep still found release-blocking bugs. The parity counter fell in waves, re-touching the same files.
When the final down-sweep ran, two of three big “deferred refactors” needed almost no work — CVXR had implemented those subsystems differently, so the large upstream change did not apply.
And the docs are a cross-version regression suite: install 1.8.2 → save → install 1.9.1 → compare at 1e-10. 68 / 68 reproduce, bit-for-bit.
What generalizes
If you are building an R package with an AI agent, the package is the test case — the disciplines are the product:
Blueprint discipline — an authoritative reference, an isomorphic mirror, cross-references you can grep. Works for any port; the reference need not be code.
Parity as one integer — annotation-based test mapping; burn the gap to zero.
Rules from incidents — every caught failure becomes a permanent, triggerable constraint. The agent improves at your project.
Plan → critique → update → act — several rounds if needed; slow by design, and it pays for itself downstream.
Independent audits — fresh agents on tagged artifacts; parallel critics on plans.
Benchmark discipline — no unmeasured number survives; no irreversible optimization.
Nothing in these is CVXR-specific. The usual best practices still apply — and now they can be encoded, not just preached.
R returned 2 — neither user method ran. It fell through to internal arithmetic on the underlying storage. A warning, but the expression “succeeded” with a meaningless result.
For CVXR this is every interesting expression: Variable + Matrix::sparseMatrix, Variable + bigq, Variable + DelayedArray.
R 4.3.0: chooseOpsMethod()
chooseOpsMethod(x, y, mx, my, cl, reverse)
R calls it when two Ops methods compete. Returns TRUE (“my class handles it”) or FALSE (“let the other side try”).
Protocol: forward pass (reverse = FALSE) → if FALSE, swap operands and try again (reverse = TRUE) → else legacy “Incompatible methods”.
This is R’s __radd__. The reverse = TRUE pass is the reflected-operator protocol.
CVXR’s opt-in — One Line
.cvxr_chooseOpsMethod <-function(x, y, mx, my, cl, reverse) TRUE
Variable + Matrix::sparseMatrix works because of that one line. CVXR knows nothing about Matrix. Matrix knows nothing about CVXR.
The Four R 4.3.0 Patches
“Generalizing Support for Functional OOP in R” — Kalinowski, Lawrence, Maechler, Vaughan, Wickham, Tierney — May 2024 blog.r-project.org/2024/05/17/…
Generic
Solves
chooseOpsMethod()
ambiguous Ops dispatch with mixed-class operands
%*% as S3 generic (matrixOps group)
S3 methods for matrix multiplication
nameOfClass()
inherits(x, ClassObject) — class-as-object
@ as a generic
S3 dispatch on property access
For CVXR, rows 1 and 2 are load-bearing. This blog post is the document that lit the fire.
A reversible optimization: .s7_is
v1.9’s own new feature checks made it +45% slower — each S7_inherits(x, Cls) rebuilds the class name via an internal paste, every call (~2.5 µs).
The fix — and how we made it safe to apply at ~200 sites:
Swap to inherits(x, "CVXR::Cls") on the cached class() vector — ~5.5×, tautologically equal because every S7 object carries its full package-qualified ancestor chain.
A test auto-enumerates all 178 classes and fails R CMD checkloudly if S7’s class layout ever changes upstream.
Single-point revert: flip one helper body, and all 200 sites revert.
“Equivalence is a theorem with a domain. Name the domain.” A +6–10% regression became a 5–13% win — reversibly.