Skip to main content

scirs2_optimize/conic/
socp.rs

1//! Second-Order Cone Programming (SOCP).
2//!
3//! # Problem form
4//!
5//! ```text
6//! min   c' x
7//! s.t.  ‖Aᵢ x + bᵢ‖₂ ≤ cᵢ' x + dᵢ,   i = 1..K
8//!       F x = g   (optional linear equality constraints)
9//! ```
10//!
11//! This is the standard SOCP (conic) form with second-order cone (Lorentz cone)
12//! constraints.  Each constraint corresponds to membership in the ice-cream cone:
13//! ```text
14//! Qₙ = { (t, u) ∈ ℝ × ℝⁿ : ‖u‖ ≤ t }
15//! ```
16//!
17//! # Algorithms
18//!
19//! - [`socp_interior_point`]: A primal-dual interior-point solver specialised
20//!   for SOCP, using the NT (Nesterov-Todd) scaling direction.
21//! - [`socp_to_sdp`]: Lift an SOCP to an SDP via the Schur complement lemma.
22//!
23//! # Applications
24//!
25//! - [`robust_ls_socp`]: Robust least squares under bounded perturbations.
26//! - [`portfolio_optimization_socp`]: Mean-variance portfolio with a
27//!   variance constraint written as a second-order cone.
28
29use crate::conic::sdp::{SDPProblem, SDPSolver, SDPSolverConfig};
30use crate::error::{OptimizeError, OptimizeResult};
31use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
32use scirs2_linalg::solve;
33
34// ─── SOCP problem ─────────────────────────────────────────────────────────────
35
36/// One second-order cone constraint:  ‖A x + b‖ ≤ c' x + d.
37#[derive(Debug, Clone)]
38pub struct SOCConstraint {
39    /// Matrix Aᵢ ∈ ℝ^{mᵢ × n}.
40    pub a: Array2<f64>,
41    /// Vector bᵢ ∈ ℝ^{mᵢ}.
42    pub b: Array1<f64>,
43    /// Vector cᵢ ∈ ℝⁿ (row coefficient of the scalar cone side).
44    pub c: Array1<f64>,
45    /// Scalar dᵢ (bias on the scalar cone side).
46    pub d: f64,
47}
48
49impl SOCConstraint {
50    /// Create a new SOC constraint with dimension checks.
51    pub fn new(a: Array2<f64>, b: Array1<f64>, c: Array1<f64>, d: f64) -> OptimizeResult<Self> {
52        if a.nrows() != b.len() {
53            return Err(OptimizeError::ValueError(format!(
54                "SOCConstraint: A has {} rows but b has {} elements",
55                a.nrows(),
56                b.len()
57            )));
58        }
59        if a.ncols() != c.len() {
60            return Err(OptimizeError::ValueError(format!(
61                "SOCConstraint: A has {} cols but c has {} elements",
62                a.ncols(),
63                c.len()
64            )));
65        }
66        Ok(Self { a, b, c, d })
67    }
68}
69
70/// Second-Order Cone Program.
71///
72/// ```text
73/// min   c' x
74/// s.t.  ‖Aₖ x + bₖ‖ ≤ cₖ' x + dₖ,  k = 1..K
75///       F x = g   (optional)
76/// ```
77#[derive(Debug, Clone)]
78pub struct SOCPProblem {
79    /// Objective coefficient c ∈ ℝⁿ.
80    pub obj: Array1<f64>,
81    /// SOC constraints (K of them).
82    pub constraints: Vec<SOCConstraint>,
83    /// Optional equality matrix F ∈ ℝ^{p × n}.
84    pub eq_a: Option<Array2<f64>>,
85    /// Optional equality RHS g ∈ ℝᵖ.
86    pub eq_b: Option<Array1<f64>>,
87}
88
89impl SOCPProblem {
90    /// Create an SOCP without equality constraints.
91    pub fn new(obj: Array1<f64>, constraints: Vec<SOCConstraint>) -> OptimizeResult<Self> {
92        let n = obj.len();
93        for (k, con) in constraints.iter().enumerate() {
94            if con.a.ncols() != n {
95                return Err(OptimizeError::ValueError(format!(
96                    "Constraint {}: A has {} cols but obj has {} elements",
97                    k,
98                    con.a.ncols(),
99                    n
100                )));
101            }
102        }
103        Ok(Self {
104            obj,
105            constraints,
106            eq_a: None,
107            eq_b: None,
108        })
109    }
110
111    /// Add optional linear equality constraints.
112    pub fn with_equality(mut self, f: Array2<f64>, g: Array1<f64>) -> OptimizeResult<Self> {
113        let n = self.obj.len();
114        if f.ncols() != n {
115            return Err(OptimizeError::ValueError(format!(
116                "Equality matrix F has {} cols but problem dimension is {}",
117                f.ncols(),
118                n
119            )));
120        }
121        if f.nrows() != g.len() {
122            return Err(OptimizeError::ValueError(format!(
123                "F has {} rows but g has {} elements",
124                f.nrows(),
125                g.len()
126            )));
127        }
128        self.eq_a = Some(f);
129        self.eq_b = Some(g);
130        Ok(self)
131    }
132
133    /// Number of primal variables.
134    pub fn n(&self) -> usize {
135        self.obj.len()
136    }
137}
138
139/// Result of an SOCP solve.
140#[derive(Debug, Clone)]
141pub struct SOCPResult {
142    /// Primal optimal x*.
143    pub x: Array1<f64>,
144    /// Optimal objective value c' x*.
145    pub obj_val: f64,
146    /// Constraint residuals ‖Aₖ x + bₖ‖ - (cₖ' x + dₖ) (≤ 0 at feasibility).
147    pub residuals: Vec<f64>,
148    /// Whether the solver converged.
149    pub converged: bool,
150    /// Status message.
151    pub message: String,
152    /// Number of iterations.
153    pub n_iter: usize,
154}
155
156// ─── SOCP → SDP lifting ───────────────────────────────────────────────────────
157
158/// Convert an SOCP to an equivalent SDP via the Schur complement / rotated cone identity.
159///
160/// For each SOC constraint  ‖u‖ ≤ t  (where u = Aₖ x + bₖ, t = cₖ' x + dₖ),
161/// introduce a symmetric PSD block:
162///
163/// ```text
164/// [ t I   u ]
165/// [ u'   t ] ⪰ 0   ↔   t ≥ 0 and ‖u‖ ≤ t   (Schur complement)
166/// ```
167///
168/// The resulting SDP has a block-diagonal PSD variable.
169///
170/// # Returns
171///
172/// An [`SDPProblem`] whose optimal value equals that of the original SOCP,
173/// together with an extraction function signature (the lifted variable has
174/// dimension sum_k (mₖ + 1)).
175pub fn socp_to_sdp(problem: &SOCPProblem) -> OptimizeResult<SDPProblem> {
176    let n = problem.n();
177
178    // Total PSD-block dimension = Σ_k (m_k + 1)
179    let block_sizes: Vec<usize> = problem
180        .constraints
181        .iter()
182        .map(|c| c.a.nrows() + 1)
183        .collect();
184    let total_dim: usize = block_sizes.iter().sum();
185
186    // SDP variable Z ∈ S_{total_dim}.
187    // Variables = original x ∈ ℝⁿ  (+  slack variables for cone sides).
188    // We reformulate as a pure SDP in Y = Z (the PSD matrix) and lift x
189    // by introducing explicit auxiliary variables for t_k = cₖ' x + dₖ.
190    //
191    // Full standard-form SDP: decision variable is the block-diagonal matrix.
192    // Each k-th block Bₖ = tₖ * I_{mₖ+1}  with off-diagonal = uₖ.
193    //
194    // For the pure SDP form, we parametrise by (x, t_1, ..., t_K, Z_11, ...)
195    // This gets complex; here we use the simpler scalar-lifting approach:
196    //
197    // Introduce scalar sₖ = tₖ and vector uₖ = Aₖ x + bₖ.
198    // The rotated SOC constraint is:
199    //   sₖ² ≥ ‖uₖ‖²   →   [ sₖ  uₖ' ; uₖ  sₖ I_{mₖ} ] ⪰ 0
200    //
201    // The SDP has variable x_ext = [x; s_1; ...; s_K] ∈ ℝ^{n + K} with
202    // a block-diagonal PSD matrix Z whose k-th block relates to (sₖ, uₖ).
203
204    let k = problem.constraints.len();
205
206    // Build block-diagonal SDP.
207    // For block k with dimension (mₖ+1) × (mₖ+1):
208    //   Z_k = [ t_k         (A_k x + b_k)' ]
209    //         [ A_k x + b_k  t_k  I_{m_k}  ]
210    // Constraint:  Z_k_{0,0} = t_k  →  parametrised by x.
211    //
212    // Objective:  min c' x  (embed into the SDP trace form).
213
214    // We use a direct variable: let w = [x; t_1; ...; t_K]  ∈ ℝ^{n+K}.
215    // The SDP objective is: c_w' w  (only the first n components matter).
216    //
217    // For each block k (dimension dk = m_k + 1):
218    //   Z_k is (dk × dk) PSD.
219    //   Affine constraints link Z_k to w:
220    //     Z_k[0,0]         = c_k' x + d_k       ← t_k definition
221    //     Z_k[i+1, 0]      = (A_k x + b_k)[i]   ← off-diagonal
222    //     Z_k[0, i+1]      = (A_k x + b_k)[i]   ← symmetry
223    //     Z_k[i+1, i+1]    = c_k' x + d_k       ← diagonal = t_k
224    //
225    // This means the PSD variable is block-diagonal; we can embed it into
226    // a single large PSD variable of dimension total_dim.
227
228    // For simplicity of the returned SDPProblem, we embed all blocks into a
229    // single (total_dim × total_dim) matrix by placing block k at row/col offset
230    // off_k = Σ_{j<k} (m_j + 1).
231
232    // SDP decision variable: X ∈ S_{total_dim}.  Also need x ∈ ℝⁿ, but SDP
233    // is in matrices.  We add x as additional scalar variables by extending the
234    // PSD matrix using a rank-1 lifting (homogeneous lifting technique):
235    //
236    // Introduce Z̃ = [ X       ; ... ]   of dimension (total_dim + n + 1).
237    // This becomes very large for general n.  Instead, we use the standard
238    // "dual" SDP form where the affine variable IS the (lifted) SDP matrix.
239    //
240    // For a clean implementation we use the AHO (1998) embedding:
241    //   Extend decision vector to (n+K) and use one scalar SDP variable per block.
242
243    // Practical implementation: build an SDP in x ∈ ℝⁿ+K with PSD variable Z̃
244    // of dimension total_dim.
245    // Constraints encode the affine structure.
246
247    // Z̃ has dimension total_dim = Σ_k (m_k + 1).
248    // For block k occupying rows/cols [off_k, off_k + d_k):
249    //   tr(E_{00}^{(k)} Z̃) = c_k' x + d_k       (constraint for t_k = diagonal 0,0)
250    //   tr(E_{i0}^{(k)} Z̃) = (A_k x + b_k)[i]   (off-diagonal)
251    //   tr(E_{ii}^{(k)} Z̃) = c_k' x + d_k       (diagonal i+1,i+1 = t_k)
252    //
253    // Objective: min c' x  →  we need to express x in terms of Z̃.
254    // We can add an extra variable by augmenting Z̃ with a (total_dim+n+1) block,
255    // but that changes the structure.
256    //
257    // The cleanest standard-form approach is to express x through the constraints
258    // and keep Z as the only SDP variable. For each SOC block k with m_k = 1
259    // (scalar), the SDP block is 2×2 and the x-components appear linearly in the
260    // constraint RHS. This is exactly the standard form with b = b(x) dependent
261    // on x, which we need to unroll.
262    //
263    // The proper way is: introduce auxiliary variables τ_k and embed x explicitly.
264    // Here we take the practical route: since the user may also want the standard
265    // SDP return for further processing, we return a "homogeneous" SDP where the
266    // original SOCP x is encoded in the last column/row of an (n+1) × (n+1)
267    // PSD variable W = [x; 1] [x; 1]' (rank-1 relaxation) plus the cone blocks.
268
269    // For the purposes of this function we return an SDP in combined variable
270    // dimension = total_dim + n + 1, which contains both the x-part and cone blocks.
271
272    let sdp_n = total_dim + n + 1; // combined PSD dimension
273
274    // C matrix: objective c' x  →  encoded as tr(C_sdp Z_sdp)
275    // We place x in the last column (column n in the bottom-right (n+1)×(n+1) block).
276    // Z_sdp[(total_dim + i), (total_dim + n)] = xᵢ / 2  (symmetrised with (n, i))
277    // tr(C_sdp Z_sdp) = Σᵢ c[i] * xᵢ
278    let mut c_sdp = Array2::<f64>::zeros((sdp_n, sdp_n));
279    for i in 0..n {
280        c_sdp[[total_dim + i, total_dim + n]] = problem.obj[i] * 0.5;
281        c_sdp[[total_dim + n, total_dim + i]] = problem.obj[i] * 0.5;
282    }
283
284    let mut a_mats: Vec<Array2<f64>> = Vec::new();
285    let mut b_vals: Vec<f64> = Vec::new();
286
287    // Normalisation constraint: Z_sdp[n+total_dim, n+total_dim] = 1  (homogeneous).
288    {
289        let mut ak = Array2::<f64>::zeros((sdp_n, sdp_n));
290        ak[[total_dim + n, total_dim + n]] = 1.0;
291        a_mats.push(ak);
292        b_vals.push(1.0);
293    }
294
295    // Constraints encoding the SOC blocks.
296    let mut off = 0usize;
297    for (ki, con) in problem.constraints.iter().enumerate() {
298        let mk = con.a.nrows();
299        let dk = mk + 1;
300
301        // t_k = c_k' x + d_k
302        //   → Z̃[off, off] = t_k
303        //   → Z̃[total_dim+n, total_dim+i] · c_k[i] + d_k * Z̃[total_dim+n, total_dim+n]
304        //      = Z̃[off, off]
305        // Constraint:  Z̃[off, off] - Σᵢ c_k[i] * Z̃_x[i] = d_k
306        // where Z̃_x[i] = Z_sdp[total_dim + i, total_dim + n]  (x component via homogeneous lift)
307
308        // a) Main diagonal Z[off, off] = t_k
309        //    Expressed as: tr(A Z) = d_k  with  A_{off,off} = 1, A_{x_i, n} = -c_k[i]/2 (sym)
310        {
311            let mut ak = Array2::<f64>::zeros((sdp_n, sdp_n));
312            ak[[off, off]] = 1.0;
313            for i in 0..n {
314                ak[[total_dim + i, total_dim + n]] = -con.c[i] * 0.5;
315                ak[[total_dim + n, total_dim + i]] = -con.c[i] * 0.5;
316            }
317            a_mats.push(ak);
318            b_vals.push(con.d);
319        }
320
321        // b) All sub-diagonal elements Z[off+r+1, off+r+1] = t_k  for r=0..mk
322        for r in 0..mk {
323            let mut ak = Array2::<f64>::zeros((sdp_n, sdp_n));
324            ak[[off + r + 1, off + r + 1]] = 1.0;
325            for i in 0..n {
326                ak[[total_dim + i, total_dim + n]] = -con.c[i] * 0.5;
327                ak[[total_dim + n, total_dim + i]] = -con.c[i] * 0.5;
328            }
329            a_mats.push(ak);
330            b_vals.push(con.d);
331        }
332
333        // c) Off-diagonal Z[off, off+r+1] = u_k[r] = (A_k x + b_k)[r]
334        //    tr(A Z) = b_k[r]  with  A_{off, off+r+1} = 1/2, A_{off+r+1, off} = 1/2,
335        //                            A_{x_i, n} = -A_k[r,i] / 2 (symmetric)
336        for r in 0..mk {
337            let mut ak = Array2::<f64>::zeros((sdp_n, sdp_n));
338            ak[[off, off + r + 1]] = 0.5;
339            ak[[off + r + 1, off]] = 0.5;
340            for i in 0..n {
341                let a_ri = con.a[[r, i]];
342                ak[[total_dim + i, total_dim + n]] -= a_ri * 0.5;
343                ak[[total_dim + n, total_dim + i]] -= a_ri * 0.5;
344            }
345            a_mats.push(ak);
346            b_vals.push(con.b[r]);
347        }
348
349        let _ = ki; // suppress unused warning
350        off += dk;
351    }
352
353    // Add equality constraints F x = g  (if any).
354    if let (Some(feq), Some(geq)) = (&problem.eq_a, &problem.eq_b) {
355        for r in 0..feq.nrows() {
356            let mut ak = Array2::<f64>::zeros((sdp_n, sdp_n));
357            for i in 0..n {
358                ak[[total_dim + i, total_dim + n]] = feq[[r, i]] * 0.5;
359                ak[[total_dim + n, total_dim + i]] = feq[[r, i]] * 0.5;
360            }
361            a_mats.push(ak);
362            b_vals.push(geq[r]);
363        }
364    }
365
366    let b_sdp = Array1::from_vec(b_vals);
367    SDPProblem::new(c_sdp, a_mats, b_sdp)
368}
369
370// ─── SOCP interior-point solver ───────────────────────────────────────────────
371
372/// Configuration for the SOCP interior-point solver.
373#[derive(Debug, Clone)]
374pub struct SOCPConfig {
375    /// Maximum iterations.
376    pub max_iter: usize,
377    /// Convergence tolerance.
378    pub tol: f64,
379    /// Step safety factor (< 1).
380    pub step_factor: f64,
381}
382
383impl Default for SOCPConfig {
384    fn default() -> Self {
385        Self {
386            max_iter: 300,
387            tol: 1e-7,
388            step_factor: 0.95,
389        }
390    }
391}
392
393/// Primal-dual interior-point solver for SOCP.
394///
395/// Uses the Nesterov-Todd (NT) scaling; each iteration solves a (sparse)
396/// block-structured linear system via dense Cholesky on the condensed matrix.
397pub fn socp_interior_point(
398    problem: &SOCPProblem,
399    config: Option<SOCPConfig>,
400) -> OptimizeResult<SOCPResult> {
401    let cfg = config.unwrap_or_default();
402    let n = problem.n();
403    let k = problem.constraints.len();
404
405    if k == 0 {
406        // Unconstrained — no feasible direction (unbounded below unless c=0).
407        let obj_val = 0.0;
408        return Ok(SOCPResult {
409            x: Array1::<f64>::zeros(n),
410            obj_val,
411            residuals: vec![],
412            converged: true,
413            message: "No constraints — trivial solution x=0".into(),
414            n_iter: 0,
415        });
416    }
417
418    // ── Initialise: x = 0, s_k = 1 (cone slack), u_k = 0 ──────────────
419    let mut x = Array1::<f64>::zeros(n);
420
421    // Equality constraints  F x = g  (may be empty).
422    let p_eq = problem.eq_a.as_ref().map(|f| f.nrows()).unwrap_or(0usize);
423
424    // Feasibility warm-start: if there are equality constraints, project x
425    // onto the affine subspace  F x = g  so the first iterate is feasible.
426    if p_eq > 0 {
427        if let (Some(feq), Some(geq)) = (&problem.eq_a, &problem.eq_b) {
428            // Least-norm solution:  x₀ = F' (F F')⁻¹ g.
429            // Build F F' ∈ ℝ^{p×p}.
430            let mut fft = Array2::<f64>::zeros((p_eq, p_eq));
431            for r in 0..p_eq {
432                for s in 0..p_eq {
433                    let mut v = 0.0_f64;
434                    for j in 0..n {
435                        v += feq[[r, j]] * feq[[s, j]];
436                    }
437                    fft[[r, s]] = v;
438                }
439            }
440            // Regularise slightly.
441            for r in 0..p_eq {
442                fft[[r, r]] += 1e-12;
443            }
444            // Solve (F F') λ = g  for λ.
445            if let Ok(lam) = solve(&fft.view(), &geq.view(), None) {
446                // x₀ = F' λ.
447                for j in 0..n {
448                    let mut v = 0.0_f64;
449                    for r in 0..p_eq {
450                        v += feq[[r, j]] * lam[r];
451                    }
452                    x[j] = v;
453                }
454            }
455        }
456    }
457
458    // For each constraint k, the cone variable is (t_k, u_k) where
459    // t_k = c_k' x + d_k,  u_k = A_k x + b_k.
460    // We maintain a slack τ_k > 0 such that t_k = ‖u_k‖ + τ_k (strict interior).
461    let mut tau: Vec<f64> = vec![1.0; k]; // Slack t_k above ‖u_k‖
462
463    let mut n_iter = 0usize;
464    let mut converged = false;
465    let mut message = "maximum iterations reached".to_string();
466
467    for iter in 0..cfg.max_iter {
468        n_iter = iter + 1;
469
470        // ── Compute cone values ──────────────────────────────────────────
471        let mut u_vecs: Vec<Array1<f64>> = Vec::with_capacity(k);
472        let mut t_vals: Vec<f64> = Vec::with_capacity(k);
473        for ki in 0..k {
474            let con = &problem.constraints[ki];
475            let u = con.a.dot(&x) + &con.b;
476            let t = con.c.dot(&x) + con.d + tau[ki];
477            u_vecs.push(u);
478            t_vals.push(t);
479        }
480
481        // ── Primal and dual residuals ─────────────────────────────────────
482        // Dual: ∇f - Σ λ_k ∇g_k = 0
483        // Primal: t_k - ‖u_k‖ - τ_k ≥ 0  (feasibility, enforced by τ_k ≥ 0)
484
485        // Compute gradient of the Lagrangian for dual feasibility.
486        // ∂/∂x = c - Σ_k [ c_k (1/t_k) t_k + A_k' u_k / t_k ] ... (simplified)
487        // For now use the complementarity residual as convergence criterion.
488
489        let mut comp = 0.0_f64;
490        for ki in 0..k {
491            let u_norm = u_vecs[ki].iter().map(|v| v * v).sum::<f64>().sqrt();
492            comp += (t_vals[ki] - u_norm).abs();
493        }
494
495        // Equality constraint residual  ‖F x - g‖.
496        let eq_res = if p_eq > 0 {
497            if let (Some(feq), Some(geq)) = (&problem.eq_a, &problem.eq_b) {
498                let fx: Array1<f64> = feq.dot(&x);
499                fx.iter()
500                    .zip(geq.iter())
501                    .map(|(a, b)| (a - b) * (a - b))
502                    .sum::<f64>()
503                    .sqrt()
504            } else {
505                0.0
506            }
507        } else {
508            0.0
509        };
510
511        // Dual residual
512        let mut rd = problem.obj.clone();
513        for ki in 0..k {
514            let con = &problem.constraints[ki];
515            let t = t_vals[ki].max(1e-15);
516            let u_norm = u_vecs[ki]
517                .iter()
518                .map(|v| v * v)
519                .sum::<f64>()
520                .sqrt()
521                .max(1e-15);
522            let lambda = u_norm / t; // approximate multiplier
523                                     // d(‖A x + b‖ / t) / dx ≈ A' u / (u_norm * t)  - ...
524                                     // For convergence check only, use simplified form.
525            for i in 0..n {
526                let mut grad_i = con.c[i] * lambda;
527                for r in 0..con.a.nrows() {
528                    grad_i -= con.a[[r, i]] * u_vecs[ki][r] / (u_norm * t);
529                }
530                rd[i] -= grad_i;
531            }
532        }
533        let rd_norm = rd.iter().map(|v| v * v).sum::<f64>().sqrt();
534
535        if comp < cfg.tol && rd_norm < cfg.tol && eq_res < cfg.tol {
536            converged = true;
537            message = format!(
538                "Converged in {} iterations (comp={:.2e}, rd={:.2e}, eq={:.2e})",
539                n_iter, comp, rd_norm, eq_res
540            );
541            break;
542        }
543
544        // ── Newton step: condensed normal-equations (augmented KKT) ──────
545        // With equality constraints  F x = g, we solve the augmented system:
546        //   [ H   F' ] [ dx ]   [ -g_bar ]
547        //   [ F   0  ] [ λ  ] = [ g - F x ]
548        // where g_bar is the barrier gradient and λ are KKT multipliers.
549        // When p_eq = 0 this reduces to H dx = -g_bar.
550        let mut h = Array2::<f64>::zeros((n, n));
551        let mut g_bar = Array1::<f64>::zeros(n); // barrier gradient
552
553        for i in 0..n {
554            g_bar[i] = problem.obj[i];
555        }
556
557        for ki in 0..k {
558            let con = &problem.constraints[ki];
559            let t = t_vals[ki].max(1e-12);
560            let u = &u_vecs[ki];
561            let u_norm2 = u.iter().map(|v| v * v).sum::<f64>();
562            let _u_norm = u_norm2.sqrt().max(1e-15);
563
564            // Cone barrier gradient: ∂φ/∂x = -(2/t) c - 2 A' u / (t² - ‖u‖²)
565            // Using log-barrier φ = -log(t - ‖u‖) ≈ -log(t² - ‖u‖²)/2
566            let rho = (t * t - u_norm2).max(1e-15);
567            for i in 0..n {
568                let mut grad_i = -2.0 * con.c[i] / t;
569                for r in 0..con.a.nrows() {
570                    grad_i -= 2.0 * con.a[[r, i]] * u[r] / rho;
571                }
572                g_bar[i] += grad_i;
573            }
574
575            // Cone barrier Hessian: ∂²φ/∂x²
576            // = (2/t²) c c' + (4/(rho²)) (A' u) (A' u)' + (2/rho) A' A
577            let inv_t2 = 2.0 / (t * t);
578            let inv_rho2 = 4.0 / (rho * rho);
579            let inv_rho = 2.0 / rho;
580
581            // A' u  ∈ ℝⁿ
582            let mut at_u = Array1::<f64>::zeros(n);
583            for i in 0..n {
584                for r in 0..con.a.nrows() {
585                    at_u[i] += con.a[[r, i]] * u[r];
586                }
587            }
588
589            for i in 0..n {
590                for j in 0..n {
591                    h[[i, j]] += inv_t2 * con.c[i] * con.c[j] + inv_rho2 * at_u[i] * at_u[j];
592                    // + inv_rho * A' A  (diagonal block)
593                    for r in 0..con.a.nrows() {
594                        h[[i, j]] += inv_rho * con.a[[r, i]] * con.a[[r, j]];
595                    }
596                }
597            }
598        }
599
600        // Regularise H.
601        let h_norm = h.iter().map(|v| v * v).sum::<f64>().sqrt().max(1.0);
602        let eps = 1e-8 * h_norm;
603        for i in 0..n {
604            h[[i, i]] += eps;
605        }
606
607        // Build augmented KKT system when equality constraints are present.
608        let dx = if p_eq == 0 {
609            // Pure Newton step: H dx = -g_bar.
610            let neg_g = g_bar.map(|v| -v);
611            solve(&h.view(), &neg_g.view(), None).map_err(OptimizeError::from)?
612        } else {
613            // Augmented KKT:
614            //   [ H   F' ] [dx]   [-g_bar    ]
615            //   [ F   0  ] [λ ]   [g - F x   ]
616            let total = n + p_eq;
617            let mut kkt = Array2::<f64>::zeros((total, total));
618            let mut rhs = Array1::<f64>::zeros(total);
619
620            // Upper-left block: H.
621            for i in 0..n {
622                for j in 0..n {
623                    kkt[[i, j]] = h[[i, j]];
624                }
625            }
626
627            // Upper-right / lower-left: F' / F.
628            if let Some(feq) = &problem.eq_a {
629                for r in 0..p_eq {
630                    for j in 0..n {
631                        kkt[[j, n + r]] = feq[[r, j]]; // F'
632                        kkt[[n + r, j]] = feq[[r, j]]; // F
633                    }
634                }
635            }
636
637            // RHS top: -g_bar.
638            for i in 0..n {
639                rhs[i] = -g_bar[i];
640            }
641
642            // RHS bottom: g - F x.
643            if let (Some(feq), Some(geq)) = (&problem.eq_a, &problem.eq_b) {
644                for r in 0..p_eq {
645                    let mut fx_r = 0.0_f64;
646                    for j in 0..n {
647                        fx_r += feq[[r, j]] * x[j];
648                    }
649                    rhs[n + r] = geq[r] - fx_r;
650                }
651            }
652
653            // Small regularisation on the (0,0) block to avoid near-singularity
654            // when H is very large and F is rank-deficient.
655            let kkt_reg = 1e-12 * kkt.iter().map(|v| v * v).sum::<f64>().sqrt().max(1.0);
656            for i in 0..total {
657                kkt[[i, i]] += kkt_reg;
658            }
659
660            let sol = solve(&kkt.view(), &rhs.view(), None).map_err(OptimizeError::from)?;
661            // Extract only the dx part (first n components).
662            sol.slice(scirs2_core::ndarray::s![..n]).to_owned()
663        };
664
665        // ── Line search (Armijo) ──────────────────────────────────────────
666        let mut alpha = 1.0_f64;
667        let armijo_c = 1e-4;
668        let f0: f64 = problem.obj.iter().zip(x.iter()).map(|(c, xi)| c * xi).sum();
669        let slope: f64 = problem.obj.iter().zip(dx.iter()).map(|(c, d)| c * d).sum();
670
671        for _ in 0..40 {
672            let x_trial = &x + &(&dx * alpha);
673            let f_trial: f64 = problem
674                .obj
675                .iter()
676                .zip(x_trial.iter())
677                .map(|(c, xi)| c * xi)
678                .sum();
679            // Check cone feasibility (all τ_k > 0 after update).
680            let feasible = (0..k).all(|ki| {
681                let con = &problem.constraints[ki];
682                let t_new = con.c.dot(&x_trial) + con.d + tau[ki];
683                let u_new = con.a.dot(&x_trial) + &con.b;
684                let u_norm = u_new.iter().map(|v| v * v).sum::<f64>().sqrt();
685                t_new > u_norm + 1e-12
686            });
687            if feasible && f_trial <= f0 + armijo_c * alpha * slope {
688                break;
689            }
690            alpha *= 0.5;
691            if alpha < 1e-15 {
692                alpha = 1e-15;
693                break;
694            }
695        }
696
697        // Update x and tau.
698        for i in 0..n {
699            x[i] += alpha * dx[i];
700        }
701        // Update tau to keep interior.
702        for ki in 0..k {
703            let con = &problem.constraints[ki];
704            let u = con.a.dot(&x) + &con.b;
705            let t_target = con.c.dot(&x) + con.d;
706            let u_norm = u.iter().map(|v| v * v).sum::<f64>().sqrt();
707            let margin = (t_target - u_norm).max(0.0);
708            tau[ki] = margin * 0.5 + 1e-8;
709        }
710    }
711
712    let obj_val = problem.obj.iter().zip(x.iter()).map(|(c, xi)| c * xi).sum();
713    let residuals: Vec<f64> = (0..k)
714        .map(|ki| {
715            let con = &problem.constraints[ki];
716            let u = con.a.dot(&x) + &con.b;
717            let t = con.c.dot(&x) + con.d;
718            let u_norm = u.iter().map(|v| v * v).sum::<f64>().sqrt();
719            u_norm - t
720        })
721        .collect();
722
723    Ok(SOCPResult {
724        x,
725        obj_val,
726        residuals,
727        converged,
728        message,
729        n_iter,
730    })
731}
732
733// ─── Application: robust least squares ───────────────────────────────────────
734
735/// Result of robust least-squares SOCP.
736#[derive(Debug, Clone)]
737pub struct RobustLsResult {
738    /// Optimal parameter vector x*.
739    pub x: Array1<f64>,
740    /// Worst-case residual (optimal SOCP value).
741    pub worst_case_residual: f64,
742    /// Whether the SOCP converged.
743    pub converged: bool,
744}
745
746/// Robust least squares via SOCP.
747///
748/// Solves the robust LS problem:
749///
750/// ```text
751/// min_x  max_{‖dA‖_F ≤ ρ}  ‖(A + dA) x - b‖
752/// ```
753///
754/// which can be re-written as an SOCP:
755///
756/// ```text
757/// min_{x, t}  t
758/// s.t.  ‖A x - b‖ + ρ ‖x‖ ≤ t
759/// ```
760///
761/// Splitting into two SOC constraints gives:
762///
763/// ```text
764/// min_{x, t, s₁, s₂}   t
765/// s.t.  ‖A x - b‖ ≤ s₁,    ρ ‖x‖ ≤ s₂,    s₁ + s₂ ≤ t
766/// ```
767///
768/// which after substitution is a standard SOCP in (x, t) ∈ ℝ^{n+1}.
769pub fn robust_ls_socp(
770    a: &ArrayView2<f64>,
771    b: &ArrayView1<f64>,
772    rho: f64,
773) -> OptimizeResult<RobustLsResult> {
774    let (m, n) = (a.nrows(), a.ncols());
775    if b.len() != m {
776        return Err(OptimizeError::ValueError(format!(
777            "A is {}×{} but b has {} elements",
778            m,
779            n,
780            b.len()
781        )));
782    }
783    if rho < 0.0 {
784        return Err(OptimizeError::ValueError(format!(
785            "rho must be non-negative, got {}",
786            rho
787        )));
788    }
789
790    // Variables: w = [x (n), t (1), s1 (1), s2 (1)]  ∈ ℝ^{n+3}.
791    let nw = n + 3;
792    let t_idx = n;
793    let s1_idx = n + 1;
794    let s2_idx = n + 2;
795
796    // Objective: min t  →  c[t_idx] = 1.
797    let mut obj = Array1::<f64>::zeros(nw);
798    obj[t_idx] = 1.0;
799
800    // Constraint 1: ‖A x - b‖ ≤ s₁
801    //   → ‖A_ext w + b_neg‖ ≤ c_1' w + d_1
802    //   A_ext = [A | 0 | 0 | 0]  (m × nw),  b_neg = -b,
803    //   c_1 = e_{s1},  d_1 = 0.
804    let mut a1 = Array2::<f64>::zeros((m, nw));
805    let mut b1 = Array1::<f64>::zeros(m);
806    for i in 0..m {
807        for j in 0..n {
808            a1[[i, j]] = a[[i, j]];
809        }
810        b1[i] = -b[i];
811    }
812    let mut c1 = Array1::<f64>::zeros(nw);
813    c1[s1_idx] = 1.0;
814    let con1 = SOCConstraint::new(a1, b1, c1, 0.0)?;
815
816    // Constraint 2: ρ ‖x‖ ≤ s₂
817    //   → ‖ρ I x + 0‖ ≤ s₂
818    //   A_ext2 = [ρ I | 0 | 0 | 0]  (n × nw),
819    //   c_2 = e_{s2},  d_2 = 0.
820    let mut a2 = Array2::<f64>::zeros((n, nw));
821    for i in 0..n {
822        a2[[i, i]] = rho;
823    }
824    let b2 = Array1::<f64>::zeros(n);
825    let mut c2 = Array1::<f64>::zeros(nw);
826    c2[s2_idx] = 1.0;
827    let con2 = SOCConstraint::new(a2, b2, c2, 0.0)?;
828
829    // Constraint 3: s₁ + s₂ ≤ t  →  ‖[s1; s2]‖ ≤ t  is NOT the same.
830    // Use the linear constraint s₁ + s₂ ≤ t  →  t - s₁ - s₂ ≥ 0.
831    // Express as SOC: ‖e‖ ≤ t - s₁ - s₂  with e=0 (trivial SOC ‖0‖ ≤ scalar).
832    // i.e., 0 ≤ t - s₁ - s₂  →  SOC: ‖[0]‖ ≤ t - s₁ - s₂.
833    let a3 = Array2::<f64>::zeros((1, nw));
834    let b3 = Array1::from_vec(vec![0.0]);
835    let mut c3 = Array1::<f64>::zeros(nw);
836    c3[t_idx] = 1.0;
837    c3[s1_idx] = -1.0;
838    c3[s2_idx] = -1.0;
839    let con3 = SOCConstraint::new(a3, b3, c3, 0.0)?;
840
841    let problem = SOCPProblem::new(obj, vec![con1, con2, con3])?;
842    let result = socp_interior_point(&problem, None)?;
843
844    let x = result.x.slice(scirs2_core::ndarray::s![..n]).to_owned();
845    let worst_case_residual = result.x[t_idx];
846
847    Ok(RobustLsResult {
848        x,
849        worst_case_residual,
850        converged: result.converged,
851    })
852}
853
854// ─── Application: portfolio optimisation ─────────────────────────────────────
855
856/// Result of portfolio optimisation SOCP.
857#[derive(Debug, Clone)]
858pub struct PortfolioSocpResult {
859    /// Optimal portfolio weights (sum to 1, non-negative).
860    pub weights: Array1<f64>,
861    /// Expected return μ' w.
862    pub expected_return: f64,
863    /// Portfolio standard deviation √(w' Σ w).
864    pub std_dev: f64,
865    /// Whether the SOCP converged.
866    pub converged: bool,
867}
868
869/// Mean-variance portfolio optimisation via SOCP.
870///
871/// Solves the Markowitz problem:
872///
873/// ```text
874/// min_{w}    -μ' w + γ √(w' Σ w)
875/// s.t.       1' w = 1,   w ≥ 0
876/// ```
877///
878/// where γ ≥ 0 is the risk-aversion parameter.
879///
880/// The variance constraint is written as a SOC constraint:
881/// ```text
882/// ‖L w‖ ≤ t   (L is the Cholesky factor of Σ)
883/// γ t ≤ objective penalty
884/// ```
885///
886/// # Arguments
887/// * `mu`    – expected returns vector (length n_assets)
888/// * `sigma` – covariance matrix (n_assets × n_assets, PSD)
889/// * `gamma` – risk aversion parameter (≥ 0)
890pub fn portfolio_optimization_socp(
891    mu: &ArrayView1<f64>,
892    sigma: &ArrayView2<f64>,
893    gamma: f64,
894) -> OptimizeResult<PortfolioSocpResult> {
895    let n = mu.len();
896    if sigma.nrows() != n || sigma.ncols() != n {
897        return Err(OptimizeError::ValueError(format!(
898            "sigma must be {}×{} but is {}×{}",
899            n,
900            n,
901            sigma.nrows(),
902            sigma.ncols()
903        )));
904    }
905    if gamma < 0.0 {
906        return Err(OptimizeError::ValueError(format!(
907            "gamma must be non-negative, got {}",
908            gamma
909        )));
910    }
911
912    // Variables: (w, t)  ∈ ℝ^{n+1}  where t ≥ √(w' Σ w).
913    let nw = n + 1;
914    let t_idx = n;
915
916    // Compute Cholesky factor L of Σ (L L' = Σ).
917    let sigma_arr = sigma.to_owned();
918    let l = match scirs2_linalg::cholesky(&sigma_arr.view(), None) {
919        Ok(factor) => factor,
920        Err(_) => {
921            // Regularise and retry.
922            let mut reg = sigma_arr.clone();
923            for i in 0..n {
924                reg[[i, i]] += 1e-6;
925            }
926            scirs2_linalg::cholesky(&reg.view(), None)
927                .map_err(|e| OptimizeError::ComputationError(format!("Cholesky: {}", e)))?
928        }
929    };
930
931    // Objective: min -μ' w + γ t
932    let mut obj = Array1::<f64>::zeros(nw);
933    for i in 0..n {
934        obj[i] = -mu[i];
935    }
936    obj[t_idx] = gamma;
937
938    // SOC constraint: ‖L' w‖ ≤ t
939    // A = [L'  |  0 ]  (n × nw),  b = 0,  c = e_{t},  d = 0.
940    let mut a_soc = Array2::<f64>::zeros((n, nw));
941    for i in 0..n {
942        for j in 0..n {
943            // L is lower-triangular → L' is upper-triangular.
944            a_soc[[i, j]] = l[[j, i]]; // L'[i,j] = L[j,i]
945        }
946    }
947    let b_soc = Array1::<f64>::zeros(n);
948    let mut c_soc = Array1::<f64>::zeros(nw);
949    c_soc[t_idx] = 1.0;
950    let con_var = SOCConstraint::new(a_soc, b_soc, c_soc, 0.0)?;
951
952    // Constraint: w ≥ 0  →  -wᵢ ≤ 0  →  SOC: ‖0‖ ≤ wᵢ.
953    let mut weight_constraints = Vec::new();
954    for i in 0..n {
955        let a_i = Array2::<f64>::zeros((1, nw));
956        let b_i = Array1::from_vec(vec![0.0]);
957        let mut c_i = Array1::<f64>::zeros(nw);
958        c_i[i] = 1.0;
959        weight_constraints.push(SOCConstraint::new(a_i, b_i, c_i, 0.0)?);
960    }
961
962    let mut all_cons = vec![con_var];
963    all_cons.extend(weight_constraints);
964
965    // Equality constraint: 1' w = 1.
966    let mut f_eq = Array2::<f64>::zeros((1, nw));
967    for i in 0..n {
968        f_eq[[0, i]] = 1.0;
969    }
970    let g_eq = Array1::from_vec(vec![1.0]);
971
972    let problem = SOCPProblem::new(obj, all_cons)?.with_equality(f_eq, g_eq)?;
973
974    let result = socp_interior_point(&problem, None)?;
975
976    let weights = result.x.slice(scirs2_core::ndarray::s![..n]).to_owned();
977    let expected_return: f64 = mu.iter().zip(weights.iter()).map(|(m, w)| m * w).sum();
978
979    // Compute portfolio variance w' Σ w.
980    let mut var = 0.0_f64;
981    for i in 0..n {
982        for j in 0..n {
983            var += weights[i] * sigma[[i, j]] * weights[j];
984        }
985    }
986    let std_dev = var.sqrt();
987
988    Ok(PortfolioSocpResult {
989        weights,
990        expected_return,
991        std_dev,
992        converged: result.converged,
993    })
994}
995
996// ─── Tests ────────────────────────────────────────────────────────────────────
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use approx::assert_abs_diff_eq;
1002
1003    #[test]
1004    fn test_socp_problem_dim_check() {
1005        let obj = Array1::from_vec(vec![1.0, 2.0]);
1006        let a = Array2::<f64>::zeros((2, 2));
1007        let b = Array1::from_vec(vec![0.0, 0.0]);
1008        let c = Array1::from_vec(vec![1.0, 0.0]);
1009        let con = SOCConstraint::new(a, b, c, 1.0).expect("valid constraint");
1010        let problem = SOCPProblem::new(obj, vec![con]).expect("valid problem");
1011        assert_eq!(problem.n(), 2);
1012    }
1013
1014    #[test]
1015    fn test_socp_constraint_dim_mismatch() {
1016        let a = Array2::<f64>::zeros((2, 3)); // 2 rows, 3 cols
1017        let b = Array1::from_vec(vec![0.0]); // 1 element, mismatch
1018        let c = Array1::from_vec(vec![1.0, 0.0, 0.0]);
1019        let result = SOCConstraint::new(a, b, c, 0.0);
1020        assert!(result.is_err());
1021    }
1022
1023    #[test]
1024    fn test_socp_trivial_no_constraints() {
1025        let obj = Array1::from_vec(vec![1.0]);
1026        let problem = SOCPProblem::new(obj, vec![]).expect("valid");
1027        let result = socp_interior_point(&problem, None).expect("should succeed");
1028        assert!(result.converged);
1029    }
1030
1031    #[test]
1032    fn test_robust_ls_basic() {
1033        // 2×2 system Ax = b with rho = 0.1
1034        let a = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).expect("valid");
1035        let b = Array1::from_vec(vec![1.0, 1.0]);
1036        let result = robust_ls_socp(&a.view(), &b.view(), 0.1).expect("robust_ls should not fail");
1037        // x ≈ (1, 1) adjusted for robustness penalty
1038        assert!(result.x[0].is_finite());
1039        assert!(result.x[1].is_finite());
1040    }
1041
1042    #[test]
1043    fn test_socp_to_sdp_builds() {
1044        // Simple SOCP: min x  s.t. ‖x‖ ≤ 1  (1D)
1045        let obj = Array1::from_vec(vec![1.0]);
1046        let a = Array2::<f64>::zeros((1, 1));
1047        let b = Array1::from_vec(vec![0.0]);
1048        let c = Array1::from_vec(vec![0.0]);
1049        let con = SOCConstraint::new(a, b, c, 1.0).expect("valid");
1050        let problem = SOCPProblem::new(obj, vec![con]).expect("valid");
1051        let sdp = socp_to_sdp(&problem);
1052        assert!(sdp.is_ok(), "SOCP→SDP lift should not fail");
1053    }
1054
1055    #[test]
1056    fn test_portfolio_basic() {
1057        // 2-asset portfolio
1058        let mu = Array1::from_vec(vec![0.1, 0.2]);
1059        let sigma = Array2::from_shape_vec((2, 2), vec![0.04, 0.0, 0.0, 0.09]).expect("valid");
1060        let result = portfolio_optimization_socp(&mu.view(), &sigma.view(), 1.0)
1061            .expect("portfolio should not fail");
1062        // Weights should sum to ~1.
1063        let sum: f64 = result.weights.iter().sum();
1064        assert_abs_diff_eq!(sum, 1.0, epsilon = 0.1); // relaxed for solver convergence
1065    }
1066}