Skip to content
Merged
51 changes: 41 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,15 @@ prob.solve(nlp=True, solver=cp.IPOPT, best_of=5)
- Target Python version: 3.11+
- Line length: 100 characters
- **IMPORTANT: IMPORTS AT THE TOP** of files - circular imports are the only exception
- Ruff excludes all `*__init__.py` files (configured in `pyproject.toml`)
- Pre-commit hooks include: ruff (with `--fix`), check-jsonschema (GitHub workflows/dependabot), actionlint, and validate-pyproject

## License Header

New files should include the Apache 2.0 license header:
New files should include the Apache 2.0 license header (matching the convention used in the majority of the codebase):
```python
"""
Copyright 2025, the CVXPY developers
Copyright, the CVXPY authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -129,6 +131,8 @@ Key reduction classes in `cvxpy/reductions/`:

For DNLP: `CvxAttr2Constr` → `Dnlp2Smooth` → `NLPSolver`

Note: The standard `SolvingChain` in `solving_chain.py` handles DCP/DGP/DQCP solver selection automatically. NLP solving is triggered explicitly via `prob.solve(nlp=True)` and bypasses the standard chain.

### Solver Categories

- **ConicSolvers** (`cvxpy/reductions/solvers/conic_solvers/`) - SCS, Clarabel, ECOS, etc.
Expand All @@ -142,12 +146,20 @@ The NLP infrastructure provides oracle-based interfaces for nonlinear solvers:
- `Bounds` class: extracts variable/constraint bounds from problem
- `Oracles` class: provides function and derivative oracles (objective, gradient, constraints, jacobian, hessian)
- `dnlp2smooth.py` - Transforms DNLP problems to smooth form via `Dnlp2Smooth` reduction
- DNLP validation: expressions must be smooth (ESR and HSR)
- DNLP validation: expressions must be linearizable (linearizable convex and linearizable concave)
- Problem validity checked via `problem.is_dnlp()` method

### Diff Engine (SparseDiffPy)

The automatic differentiation engine is provided by the [SparseDiffPy](https://github.com/SparseDifferentiation/SparseDiffPy) package (`pip install sparsediffpy`), which wraps the [SparseDiffEngine](https://github.com/SparseDifferentiation/SparseDiffEngine) C library. It builds expression trees from CVXPY problems and computes derivatives (gradients, Jacobians, Hessians) for NLP solvers. New diff engine atoms require C-level additions in SparseDiffPy.
The automatic differentiation engine is provided by the [SparseDiffPy](https://github.com/SparseDifferentiation/SparseDiffPy) package (installed automatically as a hard dependency), which wraps the [SparseDiffEngine](https://github.com/SparseDifferentiation/SparseDiffEngine) C library. It builds expression trees from CVXPY problems and computes derivatives (gradients, Jacobians, Hessians) for NLP solvers.

Adding a new diff engine atom requires:
1. C-level implementation in SparseDiffPy
2. Python-side converter in `cvxpy/reductions/solvers/nlp_solvers/diff_engine/converters.py` (add to `ATOM_CONVERTERS` dict)

The diff engine supports CVXPY `Parameter` objects: `C_problem` registers parameters with the C engine and `update_params()` re-pushes values without rebuilding the expression tree. Sparse parameter values are fused into sparse matmul operations.

`DerivativeChecker` in `nlp_solver.py` provides finite-difference verification of gradients, Jacobians, and Hessians during development.

## Implementing New Atoms

Expand All @@ -164,15 +176,25 @@ The automatic differentiation engine is provided by the [SparseDiffPy](https://g
1. Create a canonicalizer in `cvxpy/reductions/dnlp2smooth/canonicalizers/`
2. The canonicalizer converts non-smooth atoms to smooth equivalents using auxiliary variables
3. Register in `canonicalizers/__init__.py` by adding to `SMOOTH_CANON_METHODS` dict
4. Ensure the atom has proper `is_smooth()`, `is_esr()`, `is_hsr()` methods
4. Classify the atom using the three-way atom-level API (see below)

### DNLP Atom Classification (Three-way)

Each atom implements exactly one of three atom-level methods:

### DNLP Rules (ESR/HSR)
| Category | Method | Examples |
|---|---|---|
| **Smooth** | `is_atom_smooth() → True` | exp, log, power, sin, prod, quad_form |
| **NS-convex** | `is_atom_nonsmooth_convex() → True` | abs, max, norm1, norm_inf, huber |
| **NS-concave** | `is_atom_nonsmooth_concave() → True` | min, minimum |

- **Smooth**: functions that are both ESR and HSR (analogous to affine in DCP)
- **ESR** (Essentially Smooth Respecting): can be minimized or appear in `<= 0` constraints
- **HSR** (Hierarchically Smooth Respecting): can be maximized or appear in `>= 0` constraints
### DNLP Expression-level Rules

Use `expr.is_smooth()`, `expr.is_esr()`, `expr.is_hsr()` to check expression properties.
- **Smooth**: functions that are both linearizable convex and linearizable concave (analogous to affine in DCP)
- **Linearizable Convex**: can be minimized or appear in `<= 0` constraints
- **Linearizable Concave**: can be maximized or appear in `>= 0` constraints

Use `expr.is_smooth()`, `expr.is_linearizable_convex()`, `expr.is_linearizable_concave()` to check expression properties.

## Testing

Expand All @@ -192,3 +214,12 @@ class TestMyFeature(BaseTest):
```

NLP tests are in `cvxpy/tests/nlp_tests/` with Jacobian and Hessian verification tests.

## Benchmarks

