|
| 1 | +# Design: C Problem Struct |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +Create a native C `problem` struct that encapsulates objective + constraints, with methods for: |
| 6 | +- `forward(u)` - evaluate objective and all constraints |
| 7 | +- `gradient(u)` - return objective gradient (jacobian.T) |
| 8 | +- `jacobian(u)` - return single stacked CSR matrix of constraint jacobians |
| 9 | + |
| 10 | +## Files to Create/Modify |
| 11 | + |
| 12 | +1. **`include/problem.h`** - New header defining problem struct |
| 13 | +2. **`src/problem.c`** - Implementation |
| 14 | +3. **`python/bindings.c`** - Python bindings for problem |
| 15 | +4. **`python/convert.py`** - Update to return problem capsule |
| 16 | +5. **`tests/problem/test_problem.h`** - C tests |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Step 1: Create `include/problem.h` |
| 21 | + |
| 22 | +```c |
| 23 | +#ifndef PROBLEM_H |
| 24 | +#define PROBLEM_H |
| 25 | + |
| 26 | +#include "expr.h" |
| 27 | +#include "utils/CSR_Matrix.h" |
| 28 | + |
| 29 | +typedef struct problem |
| 30 | +{ |
| 31 | + expr *objective; /* Objective expression (scalar) */ |
| 32 | + expr **constraints; /* Array of constraint expressions */ |
| 33 | + int n_constraints; |
| 34 | + int n_vars; |
| 35 | + int total_constraint_size; /* Sum of all constraint sizes */ |
| 36 | + |
| 37 | + /* Pre-allocated storage */ |
| 38 | + double *constraint_values; |
| 39 | + double *gradient_values; /* Dense gradient array */ |
| 40 | + CSR_Matrix *stacked_jac; |
| 41 | +} problem; |
| 42 | + |
| 43 | +problem *new_problem(expr *objective, expr **constraints, int n_constraints); |
| 44 | +void problem_allocate(problem *prob, const double *u); |
| 45 | +void free_problem(problem *prob); |
| 46 | +double problem_forward(problem *prob, const double *u); |
| 47 | +double *problem_gradient(problem *prob, const double *u); |
| 48 | +CSR_Matrix *problem_jacobian(problem *prob, const double *u); |
| 49 | + |
| 50 | +#endif |
| 51 | +``` |
| 52 | + |
| 53 | +--- |
| 54 | + |
| 55 | +## Step 2: Create `src/problem.c` |
| 56 | + |
| 57 | +Key functions: |
| 58 | + |
| 59 | +### `new_problem` |
| 60 | +- Retain (increment refcount) on objective and all constraints |
| 61 | +- Compute `total_constraint_size = sum(constraints[i]->size)` |
| 62 | +- Does NOT allocate storage arrays (use `problem_allocate` separately) |
| 63 | + |
| 64 | +### `problem_allocate` |
| 65 | +Separate function to allocate memory for constraint values and jacobian: |
| 66 | + |
| 67 | +```c |
| 68 | +void problem_allocate(problem *prob, const double *u) |
| 69 | +{ |
| 70 | + /* 1. Allocate constraint values array */ |
| 71 | + prob->constraint_values = malloc(prob->total_constraint_size * sizeof(double)); |
| 72 | + |
| 73 | + /* 2. Allocate jacobian: |
| 74 | + * - First, initialize all constraint jacobians |
| 75 | + * - Count total nnz across all constraints |
| 76 | + * - Allocate CSR matrix with this nnz (may be slight overestimate) |
| 77 | + */ |
| 78 | + int total_nnz = 0; |
| 79 | + for (int i = 0; i < prob->n_constraints; i++) |
| 80 | + { |
| 81 | + expr *c = prob->constraints[i]; |
| 82 | + c->forward(c, u); |
| 83 | + c->jacobian_init(c); |
| 84 | + total_nnz += c->jacobian->nnz; |
| 85 | + } |
| 86 | + |
| 87 | + /* Allocate stacked jacobian with total_constraint_size rows */ |
| 88 | + prob->stacked_jac = alloc_csr(prob->total_constraint_size, prob->n_vars, total_nnz); |
| 89 | + |
| 90 | + /* Note: The actual nnz may be smaller after evaluation due to |
| 91 | + * cancellations. Update stacked_jac->nnz after problem_jacobian(). */ |
| 92 | +} |
| 93 | +``` |
| 94 | +
|
| 95 | +### `free_problem` |
| 96 | +- Call `free_expr` on objective and all constraints (decrements refcount) |
| 97 | +- Free allocated arrays and stacked_jac |
| 98 | +
|
| 99 | +### `problem_forward` |
| 100 | +```c |
| 101 | +double problem_forward(problem *prob, const double *u) |
| 102 | +{ |
| 103 | + prob->objective->forward(prob->objective, u); |
| 104 | + double obj_val = prob->objective->value[0]; |
| 105 | +
|
| 106 | + int offset = 0; |
| 107 | + for (int i = 0; i < prob->n_constraints; i++) |
| 108 | + { |
| 109 | + expr *c = prob->constraints[i]; |
| 110 | + c->forward(c, u); |
| 111 | + memcpy(prob->constraint_values + offset, c->value, c->size * sizeof(double)); |
| 112 | + offset += c->size; |
| 113 | + } |
| 114 | + return obj_val; |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +### `problem_gradient` |
| 119 | +- Run forward pass on objective |
| 120 | +- Call jacobian_init + eval_jacobian |
| 121 | +- Objective jacobian is 1 x n_vars row vector |
| 122 | +- Copy sparse row to dense `gradient_values` array |
| 123 | +- Return pointer to internal array |
| 124 | + |
| 125 | +### `problem_jacobian` |
| 126 | +- Forward + jacobian for each constraint |
| 127 | +- Stack CSR matrices vertically: |
| 128 | + - Total rows = `total_constraint_size` |
| 129 | + - Copy row pointers with offset, copy col indices and values |
| 130 | +- Lazy allocate/reallocate `stacked_jac` based on total nnz |
| 131 | + |
| 132 | +--- |
| 133 | + |
| 134 | +## Step 3: Update `python/bindings.c` |
| 135 | + |
| 136 | +Add capsule and functions: |
| 137 | + |
| 138 | +```c |
| 139 | +#define PROBLEM_CAPSULE_NAME "DNLP_PROBLEM" |
| 140 | + |
| 141 | +static void problem_capsule_destructor(PyObject *capsule) { ... } |
| 142 | + |
| 143 | +static PyObject *py_make_problem(PyObject *self, PyObject *args) |
| 144 | +{ |
| 145 | + PyObject *obj_capsule, *constraints_list; |
| 146 | + // Parse objective capsule and list of constraint capsules |
| 147 | + // Extract expr* pointers, call new_problem |
| 148 | + // Return PyCapsule |
| 149 | +} |
| 150 | + |
| 151 | +static PyObject *py_problem_forward(PyObject *self, PyObject *args) |
| 152 | +{ |
| 153 | + // Returns: (obj_value, constraint_values_array) |
| 154 | +} |
| 155 | + |
| 156 | +static PyObject *py_problem_gradient(PyObject *self, PyObject *args) |
| 157 | +{ |
| 158 | + // Returns: numpy array of size n_vars |
| 159 | +} |
| 160 | + |
| 161 | +static PyObject *py_problem_jacobian(PyObject *self, PyObject *args) |
| 162 | +{ |
| 163 | + // Returns: (data, indices, indptr, (m, n)) for scipy CSR |
| 164 | +} |
| 165 | +``` |
| 166 | +
|
| 167 | +Add to DNLPMethods: |
| 168 | +```c |
| 169 | +{"make_problem", py_make_problem, METH_VARARGS, "Create problem"}, |
| 170 | +{"problem_forward", py_problem_forward, METH_VARARGS, "Evaluate problem"}, |
| 171 | +{"problem_gradient", py_problem_gradient, METH_VARARGS, "Compute gradient"}, |
| 172 | +{"problem_jacobian", py_problem_jacobian, METH_VARARGS, "Compute constraint jacobian"}, |
| 173 | +``` |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## Step 4: Update `python/convert.py` |
| 178 | + |
| 179 | +```python |
| 180 | +def convert_problem(problem: cp.Problem): |
| 181 | + """Convert CVXPY Problem to C problem struct.""" |
| 182 | + var_dict = build_variable_dict(problem.variables()) |
| 183 | + |
| 184 | + c_objective = _convert_expr(problem.objective.expr, var_dict) |
| 185 | + c_constraints = [_convert_expr(c.expr, var_dict) for c in problem.constraints] |
| 186 | + |
| 187 | + return diffengine.make_problem(c_objective, c_constraints) |
| 188 | + |
| 189 | + |
| 190 | +class Problem: |
| 191 | + """Wrapper for C problem struct with clean Python API.""" |
| 192 | + |
| 193 | + def __init__(self, cvxpy_problem: cp.Problem): |
| 194 | + self._capsule = convert_problem(cvxpy_problem) |
| 195 | + |
| 196 | + def forward(self, u: np.ndarray) -> tuple[float, np.ndarray]: |
| 197 | + return diffengine.problem_forward(self._capsule, u) |
| 198 | + |
| 199 | + def gradient(self, u: np.ndarray) -> np.ndarray: |
| 200 | + return diffengine.problem_gradient(self._capsule, u) |
| 201 | + |
| 202 | + def jacobian(self, u: np.ndarray) -> sparse.csr_matrix: |
| 203 | + data, indices, indptr, shape = diffengine.problem_jacobian(self._capsule, u) |
| 204 | + return sparse.csr_matrix((data, indices, indptr), shape=shape) |
| 205 | +``` |
| 206 | + |
| 207 | +--- |
| 208 | + |
| 209 | +## Step 5: Add Tests |
| 210 | + |
| 211 | +### C tests in `tests/problem/test_problem.h`: |
| 212 | +- `test_problem_forward` - verify objective and constraint values |
| 213 | +- `test_problem_gradient` - verify gradient matches manual calculation |
| 214 | +- `test_problem_jacobian_stacking` - verify stacked matrix structure |
| 215 | + |
| 216 | +### Python tests in `convert.py`: |
| 217 | +- `test_problem_forward` - compare with numpy |
| 218 | +- `test_problem_gradient` - gradient of sum(log(x)) = 1/x |
| 219 | +- `test_problem_jacobian` - verify stacked jacobian shape and values |
| 220 | + |
| 221 | +--- |
| 222 | + |
| 223 | +## Implementation Order |
| 224 | + |
| 225 | +1. Create `include/problem.h` |
| 226 | +2. Create `src/problem.c` with new_problem, free_problem, problem_forward |
| 227 | +3. Add problem_gradient and problem_jacobian |
| 228 | +4. Add Python bindings to `bindings.c` |
| 229 | +5. Rebuild: `cmake --build build` |
| 230 | +6. Update `convert.py` with Problem class |
| 231 | +7. Add and run tests |
| 232 | + |
| 233 | +## Key Design Notes |
| 234 | + |
| 235 | +- **Memory**: Uses expr refcounting - new_problem retains, free_problem releases |
| 236 | +- **Two-phase init**: `new_problem` creates struct, `problem_allocate` allocates arrays |
| 237 | + - Constraint values array: size = `total_constraint_size` |
| 238 | + - Jacobian: initialize all constraint jacobians first, count total nnz, allocate CSR |
| 239 | + - The allocated nnz may be a slight overestimate; update `stacked_jac->nnz` after evaluation |
| 240 | +- **Hessian**: Deferred - not allocated in this design (to be added later) |
| 241 | +- **API**: Returns internal pointers (caller should NOT free) |
0 commit comments