Skip to main content

pounce_cli/
cbf.rs

1//! Reader for the **Conic Benchmark Format** (CBF / `.cbf`), the format the
2//! CBLIB conic benchmark library (<https://cblib.zib.de>) ships its instances
3//! in, plus a mapping to a pounce conic program.
4//!
5//! # Format (the subset CBLIB's exponential-cone GPs use)
6//!
7//! A CBF file is a sequence of keyword blocks, blank-line separated, with `#`
8//! comments. The blocks this reader understands:
9//!
10//! - `VER` — format version (read and ignored).
11//! - `OBJSENSE` — `MIN` or `MAX`.
12//! - `POWCONES` — power-cone parameter table: each entry's weight vector
13//!   `(α₀, α₁)` gives the exponent `α = α₀/(α₀+α₁)`, referenced as `@k:POW`.
14//! - `VAR n k` — `n` scalar variables partitioned into `k` cones, one cone
15//!   per following line as `CONE dim` (`F`/`L+`/`L-`/`L=`/`EXP`/`Q`/`@k:POW`).
16//! - `CON m k` — `m` scalar constraint rows `Ax + b`, each lying in one of `k`
17//!   cones (same syntax). `L=` ⇒ `Ax+b = 0`, `L-` ⇒ `≤ 0`, `L+` ⇒ `≥ 0`.
18//! - `OBJACOORD` / `OBJBCOORD` — sparse objective `c` and constant `c₀`.
19//! - `ACOORD` / `BCOORD` — sparse `A` (`row col val`) and `b` (`row val`).
20//! - `PSDCON` + `HCOORD` / `DCOORD` — affine PSD constraints
21//!   `D_c + Σ_k x_k H_{c,k} ⪰ 0`, mapped to a `Psd` cone on the slack.
22//!
23//! The problem is `min/max cᵀx + c₀  s.t.  x ∈ K_var,  Ax + b ∈ K_con`,
24//! plus any affine PSD constraints.
25//!
26//! # Exponential-cone convention
27//!
28//! CBF's primal exponential cone is `{(u₀,u₁,u₂) : u₀ ≥ u₁·exp(u₂/u₁), u₁>0}`
29//! (the **first** coordinate is the bound), whereas pounce's is
30//! `{(x,y,z) : z ≥ y·exp(x/y), y>0}` (the **third** is the bound). The triple
31//! therefore **reverses**: pounce `(x,y,z) = (u₂, u₁, u₀)`. See
32//! `dev-notes/hsde.md` (the CBLIB benchmark-tier plan).
33
34use pounce_convex::{ConeSpec, QpProblem, Triplet};
35use std::fmt;
36
37/// A parsed CBF cone declaration: a kind and the number of scalar rows it
38/// spans.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct ConeDecl {
41    pub kind: ConeKind,
42    pub dim: usize,
43    /// The power-cone exponent `α ∈ (0, 1)` for [`ConeKind::Pow`]; `None`
44    /// for every other kind.
45    pub alpha: Option<f64>,
46}
47
48/// The CBF cone kinds this reader supports (`F`/`L=`/`L+`/`L-`/`EXP`/`Q`,
49/// plus the 3-D power cone `@k:POW` resolved against `POWCONES`). Unsupported
50/// kinds (PSD `DCOORD`, the rotated SOC `QR`, dual power cones) are rejected
51/// at parse time with a clear error rather than silently mis-handled.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ConeKind {
54    /// `F` — free (ℝ): no constraint.
55    Free,
56    /// `L=` — the zero cone: the rows are equalities.
57    Zero,
58    /// `L+` — nonnegative orthant.
59    Nonneg,
60    /// `L-` — nonpositive orthant.
61    Nonpos,
62    /// `EXP` — the 3-D exponential cone (CBF order; reversed for pounce).
63    Exp,
64    /// `Q` — the second-order cone.
65    SecondOrder,
66    /// `@k:POW` — the 3-D power cone, with the exponent `α` resolved from the
67    /// referenced `POWCONES` parameter set (stored on [`ConeDecl::alpha`]).
68    Pow,
69}
70
71impl ConeKind {
72    /// Parse a plain (non-parametric) cone token. Parametric cones
73    /// (`@k:POW`) are handled by [`parse_cone_token`].
74    fn parse(tok: &str) -> Option<ConeKind> {
75        Some(match tok {
76            "F" => ConeKind::Free,
77            "L=" => ConeKind::Zero,
78            "L+" => ConeKind::Nonneg,
79            "L-" => ConeKind::Nonpos,
80            "EXP" => ConeKind::Exp,
81            "Q" => ConeKind::SecondOrder,
82            _ => return None,
83        })
84    }
85}
86
87/// A parsed CBF instance: the objective, the variable / constraint cone
88/// partitions, and the sparse `A`/`b` (and objective `c`/`c₀`).
89#[derive(Debug, Clone)]
90pub struct CbfModel {
91    /// `true` for `OBJSENSE MIN`, `false` for `MAX`.
92    pub minimize: bool,
93    pub num_var: usize,
94    pub var_cones: Vec<ConeDecl>,
95    pub num_con: usize,
96    pub con_cones: Vec<ConeDecl>,
97    /// Objective linear term `c`, dense (length `num_var`).
98    pub c: Vec<f64>,
99    /// Objective constant `c₀`.
100    pub c0: f64,
101    /// Constraint matrix `A` as `(row, col, val)` triplets.
102    pub a: Vec<(usize, usize, f64)>,
103    /// Constraint constant `b`, dense (length `num_con`).
104    pub b: Vec<f64>,
105    /// Matrix sizes of the affine PSD constraints (`PSDCON`): constraint `c`
106    /// asserts `D_c + Σ_k x_k H_{c,k} ⪰ 0` over a `psdcon_dims[c]`-square
107    /// matrix.
108    pub psdcon_dims: Vec<usize>,
109    /// `HCOORD` entries `(con, var, i, j, val)`: `H_{con,var}[i][j] = val`
110    /// (lower triangle, `i ≥ j`) — the coefficient of scalar variable `var`
111    /// on entry `(i,j)` of PSD constraint `con`.
112    pub hcoord: Vec<(usize, usize, usize, usize, f64)>,
113    /// `DCOORD` entries `(con, i, j, val)`: `D_con[i][j] = val` (lower
114    /// triangle) — the constant term of PSD constraint `con`.
115    pub dcoord: Vec<(usize, usize, usize, f64)>,
116}
117
118/// A CBF instance mapped to a pounce conic program
119/// `min ½xᵀPx + cᵀx s.t. Ax = b, Gx ⪯_K h` (here `P = 0`). The `cones`
120/// partition the rows of `G` in order; `obj_constant` (`c₀`, sign-adjusted)
121/// is added to `solution.obj` to recover the CBF objective value.
122#[derive(Debug, Clone)]
123pub struct ConicProgram {
124    pub prob: QpProblem,
125    pub cones: Vec<ConeSpec>,
126    pub obj_constant: f64,
127}
128
129impl ConicProgram {
130    /// Recover the CBF objective value from a pounce solution objective
131    /// `½xᵀPx + cᵀx`. For a `MAX` instance the linear term was negated when
132    /// building, so the value is `−pounce_obj + c₀`.
133    pub fn cbf_objective(&self, pounce_obj: f64, minimize: bool) -> f64 {
134        if minimize {
135            pounce_obj + self.obj_constant
136        } else {
137            -pounce_obj + self.obj_constant
138        }
139    }
140}
141
142/// A CBF parse / mapping failure, with enough context to locate the problem.
143#[derive(Debug, Clone, PartialEq)]
144pub enum CbfError {
145    /// A required section or token was missing / malformed.
146    Malformed(String),
147    /// A cone kind appeared that this reader does not yet support.
148    UnsupportedCone(String),
149    /// An exponential cone was declared with a dimension other than 3.
150    BadExpDim(usize),
151}
152
153impl fmt::Display for CbfError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            CbfError::Malformed(s) => write!(f, "malformed CBF: {s}"),
157            CbfError::UnsupportedCone(s) => write!(f, "unsupported CBF cone '{s}'"),
158            CbfError::BadExpDim(d) => write!(f, "EXP cone must have dim 3, got {d}"),
159        }
160    }
161}
162
163impl std::error::Error for CbfError {}
164
165/// A cursor over the meaningful (non-blank, non-comment) lines of a CBF file.
166struct Lines<'a> {
167    rows: Vec<&'a str>,
168    pos: usize,
169}
170
171impl<'a> Lines<'a> {
172    fn new(text: &'a str) -> Self {
173        let rows = text
174            .lines()
175            .map(str::trim)
176            .filter(|l| !l.is_empty() && !l.starts_with('#'))
177            .collect();
178        Lines { rows, pos: 0 }
179    }
180
181    fn next(&mut self) -> Option<&'a str> {
182        let row = self.rows.get(self.pos).copied();
183        if row.is_some() {
184            self.pos += 1;
185        }
186        row
187    }
188
189    fn require(&mut self, what: &str) -> Result<&'a str, CbfError> {
190        self.next()
191            .ok_or_else(|| CbfError::Malformed(format!("expected {what}, got end of file")))
192    }
193}
194
195fn parse_usize(tok: &str, what: &str) -> Result<usize, CbfError> {
196    tok.parse()
197        .map_err(|_| CbfError::Malformed(format!("expected integer for {what}, got '{tok}'")))
198}
199
200fn parse_f64(tok: &str, what: &str) -> Result<f64, CbfError> {
201    tok.parse()
202        .map_err(|_| CbfError::Malformed(format!("expected number for {what}, got '{tok}'")))
203}
204
205/// Resolve a cone token to its `(kind, alpha)`. Plain tokens (`F`, `EXP`,
206/// …) go through [`ConeKind::parse`]; a parametric `@k:POW` token looks up
207/// power-cone parameter set `k` in `pow_params` and resolves the exponent
208/// `α = α₀ / (α₀ + α₁)` for the 3-D power cone (parameter vector `(α₀, α₁)`).
209fn parse_cone_token(
210    tok: &str,
211    pow_params: &[Vec<f64>],
212) -> Result<(ConeKind, Option<f64>), CbfError> {
213    if let Some(rest) = tok.strip_prefix('@') {
214        // `@k:KIND` — a reference into a parameter table (only POW today).
215        let (idx, kind) = rest
216            .split_once(':')
217            .ok_or_else(|| CbfError::Malformed(format!("bad parametric cone '{tok}'")))?;
218        if kind != "POW" {
219            return Err(CbfError::UnsupportedCone(format!("@{idx}:{kind}")));
220        }
221        let k = parse_usize(idx, "POW reference index")?;
222        let params = pow_params
223            .get(k)
224            .ok_or_else(|| CbfError::Malformed(format!("POW references @{k}, not declared")))?;
225        if params.len() != 2 {
226            return Err(CbfError::UnsupportedCone(format!(
227                "POW with {} parameters (only the 3-D power cone, 2 parameters, is supported)",
228                params.len()
229            )));
230        }
231        let alpha = params[0] / (params[0] + params[1]);
232        Ok((ConeKind::Pow, Some(alpha)))
233    } else {
234        let kind =
235            ConeKind::parse(tok).ok_or_else(|| CbfError::UnsupportedCone(tok.to_string()))?;
236        Ok((kind, None))
237    }
238}
239
240/// Read a `VAR`/`CON`-style cone partition: a header `total k`, then `k`
241/// lines of `CONE dim`. Returns `(total, cones)` and validates the dims sum.
242fn parse_cone_block(
243    lines: &mut Lines,
244    what: &str,
245    pow_params: &[Vec<f64>],
246) -> Result<(usize, Vec<ConeDecl>), CbfError> {
247    let header = lines.require(what)?;
248    let mut it = header.split_whitespace();
249    let total = parse_usize(it.next().unwrap_or(""), &format!("{what} total"))?;
250    let k = parse_usize(it.next().unwrap_or(""), &format!("{what} cone count"))?;
251    let mut cones = Vec::with_capacity(k);
252    let mut sum = 0;
253    for _ in 0..k {
254        let line = lines.require(&format!("{what} cone"))?;
255        let mut t = line.split_whitespace();
256        let tok = t.next().unwrap_or("");
257        let (kind, alpha) = parse_cone_token(tok, pow_params)?;
258        let dim = parse_usize(t.next().unwrap_or(""), &format!("{what} cone dim"))?;
259        if kind == ConeKind::Exp && dim != 3 {
260            return Err(CbfError::BadExpDim(dim));
261        }
262        if kind == ConeKind::Pow && dim != 3 {
263            return Err(CbfError::Malformed(format!(
264                "{what}: only the 3-D power cone is supported, got POW dim {dim}"
265            )));
266        }
267        sum += dim;
268        cones.push(ConeDecl { kind, dim, alpha });
269    }
270    if sum != total {
271        return Err(CbfError::Malformed(format!(
272            "{what} cone dims sum to {sum}, header says {total}"
273        )));
274    }
275    Ok((total, cones))
276}
277
278/// Parse a CBF instance from its text. Errors on malformed input or a cone
279/// kind outside the supported subset.
280pub fn parse(text: &str) -> Result<CbfModel, CbfError> {
281    let mut lines = Lines::new(text);
282
283    let mut minimize = true;
284    let mut num_var = 0usize;
285    let mut var_cones = Vec::new();
286    let mut num_con = 0usize;
287    let mut con_cones = Vec::new();
288    let mut c = Vec::new();
289    let mut c0 = 0.0;
290    let mut a = Vec::new();
291    let mut b = Vec::new();
292    let mut pow_params: Vec<Vec<f64>> = Vec::new();
293    let mut psdcon_dims: Vec<usize> = Vec::new();
294    let mut hcoord: Vec<(usize, usize, usize, usize, f64)> = Vec::new();
295    let mut dcoord: Vec<(usize, usize, usize, f64)> = Vec::new();
296    let mut seen_var = false;
297
298    while let Some(kw) = lines.next() {
299        match kw {
300            "VER" => {
301                lines.require("VER value")?;
302            }
303            // Power-cone parameter table: `n total`, then for each of the `n`
304            // cones a length followed by that many α weights. Must precede the
305            // `VAR`/`CON` that reference it via `@k:POW`.
306            "POWCONES" => {
307                let header = lines.require("POWCONES header")?;
308                let mut it = header.split_whitespace();
309                let ncones = parse_usize(it.next().unwrap_or(""), "POWCONES count")?;
310                let _total = parse_usize(it.next().unwrap_or(""), "POWCONES total")?;
311                for _ in 0..ncones {
312                    let len = parse_usize(lines.require("POWCONES cone length")?, "POWCONES len")?;
313                    let mut params = Vec::with_capacity(len);
314                    for _ in 0..len {
315                        params.push(parse_f64(
316                            lines.require("POWCONES alpha")?,
317                            "POWCONES alpha",
318                        )?);
319                    }
320                    pow_params.push(params);
321                }
322            }
323            // Affine PSD constraints: header `count`, then one matrix size
324            // per constraint. The constraint `c` is `D_c + Σ_k x_k H_{c,k} ⪰ 0`.
325            "PSDCON" => {
326                let count = parse_usize(lines.require("PSDCON count")?, "PSDCON count")?;
327                for _ in 0..count {
328                    psdcon_dims.push(parse_usize(lines.require("PSDCON dim")?, "PSDCON dim")?);
329                }
330            }
331            // Variable coefficient matrices of the PSD constraints.
332            "HCOORD" => {
333                let nnz = parse_usize(lines.require("HCOORD nnz")?, "HCOORD nnz")?;
334                for _ in 0..nnz {
335                    let line = lines.require("HCOORD entry")?;
336                    let mut t = line.split_whitespace();
337                    let con = parse_usize(t.next().unwrap_or(""), "HCOORD con")?;
338                    let var = parse_usize(t.next().unwrap_or(""), "HCOORD var")?;
339                    let i = parse_usize(t.next().unwrap_or(""), "HCOORD i")?;
340                    let j = parse_usize(t.next().unwrap_or(""), "HCOORD j")?;
341                    let val = parse_f64(t.next().unwrap_or(""), "HCOORD val")?;
342                    hcoord.push((con, var, i, j, val));
343                }
344            }
345            // Constant matrices of the PSD constraints.
346            "DCOORD" => {
347                let nnz = parse_usize(lines.require("DCOORD nnz")?, "DCOORD nnz")?;
348                for _ in 0..nnz {
349                    let line = lines.require("DCOORD entry")?;
350                    let mut t = line.split_whitespace();
351                    let con = parse_usize(t.next().unwrap_or(""), "DCOORD con")?;
352                    let i = parse_usize(t.next().unwrap_or(""), "DCOORD i")?;
353                    let j = parse_usize(t.next().unwrap_or(""), "DCOORD j")?;
354                    let val = parse_f64(t.next().unwrap_or(""), "DCOORD val")?;
355                    dcoord.push((con, i, j, val));
356                }
357            }
358            "OBJSENSE" => {
359                let s = lines.require("OBJSENSE value")?;
360                minimize = match s {
361                    "MIN" => true,
362                    "MAX" => false,
363                    other => {
364                        return Err(CbfError::Malformed(format!("bad OBJSENSE '{other}'")));
365                    }
366                };
367            }
368            "VAR" => {
369                let (n, cones) = parse_cone_block(&mut lines, "VAR", &pow_params)?;
370                num_var = n;
371                var_cones = cones;
372                c = vec![0.0; n];
373                seen_var = true;
374            }
375            "CON" => {
376                let (m, cones) = parse_cone_block(&mut lines, "CON", &pow_params)?;
377                num_con = m;
378                con_cones = cones;
379                b = vec![0.0; m];
380            }
381            "OBJACOORD" => {
382                if !seen_var {
383                    return Err(CbfError::Malformed("OBJACOORD before VAR".into()));
384                }
385                let nnz = parse_usize(lines.require("OBJACOORD nnz")?, "OBJACOORD nnz")?;
386                for _ in 0..nnz {
387                    let line = lines.require("OBJACOORD entry")?;
388                    let mut t = line.split_whitespace();
389                    let col = parse_usize(t.next().unwrap_or(""), "OBJACOORD col")?;
390                    let val = parse_f64(t.next().unwrap_or(""), "OBJACOORD val")?;
391                    if col >= num_var {
392                        return Err(CbfError::Malformed(format!("OBJACOORD col {col} ≥ n")));
393                    }
394                    c[col] += val;
395                }
396            }
397            "OBJBCOORD" => {
398                c0 = parse_f64(lines.require("OBJBCOORD value")?, "OBJBCOORD")?;
399            }
400            "ACOORD" => {
401                let nnz = parse_usize(lines.require("ACOORD nnz")?, "ACOORD nnz")?;
402                a.reserve(nnz);
403                for _ in 0..nnz {
404                    let line = lines.require("ACOORD entry")?;
405                    let mut t = line.split_whitespace();
406                    let row = parse_usize(t.next().unwrap_or(""), "ACOORD row")?;
407                    let col = parse_usize(t.next().unwrap_or(""), "ACOORD col")?;
408                    let val = parse_f64(t.next().unwrap_or(""), "ACOORD val")?;
409                    a.push((row, col, val));
410                }
411            }
412            "BCOORD" => {
413                if b.is_empty() && num_con > 0 {
414                    b = vec![0.0; num_con];
415                }
416                let nnz = parse_usize(lines.require("BCOORD nnz")?, "BCOORD nnz")?;
417                for _ in 0..nnz {
418                    let line = lines.require("BCOORD entry")?;
419                    let mut t = line.split_whitespace();
420                    let row = parse_usize(t.next().unwrap_or(""), "BCOORD row")?;
421                    let val = parse_f64(t.next().unwrap_or(""), "BCOORD val")?;
422                    if row >= num_con {
423                        return Err(CbfError::Malformed(format!("BCOORD row {row} ≥ m")));
424                    }
425                    b[row] += val;
426                }
427            }
428            // Integrality markers: solve the continuous relaxation, so the
429            // index list is read and discarded.
430            "INT" => {
431                let nnz = parse_usize(lines.require("INT count")?, "INT count")?;
432                for _ in 0..nnz {
433                    lines.require("INT entry")?;
434                }
435            }
436            other => {
437                return Err(CbfError::UnsupportedCone(format!("section '{other}'")));
438            }
439        }
440    }
441
442    if !seen_var {
443        return Err(CbfError::Malformed("no VAR section".into()));
444    }
445
446    Ok(CbfModel {
447        minimize,
448        num_var,
449        var_cones,
450        num_con,
451        con_cones,
452        c,
453        c0,
454        a,
455        b,
456        psdcon_dims,
457        hcoord,
458        dcoord,
459    })
460}
461
462impl CbfModel {
463    /// Row-major dense access to `A` is avoided; instead group `A` by row so
464    /// constraint-cone rows can pull their own coefficients.
465    fn rows_of_a(&self) -> Vec<Vec<(usize, f64)>> {
466        let mut rows = vec![Vec::new(); self.num_con];
467        for &(r, col, val) in &self.a {
468            rows[r].push((col, val));
469        }
470        rows
471    }
472
473    /// Map this instance to a pounce conic program. Variable cones become
474    /// slack blocks `s = −Gx ∈ K` (a `G = −I` selection, `h = 0`);
475    /// constraint cones use `s = h − Gx = Ax + b ∈ K`. `L=` rows become
476    /// equalities `Ax = −b`. Exponential triples are reversed, and power
477    /// triples rotated, into pounce cone order (see the per-arm comments).
478    pub fn to_conic(&self) -> Result<ConicProgram, CbfError> {
479        let n = self.num_var;
480        let a_rows = self.rows_of_a();
481
482        let mut g: Vec<Triplet> = Vec::new();
483        let mut h: Vec<f64> = Vec::new();
484        let mut cones: Vec<ConeSpec> = Vec::new();
485        let mut a_eq: Vec<Triplet> = Vec::new();
486        let mut b_eq: Vec<f64> = Vec::new();
487
488        // Push one cone row whose slack must equal the affine form `(coeffs,
489        // constant)`: `s = h − Gx = Σ coeffs·x + constant` ⇒ `G = −coeffs`,
490        // `h = constant`.
491        let push_row =
492            |g: &mut Vec<Triplet>, h: &mut Vec<f64>, coeffs: &[(usize, f64)], constant: f64| {
493                let r = h.len();
494                for &(col, val) in coeffs {
495                    g.push(Triplet::new(r, col, -val));
496                }
497                h.push(constant);
498            };
499
500        // --- Variable cones: the affine form is the variable itself. ---
501        let mut v = 0usize; // running scalar-variable index
502        for cone in &self.var_cones {
503            match cone.kind {
504                ConeKind::Free => {}
505                ConeKind::Nonneg => {
506                    for j in 0..cone.dim {
507                        push_row(&mut g, &mut h, &[(v + j, 1.0)], 0.0);
508                    }
509                    cones.push(ConeSpec::Nonneg(cone.dim));
510                }
511                ConeKind::Nonpos => {
512                    // x ≤ 0 ⇒ slack −x ≥ 0.
513                    for j in 0..cone.dim {
514                        push_row(&mut g, &mut h, &[(v + j, -1.0)], 0.0);
515                    }
516                    cones.push(ConeSpec::Nonneg(cone.dim));
517                }
518                ConeKind::SecondOrder => {
519                    for j in 0..cone.dim {
520                        push_row(&mut g, &mut h, &[(v + j, 1.0)], 0.0);
521                    }
522                    cones.push(ConeSpec::SecondOrder(cone.dim));
523                }
524                ConeKind::Exp => {
525                    // Reverse to pounce order (x,y,z) = (u₂,u₁,u₀).
526                    for j in (0..3).rev() {
527                        push_row(&mut g, &mut h, &[(v + j, 1.0)], 0.0);
528                    }
529                    cones.push(ConeSpec::Exponential);
530                }
531                ConeKind::Pow => {
532                    // CBF power cone (x₀,x₁,x₂): x₀^β₀·x₁^β₁ ≥ |x₂|. pounce
533                    // K_α = {|x| ≤ y^α z^{1−α}} ⇒ (x,y,z) = (x₂, x₀, x₁) with
534                    // α = β₀. Emit slack rows in that pounce order.
535                    let alpha = cone.alpha.ok_or_else(|| {
536                        CbfError::Malformed("POW cone missing its exponent".into())
537                    })?;
538                    push_row(&mut g, &mut h, &[(v + 2, 1.0)], 0.0); // x ← x₂
539                    push_row(&mut g, &mut h, &[(v, 1.0)], 0.0); // y ← x₀
540                    push_row(&mut g, &mut h, &[(v + 1, 1.0)], 0.0); // z ← x₁
541                    cones.push(ConeSpec::Power(alpha));
542                }
543                ConeKind::Zero => {
544                    // x = 0 — an equality on the variable.
545                    for j in 0..cone.dim {
546                        a_eq.push(Triplet::new(b_eq.len(), v + j, 1.0));
547                        b_eq.push(0.0);
548                    }
549                }
550            }
551            v += cone.dim;
552        }
553
554        // --- Constraint cones: the affine form is row `r` of `Ax + b`. ---
555        let mut r = 0usize; // running constraint-row index
556        for cone in &self.con_cones {
557            match cone.kind {
558                ConeKind::Zero => {
559                    // Ax + b = 0 ⇒ Ax = −b.
560                    for i in 0..cone.dim {
561                        let row = r + i;
562                        for &(col, val) in &a_rows[row] {
563                            a_eq.push(Triplet::new(b_eq.len(), col, val));
564                        }
565                        b_eq.push(-self.b[row]);
566                    }
567                }
568                ConeKind::Nonneg => {
569                    // Ax + b ≥ 0 ⇒ slack = Ax + b ≥ 0.
570                    for i in 0..cone.dim {
571                        let row = r + i;
572                        push_row(&mut g, &mut h, &a_rows[row], self.b[row]);
573                    }
574                    cones.push(ConeSpec::Nonneg(cone.dim));
575                }
576                ConeKind::Nonpos => {
577                    // Ax + b ≤ 0 ⇒ slack = −(Ax + b) ≥ 0.
578                    for i in 0..cone.dim {
579                        let row = r + i;
580                        let neg: Vec<(usize, f64)> =
581                            a_rows[row].iter().map(|&(c, v)| (c, -v)).collect();
582                        push_row(&mut g, &mut h, &neg, -self.b[row]);
583                    }
584                    cones.push(ConeSpec::Nonneg(cone.dim));
585                }
586                ConeKind::SecondOrder => {
587                    for i in 0..cone.dim {
588                        let row = r + i;
589                        push_row(&mut g, &mut h, &a_rows[row], self.b[row]);
590                    }
591                    cones.push(ConeSpec::SecondOrder(cone.dim));
592                }
593                ConeKind::Exp => {
594                    // Slack must be ((Ax+b)₂, (Ax+b)₁, (Ax+b)₀) — reversed.
595                    for i in (0..3).rev() {
596                        let row = r + i;
597                        push_row(&mut g, &mut h, &a_rows[row], self.b[row]);
598                    }
599                    cones.push(ConeSpec::Exponential);
600                }
601                ConeKind::Pow => {
602                    // pounce (x,y,z) = ((Ax+b)₂, (Ax+b)₀, (Ax+b)₁), α = β₀.
603                    let alpha = cone.alpha.ok_or_else(|| {
604                        CbfError::Malformed("POW cone missing its exponent".into())
605                    })?;
606                    for &i in &[2usize, 0, 1] {
607                        let row = r + i;
608                        push_row(&mut g, &mut h, &a_rows[row], self.b[row]);
609                    }
610                    cones.push(ConeSpec::Power(alpha));
611                }
612                ConeKind::Free => {} // a free constraint row imposes nothing
613            }
614            r += cone.dim;
615        }
616
617        // --- Affine PSD constraints (PSDCON): D_c + Σ_k x_k H_{c,k} ⪰ 0. ---
618        // The slack svec entry (i,j) is `D[i][j] + Σ_k x_k H_k[i][j]`, scaled
619        // by √2 off the diagonal so smat(s) reconstructs the matrix. Appended
620        // after the VAR/CON cone rows as Psd blocks.
621        if !self.psdcon_dims.is_empty() {
622            use std::collections::HashMap;
623            let r2 = std::f64::consts::SQRT_2;
624            let mut h_by: HashMap<(usize, usize, usize), Vec<(usize, f64)>> = HashMap::new();
625            for &(con, var, i, j, val) in &self.hcoord {
626                h_by.entry((con, i, j)).or_default().push((var, val));
627            }
628            let mut d_by: HashMap<(usize, usize, usize), f64> = HashMap::new();
629            for &(con, i, j, val) in &self.dcoord {
630                *d_by.entry((con, i, j)).or_insert(0.0) += val;
631            }
632            for (con, &dim) in self.psdcon_dims.iter().enumerate() {
633                // svec order: column by column, lower triangle (j ≤ i).
634                for j in 0..dim {
635                    for i in j..dim {
636                        let scale = if i == j { 1.0 } else { r2 };
637                        let constant = scale * d_by.get(&(con, i, j)).copied().unwrap_or(0.0);
638                        let coeffs: Vec<(usize, f64)> = h_by
639                            .get(&(con, i, j))
640                            .map(|v| v.iter().map(|&(var, val)| (var, scale * val)).collect())
641                            .unwrap_or_default();
642                        push_row(&mut g, &mut h, &coeffs, constant);
643                    }
644                }
645                cones.push(ConeSpec::Psd(dim));
646            }
647        }
648
649        // Objective: minimize cᵀx (negate for MAX), constant carried out.
650        let c: Vec<f64> = if self.minimize {
651            self.c.clone()
652        } else {
653            self.c.iter().map(|v| -v).collect()
654        };
655
656        let prob = QpProblem {
657            n,
658            p_lower: Vec::new(),
659            c,
660            a: a_eq,
661            b: b_eq,
662            g,
663            h,
664            lb: Vec::new(),
665            ub: Vec::new(),
666        };
667        Ok(ConicProgram {
668            prob,
669            cones,
670            obj_constant: self.c0,
671        })
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    const TINY_GP: &str = "\
680VER
6812
682
683OBJSENSE
684MIN
685
686VAR
6874 2
688F 1
689EXP 3
690
691CON
6921 1
693L= 1
694
695OBJACOORD
6961
6970 1.0
698
699ACOORD
7002
7010 1 1.0
7020 3 -1.0
703
704BCOORD
7051
7060 -2.0
707";
708
709    #[test]
710    fn parses_sections() {
711        let m = parse(TINY_GP).unwrap();
712        assert!(m.minimize);
713        assert_eq!(m.num_var, 4);
714        assert_eq!(m.var_cones.len(), 2);
715        assert_eq!(m.var_cones[0].kind, ConeKind::Free);
716        assert_eq!(m.var_cones[1].kind, ConeKind::Exp);
717        assert_eq!(m.num_con, 1);
718        assert_eq!(m.con_cones[0].kind, ConeKind::Zero);
719        assert_eq!(m.c, vec![1.0, 0.0, 0.0, 0.0]);
720        assert_eq!(m.b, vec![-2.0]);
721        assert_eq!(m.a.len(), 2);
722    }
723
724    #[test]
725    fn rejects_bad_exp_dim() {
726        let bad = TINY_GP.replace("EXP 3", "EXP 2");
727        assert!(matches!(parse(&bad), Err(CbfError::BadExpDim(2))));
728    }
729
730    #[test]
731    fn rejects_unsupported_cone() {
732        let bad = TINY_GP.replace("EXP 3", "POW 3");
733        assert!(matches!(parse(&bad), Err(CbfError::UnsupportedCone(_))));
734    }
735
736    #[test]
737    fn cone_dim_sum_is_checked() {
738        let bad = TINY_GP.replace("4 2", "5 2");
739        assert!(matches!(parse(&bad), Err(CbfError::Malformed(_))));
740    }
741
742    #[test]
743    fn to_conic_builds_exp_and_equality() {
744        let m = parse(TINY_GP).unwrap();
745        let cp = m.to_conic().unwrap();
746        // One exp cone over vars {1,2,3}; the L= row is an equality.
747        assert_eq!(cp.cones, vec![ConeSpec::Exponential]);
748        assert_eq!(cp.prob.m_eq(), 1); // the L= constraint
749        assert_eq!(cp.prob.m_ineq(), 3); // the exp cone's 3 rows
750        assert_eq!(cp.obj_constant, 0.0);
751        // The exp rows reverse CBF (vars 1,2,3) to pounce order (3,2,1):
752        // G row 0 selects var 3, row 1 var 2, row 2 var 1 (each with −1·−? ).
753        // push_row uses G = −coeffs with coeff +1 ⇒ G entry −1.
754        let row0: Vec<_> = cp.prob.g.iter().filter(|t| t.row == 0).collect();
755        assert_eq!(row0.len(), 1);
756        assert_eq!(row0[0].col, 3);
757    }
758
759    const TINY_POW: &str = "\
760VER
7612
762
763OBJSENSE
764MAX
765
766POWCONES
7671 2
7682
7693.0
7701.0
771
772VAR
7733 1
774@0:POW 3
775
776CON
7770 0
778
779OBJACOORD
7801
7812 1.0
782";
783
784    #[test]
785    fn parses_powcones_and_resolves_alpha() {
786        let m = parse(TINY_POW).unwrap();
787        assert_eq!(m.var_cones.len(), 1);
788        assert_eq!(m.var_cones[0].kind, ConeKind::Pow);
789        // α = α₀/(α₀+α₁) = 3/(3+1) = 0.75.
790        let a = m.var_cones[0].alpha.unwrap();
791        assert!((a - 0.75).abs() < 1e-12, "alpha {a}");
792    }
793
794    #[test]
795    fn to_conic_builds_power_cone_with_permutation() {
796        let m = parse(TINY_POW).unwrap();
797        let cp = m.to_conic().unwrap();
798        assert_eq!(cp.cones, vec![ConeSpec::Power(0.75)]);
799        assert_eq!(cp.prob.m_ineq(), 3); // the power cone's 3 rows
800                                         // pounce (x,y,z) = (CBF x₂, x₀, x₁): row 0 selects var 2.
801        let row0: Vec<_> = cp.prob.g.iter().filter(|t| t.row == 0).collect();
802        assert_eq!(row0[0].col, 2);
803        let row1: Vec<_> = cp.prob.g.iter().filter(|t| t.row == 1).collect();
804        assert_eq!(row1[0].col, 0);
805        let row2: Vec<_> = cp.prob.g.iter().filter(|t| t.row == 2).collect();
806        assert_eq!(row2[0].col, 1);
807    }
808
809    #[test]
810    fn pow_reference_to_undeclared_set_errors() {
811        let bad = TINY_POW.replace("@0:POW", "@5:POW");
812        assert!(matches!(parse(&bad), Err(CbfError::Malformed(_))));
813    }
814
815    const TINY_SDP: &str = "\
816VER
8172
818
819OBJSENSE
820MAX
821
822VAR
8231 1
824F 1
825
826PSDCON
8271
8282
829
830OBJACOORD
8311
8320 1.0
833
834HCOORD
8352
8360 0 0 0 -1.0
8370 0 1 1 -1.0
838
839DCOORD
8402
8410 0 0 2.0
8420 1 1 5.0
843";
844
845    #[test]
846    fn parses_psdcon_hcoord_dcoord() {
847        let m = parse(TINY_SDP).unwrap();
848        assert_eq!(m.psdcon_dims, vec![2]);
849        assert_eq!(m.hcoord.len(), 2);
850        assert_eq!(m.dcoord.len(), 2);
851    }
852
853    #[test]
854    fn to_conic_builds_psd_constraint() {
855        let m = parse(TINY_SDP).unwrap();
856        let cp = m.to_conic().unwrap();
857        // One affine PSD constraint of size 2 → a Psd(2) cone over 3 rows.
858        assert_eq!(cp.cones, vec![ConeSpec::Psd(2)]);
859        assert_eq!(cp.prob.m_ineq(), 3);
860        // s = svec(M − λI) = [2 − λ, 0, 5 − λ]: h = [2, 0, 5] and the diagonal
861        // svec rows (0 and 2) carry +λ from G (push_row negates H = −1).
862        assert_eq!(cp.prob.h, vec![2.0, 0.0, 5.0]);
863        let row0: Vec<_> = cp.prob.g.iter().filter(|t| t.row == 0).collect();
864        assert_eq!(row0.len(), 1);
865        assert!((row0[0].val - 1.0).abs() < 1e-12); // −H = −(−1) = +1
866    }
867}