Skip to main content

ledge_core/
workspace.rs

1//! Reusable solver workspace: factorization cache across solves.
2//!
3//! Every one-shot [`Solver::solve`](crate::Solver::solve) pays two structural
4//! costs before iterating: Ruiz equilibration of the problem data and the
5//! SMW-reduced factorization (`O(n r^2 + r^3)` with reduced dimension
6//! `r = factors + explicit linear constraints`). Rolling rebalances keep the
7//! covariance and constraint structure fixed and only move the linear cost
8//! (new expected returns / previous weights) or right-hand sides, so both
9//! costs can be paid once and reused.
10//!
11//! [`Workspace`] owns that reusable state. It is created from a solver and a
12//! problem via [`crate::Solver::workspace`], re-solves with
13//! [`Workspace::solve`], and accepts cheap in-place data updates
14//! ([`Workspace::update_linear`], [`Workspace::update_equality_rhs`],
15//! [`Workspace::update_inequality_rhs`]) that never invalidate the cached
16//! factorizations.
17//!
18//! Every solve replays the one-shot penalty policy exactly — it starts at
19//! [`SolverSettings::rho`] and adapts as usual — so a workspace changes cost,
20//! never iterates: solving through a workspace and through a fresh
21//! [`Solver::solve`](crate::Solver::solve) of the same data walk the same
22//! path. (Measured on rolling workloads, carrying the previous solve's final
23//! penalty over instead *increased* iteration counts; see
24//! `docs/DECISIONS.md`.) Factorizations are cached in a small
25//! least-recently-used table keyed by the penalty, so the ladder of penalties
26//! an adaptive solve revisits is factored once per workspace, not once per
27//! solve: warm-started rolling sequences reach a steady state of zero
28//! refactorizations per step.
29//!
30//! Auditability rules are unchanged from the one-shot path: the equilibration
31//! computed at construction is an exact transform applied to every update, and
32//! termination checks plus every reported residual are always evaluated on the
33//! original (updated) data.
34
35use std::time::Instant;
36
37use crate::{
38    certificate::{
39        check_dual_certificate, check_primal_certificate, detect_dual_infeasibility,
40        detect_primal_infeasibility, Certificate,
41    },
42    check_kkt,
43    kkt::{DualVariables, KktResiduals},
44    linalg::{covariance_columns, SmwSystem},
45    matrix::{norm_inf, Matrix},
46    polish::polish,
47    problem::{ProblemError, QpProblem},
48    scaling::ScaledProblem,
49    solver::{
50        validate_settings, ConvergenceDiagnostics, Solution, SolveStatus, SolverError,
51        SolverSettings, WarmStart,
52    },
53};
54
55/// Reduced factorizations kept per workspace. The adaptive-ρ ladder of a
56/// well-scaled solve visits a handful of penalties; pathological ladders
57/// simply fall back to least-recently-used rebuilding.
58const FACTORIZATION_CACHE_CAPACITY: usize = 16;
59
60/// Reusable solve state for a fixed problem structure.
61///
62/// Constructed by [`crate::Solver::workspace`]. Holds the equilibrated
63/// problem copy and a penalty-keyed cache of SMW-reduced factorizations, so
64/// repeated solves skip both setup costs. See the
65/// [module documentation](self) for what is cached and when it is rebuilt.
66pub struct Workspace {
67    settings: SolverSettings,
68    /// Original-space problem data; updated in place by the `update_*`
69    /// methods and used for termination checks and reported residuals.
70    problem: QpProblem,
71    scaling: Option<ScaledProblem>,
72    /// Most-recently-used-first factorization cache keyed by exact penalty
73    /// bits. The adaptive policy replays the same multiplicative ladder on
74    /// every solve, so lookups are bit-exact.
75    systems: Vec<(f64, FactorizedSystem)>,
76    factorizations: usize,
77}
78
79impl Workspace {
80    /// Validates the problem and settings, equilibrates once, and builds the
81    /// first reduced factorization.
82    pub(crate) fn new(settings: &SolverSettings, problem: &QpProblem) -> Result<Self, SolverError> {
83        problem.validate()?;
84        validate_settings(settings)?;
85        let scaling = if settings.scaling_iterations > 0 {
86            Some(ScaledProblem::new(problem, settings.scaling_iterations)?)
87        } else {
88            None
89        };
90        let work = scaling.as_ref().map_or(problem, |scaled| &scaled.problem);
91        let system = FactorizedSystem::new(work, settings.rho, settings.sigma)?;
92        Ok(Self {
93            settings: settings.clone(),
94            problem: problem.clone(),
95            scaling,
96            systems: vec![(settings.rho, system)],
97            factorizations: 1,
98        })
99    }
100
101    /// Returns the problem data currently held by the workspace (original,
102    /// unscaled space, including any updates applied so far).
103    #[must_use]
104    pub fn problem(&self) -> &QpProblem {
105        &self.problem
106    }
107
108    /// Returns the settings this workspace iterates with.
109    #[must_use]
110    pub const fn settings(&self) -> &SolverSettings {
111        &self.settings
112    }
113
114    /// Number of reduced factorizations built since construction (including
115    /// the initial one). Stable counts across rolling solves demonstrate
116    /// factorization reuse.
117    #[must_use]
118    pub const fn factorizations(&self) -> usize {
119        self.factorizations
120    }
121
122    /// Replaces the linear objective coefficient (for portfolios: new
123    /// expected returns and/or previous weights folded into `q`).
124    ///
125    /// The cached factorizations stay valid; the equilibration computed at
126    /// construction is reapplied to the new vector as an exact transform.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`SolverError::InvalidProblem`] when the vector has the wrong
131    /// length or contains a non-finite value.
132    pub fn update_linear(&mut self, linear: &[f64]) -> Result<(), SolverError> {
133        replace_vector("linear", &mut self.problem.linear, linear)?;
134        if let Some(scaled) = &mut self.scaling {
135            scaled.set_linear(linear);
136        }
137        Ok(())
138    }
139
140    /// Replaces the anchor of the L1 term (for portfolios: the previous
141    /// weights the proportional turnover cost is measured from).
142    ///
143    /// The anchor enters neither the quadratic nor the constraint matrices,
144    /// so the cached factorizations stay valid; the frozen variable scaling
145    /// is reapplied as an exact transform. The costs themselves are part of
146    /// the problem structure held by this workspace and stay fixed.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`SolverError::InvalidProblem`] when the problem has no L1
151    /// term, or the vector has the wrong length or a non-finite value.
152    pub fn update_l1_anchor(&mut self, anchor: &[f64]) -> Result<(), SolverError> {
153        let Some(term) = &mut self.problem.l1 else {
154            return Err(SolverError::InvalidProblem(ProblemError::Dimension {
155                field: "l1.anchor",
156                expected: 0,
157                actual: anchor.len(),
158            }));
159        };
160        replace_vector("l1.anchor", &mut term.anchor, anchor)?;
161        if let Some(scaled) = &mut self.scaling {
162            scaled.set_l1_anchor(anchor);
163        }
164        Ok(())
165    }
166
167    /// Replaces the equality right-hand side (for portfolios: a new budget).
168    ///
169    /// The cached factorizations stay valid.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`SolverError::InvalidProblem`] when the vector has the wrong
174    /// length or contains a non-finite value.
175    pub fn update_equality_rhs(&mut self, rhs: &[f64]) -> Result<(), SolverError> {
176        replace_vector("equalities.rhs", &mut self.problem.equalities.rhs, rhs)?;
177        if let Some(scaled) = &mut self.scaling {
178            scaled.set_equality_rhs(rhs);
179        }
180        Ok(())
181    }
182
183    /// Replaces the inequality right-hand side (for portfolios: new exposure
184    /// caps with unchanged constraint rows).
185    ///
186    /// The cached factorizations stay valid.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`SolverError::InvalidProblem`] when the vector has the wrong
191    /// length or contains a non-finite value.
192    pub fn update_inequality_rhs(&mut self, rhs: &[f64]) -> Result<(), SolverError> {
193        replace_vector("inequalities.rhs", &mut self.problem.inequalities.rhs, rhs)?;
194        if let Some(scaled) = &mut self.scaling {
195            scaled.set_inequality_rhs(rhs);
196        }
197        Ok(())
198    }
199
200    /// Solves the current problem data, reusing the cached equilibration and
201    /// factorizations.
202    ///
203    /// The iterate path is identical to a fresh
204    /// [`Solver::solve`](crate::Solver::solve) of the same data: the penalty
205    /// policy restarts from [`SolverSettings::rho`] and adapts as usual, but
206    /// every penalty already visited by this workspace reuses its cached
207    /// factorization instead of rebuilding it.
208    ///
209    /// Unlike [`crate::Solver::solve`], the reported
210    /// [`Solution::solve_time`] covers only this call: one-time setup was
211    /// paid at construction.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`SolverError`] when the warm start is invalid or a rebuilt
216    /// reduced system cannot be factored.
217    pub fn solve(&mut self, warm_start: Option<&WarmStart>) -> Result<Solution, SolverError> {
218        self.solve_from(Instant::now(), warm_start)
219    }
220
221    /// Shared ADMM engine; `started` controls what the reported solve time
222    /// includes so the one-shot path can charge setup to the solve.
223    pub(crate) fn solve_from(
224        &mut self,
225        started: Instant,
226        warm_start: Option<&WarmStart>,
227    ) -> Result<Solution, SolverError> {
228        let settings = self.settings.clone();
229        // Field-level borrows: `work` reads `scaling`/`problem` while the
230        // factorization cache below is mutated independently.
231        let work = self
232            .scaling
233            .as_ref()
234            .map_or(&self.problem, |scaled| &scaled.problem);
235        let systems = &mut self.systems;
236        let factorizations = &mut self.factorizations;
237
238        let n = self.problem.quadratic.dimension();
239        let equality_count = self.problem.equalities.len();
240        let inequality_count = self.problem.inequalities.len();
241        let l1_count = if self.problem.l1.is_some() { n } else { 0 };
242        let mut x = if let Some(warm) = warm_start {
243            validate_warm_vector("x", &warm.x, n)?;
244            warm.x.clone()
245        } else {
246            vec![0.0; n]
247        };
248        let mut dual = DualVariables {
249            equalities: warm_vector(
250                warm_start.and_then(|warm| warm.equality_dual.as_deref()),
251                "equality_dual",
252                equality_count,
253            )?,
254            inequalities: warm_vector(
255                warm_start.and_then(|warm| warm.inequality_dual.as_deref()),
256                "inequality_dual",
257                inequality_count,
258            )?,
259            bounds: warm_vector(
260                warm_start.and_then(|warm| warm.bound_dual.as_deref()),
261                "bound_dual",
262                n,
263            )?,
264            l1: warm_vector(
265                warm_start.and_then(|warm| warm.l1_dual.as_deref()),
266                "l1_dual",
267                l1_count,
268            )?,
269        };
270        if let Some(scaled) = &self.scaling {
271            scaled.scale_iterates_in_place(&mut x, &mut dual);
272        }
273
274        let mut rho = settings.rho;
275        ensure_system(systems, factorizations, work, rho, settings.sigma)?;
276        let mut equality_slack = vec![0.0; equality_count];
277        let initial_inequality = work.inequalities.matrix.mul_vec(&x);
278        let mut inequality_slack: Vec<f64> = initial_inequality
279            .iter()
280            .zip(&dual.inequalities)
281            .zip(&work.inequalities.rhs)
282            .map(|((value, multiplier), rhs)| (value + multiplier / rho).min(*rhs))
283            .collect();
284        equality_slack.copy_from_slice(&work.equalities.rhs);
285        let mut bound_slack: Vec<f64> = x
286            .iter()
287            .zip(&dual.bounds)
288            .enumerate()
289            .map(|(index, (value, multiplier))| {
290                project(
291                    value + multiplier / rho,
292                    work.lower_bounds[index],
293                    work.upper_bounds[index],
294                )
295            })
296            .collect();
297        // L1 consensus block `x - anchor = z` (roadmap 2.1): its projection
298        // is the soft threshold, initialized like the other slacks.
299        let mut l1_slack: Vec<f64> = work.l1.as_ref().map_or_else(Vec::new, |term| {
300            x.iter()
301                .zip(&dual.l1)
302                .enumerate()
303                .map(|(index, (value, multiplier))| {
304                    soft_threshold(
305                        value - term.anchor[index] + multiplier / rho,
306                        term.costs[index] / rho,
307                    )
308                })
309                .collect()
310        });
311
312        let mut status = SolveStatus::MaxIterations;
313        let mut completed_iterations = 0;
314        let mut rho_updates = 0;
315        let mut certificate: Option<Certificate> = None;
316        // Infeasibility detection compares original-space iterates between
317        // consecutive termination checks: on infeasible problems those
318        // differences converge to certificate directions (OSQP-style; see
319        // `certificate.rs`), while scaling-space artifacts never enter.
320        let detect_infeasibility = settings.infeasibility_tolerance > 0.0;
321        let (mut checked_x, mut checked_dual) = if detect_infeasibility {
322            unscaled_iterates(self.scaling.as_ref(), &x, &dual)
323        } else {
324            (Vec::new(), DualVariables::default())
325        };
326
327        for iteration in 1..=settings.max_iterations {
328            let mut right_hand_side: Vec<f64> = x
329                .iter()
330                .zip(&work.linear)
331                .map(|(value, linear)| settings.sigma * value - linear)
332                .collect();
333
334            let equality_weight: Vec<f64> = equality_slack
335                .iter()
336                .zip(&dual.equalities)
337                .map(|(slack, multiplier)| rho * slack - multiplier)
338                .collect();
339            work.equalities
340                .matrix
341                .transpose_mul_add(&equality_weight, &mut right_hand_side);
342            let inequality_weight: Vec<f64> = inequality_slack
343                .iter()
344                .zip(&dual.inequalities)
345                .map(|(slack, multiplier)| rho * slack - multiplier)
346                .collect();
347            work.inequalities
348                .matrix
349                .transpose_mul_add(&inequality_weight, &mut right_hand_side);
350            for ((right_hand_side, slack), multiplier) in right_hand_side
351                .iter_mut()
352                .zip(&bound_slack)
353                .zip(&dual.bounds)
354            {
355                *right_hand_side += rho * slack - multiplier;
356            }
357            if let Some(term) = &work.l1 {
358                // From `rho/2 * ||x - anchor - z + y/rho||^2`:
359                // the x-gradient contributes `rho * (anchor + z) - y`.
360                for (((right_hand_side, slack), multiplier), anchor) in right_hand_side
361                    .iter_mut()
362                    .zip(&l1_slack)
363                    .zip(&dual.l1)
364                    .zip(&term.anchor)
365                {
366                    *right_hand_side += rho * (anchor + slack) - multiplier;
367                }
368            }
369
370            // The current factorization is always the most recently used.
371            systems[0].1.solve_in_place(&mut right_hand_side);
372            x = right_hand_side;
373            if x.iter().any(|value| !value.is_finite()) {
374                status = SolveStatus::NumericalFailure;
375                completed_iterations = iteration;
376                break;
377            }
378
379            let equality_values = work.equalities.matrix.mul_vec(&x);
380            let inequality_values = work.inequalities.matrix.mul_vec(&x);
381            let adapt_rho = settings.adaptive_rho
382                && iteration % settings.adaptive_rho_interval == 0
383                && iteration < settings.max_iterations;
384            let old_inequality_slack = if adapt_rho {
385                inequality_slack.clone()
386            } else {
387                Vec::new()
388            };
389            let old_bound_slack = if adapt_rho {
390                bound_slack.clone()
391            } else {
392                Vec::new()
393            };
394            let old_l1_slack = if adapt_rho {
395                l1_slack.clone()
396            } else {
397                Vec::new()
398            };
399            // Over-relaxation: every consensus block sees the blend
400            // `alpha * Ax + (1 - alpha) * z_prev` in place of `Ax`
401            // (Boyd et al. §3.4.3); `alpha = 1` recovers plain ADMM.
402            let alpha = settings.over_relaxation;
403            for ((multiplier, value), slack) in dual
404                .equalities
405                .iter_mut()
406                .zip(&equality_values)
407                .zip(&equality_slack)
408            {
409                let relaxed = alpha * value + (1.0 - alpha) * slack;
410                *multiplier += rho * (relaxed - slack);
411            }
412            for (((slack, multiplier), value), rhs) in inequality_slack
413                .iter_mut()
414                .zip(&mut dual.inequalities)
415                .zip(&inequality_values)
416                .zip(&work.inequalities.rhs)
417            {
418                let relaxed = alpha * value + (1.0 - alpha) * *slack;
419                *slack = (relaxed + *multiplier / rho).min(*rhs);
420                *multiplier += rho * (relaxed - *slack);
421            }
422            for (index, ((slack, multiplier), value)) in bound_slack
423                .iter_mut()
424                .zip(&mut dual.bounds)
425                .zip(&x)
426                .enumerate()
427            {
428                let relaxed = alpha * value + (1.0 - alpha) * *slack;
429                *slack = project(
430                    relaxed + *multiplier / rho,
431                    work.lower_bounds[index],
432                    work.upper_bounds[index],
433                );
434                *multiplier += rho * (relaxed - *slack);
435            }
436            if let Some(term) = &work.l1 {
437                // The prox of `(c_i / rho) * |z|` is the soft threshold;
438                // the consensus value for this block is `x - anchor`.
439                for (index, ((slack, multiplier), value)) in
440                    l1_slack.iter_mut().zip(&mut dual.l1).zip(&x).enumerate()
441                {
442                    let consensus = value - term.anchor[index];
443                    let relaxed = alpha * consensus + (1.0 - alpha) * *slack;
444                    *slack = soft_threshold(relaxed + *multiplier / rho, term.costs[index] / rho);
445                    *multiplier += rho * (relaxed - *slack);
446                }
447            }
448            completed_iterations = iteration;
449
450            if iteration % settings.check_termination_every == 0
451                || iteration == settings.max_iterations
452            {
453                // Termination is always decided on the original data so that a
454                // reported `Solved` never depends on the equilibration. The
455                // complementarity gate keeps `Solved` as strong as feasibility
456                // plus stationarity alone would suggest: `check_kkt` scores
457                // stray multipliers by their complementarity products rather
458                // than folding them into the dual residual.
459                let (original_x, original_dual) =
460                    unscaled_iterates(self.scaling.as_ref(), &x, &dual);
461                let residuals = check_kkt(&self.problem, &original_x, &original_dual)?;
462                let (primal_tolerance, dual_tolerance) =
463                    stopping_tolerances(&self.problem, &original_x, &original_dual, &settings);
464                if residuals.primal <= primal_tolerance
465                    && residuals.dual <= dual_tolerance
466                    && residuals.complementarity <= primal_tolerance.max(dual_tolerance)
467                {
468                    status = SolveStatus::Solved;
469                    break;
470                }
471                if detect_infeasibility {
472                    let delta_dual = DualVariables {
473                        equalities: difference(&original_dual.equalities, &checked_dual.equalities),
474                        inequalities: difference(
475                            &original_dual.inequalities,
476                            &checked_dual.inequalities,
477                        ),
478                        bounds: difference(&original_dual.bounds, &checked_dual.bounds),
479                        // The L1 multiplier is boxed in [-costs, costs], so it
480                        // cannot diverge along a Farkas ray; it plays no role
481                        // in either certificate.
482                        l1: Vec::new(),
483                    };
484                    if let Some(found) = detect_primal_infeasibility(
485                        &self.problem,
486                        &delta_dual,
487                        settings.infeasibility_tolerance,
488                    ) {
489                        certificate = Some(Certificate::Primal(found));
490                        status = SolveStatus::PrimalInfeasible;
491                        break;
492                    }
493                    let delta_x = difference(&original_x, &checked_x);
494                    if let Some(found) = detect_dual_infeasibility(
495                        &self.problem,
496                        &delta_x,
497                        settings.infeasibility_tolerance,
498                    ) {
499                        certificate = Some(Certificate::Dual(found));
500                        status = SolveStatus::DualInfeasible;
501                        break;
502                    }
503                    checked_x = original_x;
504                    checked_dual = original_dual;
505                }
506            }
507
508            if adapt_rho {
509                let primal_residual = consensus_primal_residual(
510                    work,
511                    &equality_values,
512                    &equality_slack,
513                    &inequality_values,
514                    &inequality_slack,
515                    &x,
516                    &bound_slack,
517                    &l1_slack,
518                );
519                let dual_residual = consensus_dual_residual(
520                    work,
521                    &old_inequality_slack,
522                    &inequality_slack,
523                    &old_bound_slack,
524                    &bound_slack,
525                    &old_l1_slack,
526                    &l1_slack,
527                    rho,
528                );
529                let next_rho = balanced_rho(rho, primal_residual, dual_residual, &settings);
530                if next_rho.to_bits() != rho.to_bits() {
531                    ensure_system(systems, factorizations, work, next_rho, settings.sigma)?;
532                    rho = next_rho;
533                    rho_updates += 1;
534                }
535            }
536        }
537
538        let (mut x, mut dual) = unscaled_iterates(self.scaling.as_ref(), &x, &dual);
539        let mut residuals = if status == SolveStatus::NumericalFailure {
540            KktResiduals {
541                primal: f64::INFINITY,
542                dual: f64::INFINITY,
543                complementarity: f64::INFINITY,
544            }
545        } else {
546            check_kkt(&self.problem, &x, &dual)?
547        };
548        // Polishing refines only Solved iterates (certificates and failure
549        // diagnostics stay untouched) and is adopted only when the audited
550        // worst KKT residual improves; see `polish.rs`.
551        let mut polished = false;
552        if status == SolveStatus::Solved && settings.polish {
553            if let Some(improved) = polish(&self.problem, &x, &dual, &residuals, &settings) {
554                x = improved.x;
555                dual = improved.dual;
556                residuals = improved.residuals;
557                polished = true;
558            }
559        }
560        let diagnostics = if status == SolveStatus::Solved {
561            None
562        } else {
563            Some(diagnose_failure(
564                &self.problem,
565                &x,
566                &dual,
567                &residuals,
568                status,
569                certificate.as_ref(),
570                rho,
571                rho_updates,
572                &settings,
573            ))
574        };
575        Ok(Solution {
576            status,
577            objective: self.problem.objective(&x),
578            x,
579            dual,
580            residuals,
581            iterations: completed_iterations,
582            solve_time: started.elapsed(),
583            final_rho: rho,
584            rho_updates,
585            polished,
586            diagnostics,
587            certificate,
588        })
589    }
590}
591
592/// Elementwise `current - previous`.
593fn difference(current: &[f64], previous: &[f64]) -> Vec<f64> {
594    current
595        .iter()
596        .zip(previous)
597        .map(|(new, old)| new - old)
598        .collect()
599}
600
601/// Moves the factorization for `rho` to the front of the cache, building it
602/// on a miss and evicting the least recently used entry beyond capacity.
603///
604/// A free function over disjoint `Workspace` fields so the solve loop can
605/// hold the scaled problem borrowed while the cache mutates.
606fn ensure_system(
607    systems: &mut Vec<(f64, FactorizedSystem)>,
608    factorizations: &mut usize,
609    work: &QpProblem,
610    rho: f64,
611    sigma: f64,
612) -> Result<(), SolverError> {
613    if let Some(position) = systems
614        .iter()
615        .position(|(cached, _)| cached.to_bits() == rho.to_bits())
616    {
617        let entry = systems.remove(position);
618        systems.insert(0, entry);
619        return Ok(());
620    }
621    let system = FactorizedSystem::new(work, rho, sigma)?;
622    *factorizations += 1;
623    systems.insert(0, (rho, system));
624    systems.truncate(FACTORIZATION_CACHE_CAPACITY);
625    Ok(())
626}
627
628fn replace_vector(
629    field: &'static str,
630    target: &mut [f64],
631    values: &[f64],
632) -> Result<(), SolverError> {
633    if values.len() != target.len() {
634        return Err(SolverError::InvalidProblem(ProblemError::Dimension {
635            field,
636            expected: target.len(),
637            actual: values.len(),
638        }));
639    }
640    if values.iter().any(|value| !value.is_finite()) {
641        return Err(SolverError::InvalidProblem(ProblemError::NonFinite(field)));
642    }
643    target.copy_from_slice(values);
644    Ok(())
645}
646
647pub(crate) struct FactorizedSystem {
648    system: SmwSystem,
649}
650
651impl FactorizedSystem {
652    pub(crate) fn new(problem: &QpProblem, rho: f64, sigma: f64) -> Result<Self, SolverError> {
653        let covariance = covariance_columns(&problem.quadratic)?;
654        let n = problem.quadratic.dimension();
655        let factor_count = problem.quadratic.factor_count();
656        let constraint_count = problem.equalities.len() + problem.inequalities.len();
657        let reduced_dimension = factor_count + constraint_count;
658        let mut columns = Matrix::zeros(n, reduced_dimension);
659        for row in 0..n {
660            for col in 0..factor_count {
661                columns[(row, col)] = covariance[(row, col)];
662            }
663        }
664        let rho_root = rho.sqrt();
665        for constraint in 0..problem.equalities.len() {
666            for variable in 0..n {
667                columns[(variable, factor_count + constraint)] =
668                    rho_root * problem.equalities.matrix[(constraint, variable)];
669            }
670        }
671        let inequality_offset = factor_count + problem.equalities.len();
672        for constraint in 0..problem.inequalities.len() {
673            for variable in 0..n {
674                columns[(variable, inequality_offset + constraint)] =
675                    rho_root * problem.inequalities.matrix[(constraint, variable)];
676            }
677        }
678
679        // The box consensus x = z contributes rho*I, so the base remains
680        // diagonal even when every variable has finite bounds. The L1
681        // soft-threshold consensus x - anchor = z contributes another rho*I:
682        // the reduced dimension never grows with the L1 term.
683        let l1_rho = if problem.l1.is_some() { rho } else { 0.0 };
684        let diagonal: Vec<f64> = problem
685            .quadratic
686            .diagonal
687            .iter()
688            .map(|diagonal| diagonal + sigma + rho + l1_rho)
689            .collect();
690        Ok(Self {
691            system: SmwSystem::factor(&diagonal, columns)?,
692        })
693    }
694
695    pub(crate) fn solve_in_place(&self, right_hand_side: &mut [f64]) {
696        self.system.solve_in_place(right_hand_side);
697    }
698}
699
700fn project(value: f64, lower: f64, upper: f64) -> f64 {
701    value.max(lower).min(upper)
702}
703
704/// Proximal operator of `threshold * |z|`: shrink toward zero by
705/// `threshold`, exactly zero inside the dead zone.
706fn soft_threshold(value: f64, threshold: f64) -> f64 {
707    if value > threshold {
708        value - threshold
709    } else if value < -threshold {
710        value + threshold
711    } else {
712        0.0
713    }
714}
715
716/// Maps scaled-space iterates back to the original space; the identity when
717/// scaling is disabled.
718fn unscaled_iterates(
719    scaling: Option<&ScaledProblem>,
720    x: &[f64],
721    dual: &DualVariables,
722) -> (Vec<f64>, DualVariables) {
723    scaling.map_or_else(
724        || (x.to_vec(), dual.clone()),
725        |scaled| (scaled.unscaled_x(x), scaled.unscaled_dual(dual)),
726    )
727}
728
729#[allow(clippy::too_many_arguments)]
730fn consensus_primal_residual(
731    problem: &QpProblem,
732    equality_values: &[f64],
733    equality_slack: &[f64],
734    inequality_values: &[f64],
735    inequality_slack: &[f64],
736    x: &[f64],
737    bound_slack: &[f64],
738    l1_slack: &[f64],
739) -> f64 {
740    let l1_residual = problem.l1.as_ref().map_or(0.0, |term| {
741        x.iter()
742            .zip(&term.anchor)
743            .zip(l1_slack)
744            .map(|((value, anchor), slack)| (value - anchor - slack).abs())
745            .fold(0.0, f64::max)
746    });
747    equality_values
748        .iter()
749        .zip(equality_slack)
750        .chain(inequality_values.iter().zip(inequality_slack))
751        .map(|(value, slack)| (value - slack).abs())
752        .chain(
753            x.iter()
754                .zip(bound_slack)
755                .map(|(value, slack)| (value - slack).abs()),
756        )
757        .fold(l1_residual, f64::max)
758}
759
760#[allow(clippy::too_many_arguments)]
761fn consensus_dual_residual(
762    problem: &QpProblem,
763    old_inequality_slack: &[f64],
764    inequality_slack: &[f64],
765    old_bound_slack: &[f64],
766    bound_slack: &[f64],
767    old_l1_slack: &[f64],
768    l1_slack: &[f64],
769    rho: f64,
770) -> f64 {
771    let inequality_change: Vec<f64> = inequality_slack
772        .iter()
773        .zip(old_inequality_slack)
774        .map(|(new, old)| new - old)
775        .collect();
776    let mut dual_change: Vec<f64> = bound_slack
777        .iter()
778        .zip(old_bound_slack)
779        .map(|(new, old)| new - old)
780        .collect();
781    // The L1 block's constraint matrix is the identity, like the box block.
782    for (change, (new, old)) in dual_change
783        .iter_mut()
784        .zip(l1_slack.iter().zip(old_l1_slack))
785    {
786        *change += new - old;
787    }
788    problem
789        .inequalities
790        .matrix
791        .transpose_mul_add(&inequality_change, &mut dual_change);
792    rho * norm_inf(&dual_change)
793}
794
795fn balanced_rho(
796    rho: f64,
797    primal_residual: f64,
798    dual_residual: f64,
799    settings: &SolverSettings,
800) -> f64 {
801    if primal_residual > settings.adaptive_rho_tolerance * dual_residual {
802        (rho * settings.adaptive_rho_multiplier).min(settings.maximum_rho)
803    } else if dual_residual > settings.adaptive_rho_tolerance * primal_residual {
804        (rho / settings.adaptive_rho_multiplier).max(settings.minimum_rho)
805    } else {
806        rho
807    }
808}
809
810fn validate_warm_vector(
811    field: &'static str,
812    values: &[f64],
813    expected: usize,
814) -> Result<(), SolverError> {
815    if values.len() != expected {
816        return Err(SolverError::WarmStartDimension {
817            field,
818            expected,
819            actual: values.len(),
820        });
821    }
822    if values.iter().any(|value| !value.is_finite()) {
823        return Err(SolverError::WarmStartNonFinite(field));
824    }
825    Ok(())
826}
827
828fn warm_vector(
829    values: Option<&[f64]>,
830    field: &'static str,
831    expected: usize,
832) -> Result<Vec<f64>, SolverError> {
833    if let Some(values) = values {
834        validate_warm_vector(field, values, expected)?;
835        Ok(values.to_vec())
836    } else {
837        Ok(vec![0.0; expected])
838    }
839}
840
841#[allow(clippy::too_many_arguments)]
842fn diagnose_failure(
843    problem: &QpProblem,
844    x: &[f64],
845    dual: &DualVariables,
846    residuals: &KktResiduals,
847    status: SolveStatus,
848    certificate: Option<&Certificate>,
849    rho: f64,
850    rho_updates: usize,
851    settings: &SolverSettings,
852) -> ConvergenceDiagnostics {
853    let (primal_tolerance, dual_tolerance) = stopping_tolerances(problem, x, dual, settings);
854    let coefficient_spread_decades = coefficient_spread_decades(problem);
855    let rho_at_limit = rho <= settings.minimum_rho || rho >= settings.maximum_rho;
856
857    let mut hints = Vec::new();
858    match certificate {
859        Some(Certificate::Primal(primal)) => {
860            if let Ok(audited) = check_primal_certificate(problem, primal) {
861                hints.push(format!(
862                    "a Farkas certificate proves the constraints admit no common point: \
863                     the weighted constraint combination in Solution::certificate cancels \
864                     every variable (stationarity residual {:.1e}) yet requires a value \
865                     below {:.3e}; relax one of the participating constraints \
866                     (audit independently with check_primal_certificate)",
867                    audited.stationarity, audited.support_gap
868                ));
869            }
870        }
871        Some(Certificate::Dual(dual_certificate)) => {
872            if let Ok(audited) = check_dual_certificate(problem, dual_certificate) {
873                hints.push(format!(
874                    "a descent-ray certificate proves the objective is unbounded below: \
875                     along Solution::certificate's direction the quadratic cost stays at \
876                     {:.1e} while the linear cost falls at slope {:.3e} and no constraint \
877                     blocks the ray; add bounds or constraints capping this direction \
878                     (audit independently with check_dual_certificate)",
879                    audited.curvature, audited.objective_gap
880                ));
881            }
882        }
883        None => {}
884    }
885    if status == SolveStatus::NumericalFailure {
886        hints.push(
887            "an iterate became non-finite; check the input for extreme coefficient \
888             magnitudes or near-singular covariance data"
889                .to_owned(),
890        );
891    }
892    if coefficient_spread_decades > 6.0 {
893        if settings.scaling_iterations == 0 {
894            hints.push(format!(
895                "problem coefficients span about {coefficient_spread_decades:.0} orders of \
896                 magnitude and automatic scaling is disabled; set scaling_iterations \
897                 to its default (10) or rescale returns, covariance, and constraints \
898                 toward comparable units"
899            ));
900        } else {
901            hints.push(format!(
902                "problem coefficients span about {coefficient_spread_decades:.0} orders of \
903                 magnitude even though Ruiz equilibration ran; rescale returns, \
904                 covariance, and constraints toward comparable units before solving"
905            ));
906        }
907    }
908    if rho_at_limit {
909        hints.push(format!(
910            "the adaptive penalty finished pinned at {rho:.1e} (allowed range \
911             [{:.1e}, {:.1e}]); widen minimum_rho/maximum_rho or start rho closer to \
912             this value",
913            settings.minimum_rho, settings.maximum_rho
914        ));
915    }
916    let primal_gap = residuals.primal / primal_tolerance.max(f64::MIN_POSITIVE);
917    let dual_gap = residuals.dual / dual_tolerance.max(f64::MIN_POSITIVE);
918    if status == SolveStatus::MaxIterations {
919        if primal_gap.max(dual_gap) < 100.0 {
920            hints.push(format!(
921                "residuals are within two orders of magnitude of tolerance \
922                 (primal {:.1e} vs {:.1e}, dual {:.1e} vs {:.1e}); raising \
923                 max_iterations above {} will likely finish the solve",
924                residuals.primal,
925                primal_tolerance,
926                residuals.dual,
927                dual_tolerance,
928                settings.max_iterations
929            ));
930        } else if primal_gap > 100.0 * dual_gap.max(1.0) {
931            hints.push(
932                "the primal residual dominates and no infeasibility certificate was \
933                 found within infeasibility_tolerance; the constraints may be feasible \
934                 only barely, or infeasible by a margin below that tolerance — verify \
935                 that budget, boxes, and linear constraints admit a common point"
936                    .to_owned(),
937            );
938        } else if dual_gap > 100.0 * primal_gap.max(1.0) {
939            hints.push(
940                "the dual residual dominates; consider loosening relative_tolerance, \
941                 increasing rho, or rescaling the objective so risk and return terms \
942                 have comparable magnitude"
943                    .to_owned(),
944            );
945        } else {
946            hints.push(format!(
947                "both residuals remain far from tolerance after {} iterations; \
948                 rescale the problem data or relax tolerances before raising the \
949                 iteration budget",
950                settings.max_iterations
951            ));
952        }
953    }
954    if rho_updates > 10 {
955        hints.push(format!(
956            "rho was re-tuned {rho_updates} times, a sign of conflicting primal/dual \
957             scales; data rescaling usually helps more than iteration budget"
958        ));
959    }
960
961    ConvergenceDiagnostics {
962        primal_tolerance,
963        dual_tolerance,
964        coefficient_spread_decades,
965        rho_at_limit,
966        hints,
967    }
968}
969
970/// Base-10 spread between the largest and smallest nonzero magnitudes across
971/// objective, constraint, and bound data.
972fn coefficient_spread_decades(problem: &QpProblem) -> f64 {
973    let mut smallest = f64::INFINITY;
974    let mut largest = 0.0_f64;
975    let mut observe = |value: f64| {
976        let magnitude = value.abs();
977        if magnitude.is_finite() && magnitude > 0.0 {
978            smallest = smallest.min(magnitude);
979            largest = largest.max(magnitude);
980        }
981    };
982    for value in problem.quadratic.factors.as_slice() {
983        observe(*value);
984    }
985    for value in &problem.quadratic.diagonal {
986        observe(*value);
987    }
988    for value in &problem.linear {
989        observe(*value);
990    }
991    if let Some(l1) = &problem.l1 {
992        for value in l1.costs.iter().chain(&l1.anchor) {
993            observe(*value);
994        }
995    }
996    for constraints in [&problem.equalities, &problem.inequalities] {
997        for value in constraints.matrix.as_slice() {
998            observe(*value);
999        }
1000        for value in &constraints.rhs {
1001            observe(*value);
1002        }
1003    }
1004    if largest == 0.0 || !smallest.is_finite() {
1005        0.0
1006    } else {
1007        (largest / smallest).log10()
1008    }
1009}
1010
1011fn stopping_tolerances(
1012    problem: &QpProblem,
1013    x: &[f64],
1014    dual: &DualVariables,
1015    settings: &SolverSettings,
1016) -> (f64, f64) {
1017    let equality_values = problem.equalities.matrix.mul_vec(x);
1018    let inequality_values = problem.inequalities.matrix.mul_vec(x);
1019    let primal_scale = 1.0_f64
1020        .max(norm_inf(x))
1021        .max(norm_inf(&equality_values))
1022        .max(norm_inf(&problem.equalities.rhs))
1023        .max(norm_inf(&inequality_values))
1024        .max(norm_inf(&problem.inequalities.rhs));
1025
1026    let qx = problem.quadratic.apply(x);
1027    let mut transposed_dual = vec![0.0; x.len()];
1028    problem
1029        .equalities
1030        .matrix
1031        .transpose_mul_add(&dual.equalities, &mut transposed_dual);
1032    problem
1033        .inequalities
1034        .matrix
1035        .transpose_mul_add(&dual.inequalities, &mut transposed_dual);
1036    for (value, bound) in transposed_dual.iter_mut().zip(&dual.bounds) {
1037        *value += bound;
1038    }
1039    for (value, l1) in transposed_dual.iter_mut().zip(&dual.l1) {
1040        *value += l1;
1041    }
1042    let l1_cost_scale = problem
1043        .l1
1044        .as_ref()
1045        .map_or(0.0, |term| norm_inf(&term.costs));
1046    let dual_scale = 1.0_f64
1047        .max(norm_inf(&qx))
1048        .max(norm_inf(&problem.linear))
1049        .max(l1_cost_scale)
1050        .max(norm_inf(&transposed_dual));
1051
1052    (
1053        settings.absolute_tolerance + settings.relative_tolerance * primal_scale,
1054        settings.absolute_tolerance + settings.relative_tolerance * dual_scale,
1055    )
1056}