Benchmarks use [Airspeed Velocity](https://asv.readthedocs.io/) and live in the `benchmarks/` directory. To run locally:
```bash
cd benchmarks
pip install -e .
asv run
```
10 changes: 2 additions & 8 deletions cvxpy/atoms/affine/affine_atom.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,8 @@ def is_atom_concave(self) -> bool:
"""
return True

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand Down
44 changes: 22 additions & 22 deletions cvxpy/atoms/atom.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,18 @@ def is_atom_affine(self) -> bool:
"""
return self.is_atom_concave() and self.is_atom_convex()

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
raise NotImplementedError("is_atom_esr not implemented for %s."
% self.__class__.__name__)

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
raise NotImplementedError("is_atom_hsr not implemented for %s."
% self.__class__.__name__)
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return False

def is_atom_nonsmooth_convex(self) -> bool:
"""Is the atom nonsmooth and convex?"""
return False

def is_atom_nonsmooth_concave(self) -> bool:
"""Is the atom nonsmooth and concave?"""
return False

def is_atom_log_log_convex(self) -> bool:
"""Is the atom log-log convex?
"""
Expand Down Expand Up @@ -272,34 +272,34 @@ def is_concave(self) -> bool:
return False

@perf.compute_once
def is_esr(self) -> bool:
"""Is the expression epigraph smooth representable?
def is_linearizable_convex(self) -> bool:
"""Is the expression convex after linearizing all smooth subexpressions?
"""
# Applies DNLP composition rule.
if self.is_constant():
return True
elif self.is_atom_esr():
elif self.is_atom_smooth() or self.is_atom_nonsmooth_convex():
for idx, arg in enumerate(self.args):
if not (arg.is_smooth() or
(arg.is_esr() and self.is_incr(idx)) or
(arg.is_hsr() and self.is_decr(idx))):
(arg.is_linearizable_convex() and self.is_incr(idx)) or
(arg.is_linearizable_concave() and self.is_decr(idx))):
return False
return True
else:
return False

@perf.compute_once
def is_hsr(self) -> bool:
"""Is the expression hypograph smooth representable?
def is_linearizable_concave(self) -> bool:
"""Is the expression concave after linearizing all smooth subexpressions?
"""
# Applies DNLP composition rule.
if self.is_constant():
return True
elif self.is_atom_hsr():
elif self.is_atom_smooth() or self.is_atom_nonsmooth_concave():
for idx, arg in enumerate(self.args):
if not (arg.is_smooth() or
(arg.is_hsr() and self.is_incr(idx)) or
(arg.is_esr() and self.is_decr(idx))):
(arg.is_linearizable_concave() and self.is_incr(idx)) or
(arg.is_linearizable_convex() and self.is_decr(idx))):
return False
return True
else:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/abs.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,10 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
def is_atom_nonsmooth_convex(self) -> bool:
"""Is the atom nonsmooth and convex?"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
return False

def is_incr(self, idx) -> bool:
"""Is the composition non-decreasing in argument idx?
"""
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/entr.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,8 @@ def is_atom_concave(self) -> bool:
"""
return True

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/exp.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,8 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_atom_log_log_convex(self) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/huber.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,10 @@ def is_atom_concave(self) -> bool:
"""Is the atom concave?"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
def is_atom_nonsmooth_convex(self) -> bool:
"""Is the atom nonsmooth and convex?"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
return False

def is_incr(self, idx) -> bool:
"""Is the composition non-decreasing in argument idx?"""
return self.args[idx].is_nonneg()
Expand Down
32 changes: 7 additions & 25 deletions cvxpy/atoms/elementwise/hyperbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,8 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand All @@ -74,7 +68,7 @@ def _domain(self) -> List[Constraint]:
"""Returns constraints describing the domain of the node.
"""
return []

def _grad(self, values) -> List[Constraint]:
raise NotImplementedError("Gradient not implemented for sinh.")

Expand Down Expand Up @@ -107,15 +101,9 @@ def is_atom_concave(self) -> bool:
"""Is the atom concave?
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand Down Expand Up @@ -160,10 +148,7 @@ def is_atom_convex(self) -> bool:
def is_atom_concave(self) -> bool:
return False

def is_atom_esr(self) -> bool:
return True

def is_atom_hsr(self) -> bool:
def is_atom_smooth(self) -> bool:
return True

def is_incr(self, idx) -> bool:
Expand Down Expand Up @@ -202,10 +187,7 @@ def is_atom_convex(self) -> bool:
def is_atom_concave(self) -> bool:
return False

def is_atom_esr(self) -> bool:
return True

def is_atom_hsr(self) -> bool:
def is_atom_smooth(self) -> bool:
return True

def is_incr(self, idx) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/kl_div.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,8 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,8 @@ def is_atom_concave(self) -> bool:
"""
return True

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_atom_log_log_convex(self) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/logistic.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,8 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
def is_atom_smooth(self) -> bool:
"""Is the atom smooth?"""
return True

def is_incr(self, idx) -> bool:
Expand Down
10 changes: 2 additions & 8 deletions cvxpy/atoms/elementwise/maximum.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,10 @@ def is_atom_concave(self) -> bool:
"""
return False

def is_atom_esr(self) -> bool:
"""Is the atom esr?
"""
def is_atom_nonsmooth_convex(self) -> bool:
"""Is the atom nonsmooth and convex?"""
return True

def is_atom_hsr(self) -> bool:
"""Is the atom hsr?
"""
return False

def is_atom_log_log_convex(self) -> bool:
"""Is the atom log-log convex?
"""
Expand Down
Loading
Loading