Skip to main content

ferrolearn_linear/
linear_svc.rs

1//! Linear Support Vector Classifier.
2//!
3//! This module provides [`LinearSVC`], a liblinear-faithful linear support
4//! vector classifier that operates directly in the primal space without the
5//! overhead of a kernel function. The fit minimizes the L2-regularized
6//! hinge (or squared-hinge) classification objective
7//!
8//! ```text
9//!   min_w   0.5 * ||w||^2  +  C * sum_i  L(y_i, w . x_i)
10//! ```
11//!
12//! with `y_i ∈ {-1, +1}` per one-vs-rest sub-problem (NO `1/n` averaging — the
13//! summed loss is scaled by `C`, matching `sklearn/svm/_base.py`
14//! `_fit_liblinear`). The solver is liblinear's dual coordinate descent for
15//! classification (`solve_l2r_l1l2_svc` in
16//! `sklearn/svm/src/liblinear/linear.cpp:819`), which converges to the unique
17//! minimizer of the strongly convex objective.
18//!
19//! Unlike [`SVC`](crate::svm::SVC) with a [`LinearKernel`](crate::svm::LinearKernel),
20//! `LinearSVC` avoids computing and caching the full kernel matrix, making it
21//! significantly faster for high-dimensional data.
22//!
23//! # Examples
24//!
25//! ```
26//! use ferrolearn_linear::linear_svc::LinearSVC;
27//! use ferrolearn_core::{Fit, Predict};
28//! use ndarray::{array, Array2};
29//!
30//! let x = Array2::from_shape_vec((6, 2), vec![
31//!     1.0, 1.0, 1.0, 2.0, 2.0, 1.0,
32//!     5.0, 5.0, 5.0, 6.0, 6.0, 5.0,
33//! ]).unwrap();
34//! let y = array![0usize, 0, 0, 1, 1, 1];
35//!
36//! let model = LinearSVC::<f64>::new();
37//! let fitted = model.fit(&x, &y).unwrap();
38//! let preds = fitted.predict(&x).unwrap();
39//! assert_eq!(preds.len(), 6);
40//! ```
41//!
42//! ## REQ status
43//!
44//! Binary (R-DEFER-2): SHIPPED = impl + non-test production consumer + tests +
45//! green oracle verification; NOT-STARTED = open blocker `#`. `LinearSVC`/
46//! `FittedLinearSVC`/`LinearSVCLoss` are boundary estimator types re-exported at
47//! the crate root (`pub use linear_svc::{…}` in `lib.rs`) and registered as the
48//! PyO3 `RsLinearSVC` estimator (`ferrolearn-python/src/extras.rs`); under
49//! S5/R-DEFER-1 those ARE the non-test production-consumer surface. See
50//! `.design/linear/linear_svc.md`.
51//!
52//! | REQ | Status | Evidence |
53//! |---|---|---|
54//! | REQ-1 (fit parity — coef_/intercept_ vs liblinear oracle) | SHIPPED | `fn solve_binary_dual` minimizes liblinear's `0.5·‖w‖² + C·Σ L` via the dual CD (`solve_l2r_l1l2_svc`, `linear.cpp:819`); `fn fit` maps `classes_[1]→+1` and extracts `coef_ = w[:n_features]`, `intercept_ = intercept_scaling·w_last` (`_base.py:1240-1245`). Pinned by `tests/divergence_linear_svc_fit.rs::linear_svc_coef_parity` (live oracle `coef_ [[0.12835213611984458, 0.12835213611984475]]`, `intercept_ [-1.1943776585907158]`, C=1.0, squared_hinge, fit_intercept=True). Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
55//! | REQ-2 (decision_function shape `(n,)` + values) | SHIPPED | `fn decision_function` returns [`DecisionScores::Binary`] = 1-D `X·w + b` for the binary case (sklearn ravels the single-column score to `(n,)`, `linear_model/_base.py:365`) and [`DecisionScores::Multiclass`] `(n, n_classes)` otherwise. Pinned by `tests/divergence_linear_svc_fit.rs::linear_svc_decision_function` (live oracle 1-D `(8,)` values). Consumer: `fn predict` reads the binary scores' sign. |
56//! | REQ-3 (predict + classes_) | SHIPPED | `fn predict` uses the sign of the binary decision (`>= 0 → classes_[1]`) / argmax of the OvR scores; `HasClasses::classes` = sorted unique `y` (`classes_ = np.unique(y)`, `_classes.py:311`). The labels are downstream of the liblinear-parity fit and pinned against the live oracle by `linear_svc_predict_parity in tests/divergence_linear_svc_fit.rs` (#620; 8×2 set: `predict [0,0,0,0,1,1,1,1]`, `classes_ [0,1]`). |
57//! | REQ-4 (loss {hinge, squared_hinge}) | SHIPPED | The dual CD solves BOTH the true `hinge` (`U=C`, `diag=0`) and `squared_hinge` (`U=∞`, `diag=0.5/C`) optima (`solve_l2r_l1l2_svc`, `linear.cpp:849-858`). The `hinge` optimum is pinned against the live oracle by `linear_svc_hinge_coef_parity in tests/divergence_linear_svc_fit.rs` (#621; 8×2 set, `loss='hinge'`, `C=1.0`: `coef_ [[0.15384615383852776, 0.15384615383915584]]`, `intercept_ [-1.4615384615168394]`). |
58//! | REQ-5 (penalty {l1, l2}) | SHIPPED | `LinearSVC<F>` exposes `pub penalty: LinearSVCPenalty` (default `L2`) + `#[must_use] with_penalty`. `penalty=l1` routes to `fn solve_binary_l1r_l2` — liblinear's feature-major coordinate descent (`solve_l1r_l2_svc`, `linear.cpp:1467`, solver type 5, `_base.py:1014`) minimizing `‖w‖₁ + C·Σ max(0,1−yf)²` (sparse `coef_`); `penalty=l2` keeps `fn solve_binary_dual`. `liblinear_solver_type` rejects `('l1','hinge')` (`_base.py:1013`). Pinned by `test_l1_penalty_smoke` (live oracle 8×2 `l1,squared_hinge,dual=False,C=1`: `coef_ [[0.1283185834966579, 0.12831858464059265]]`, `intercept_ [-1.2079646017762715]`; ferrolearn lands within ~1.2e-9) + `test_unsupported_combinations_rejected`. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
59//! | REQ-6 (multi_class {ovr, crammer_singer}) | SHIPPED | `LinearSVC<F>` exposes `pub multi_class: MultiClass` (`Ovr`/`CrammerSinger`, default `Ovr`, `_classes.py:239`) + `#[must_use] with_multi_class`. `multi_class=CrammerSinger` selects liblinear solver type 4 REGARDLESS of penalty/loss/dual (`_get_liblinear_solver_type`, `_base.py:1017,1020-1021`), so `fn fit` short-circuits to `fn fit_crammer_singer` (ignoring penalty/loss/dual) which runs ONE joint solve `fn solve_crammer_singer` — a faithful transcription of `Solver_MCSVM_CS` (`linear.cpp:510`, the class at `:493-787`): flattened `w[feature*nr_class + m]`, `alpha[i*nr_class + m]` with `Σ_m alpha=0`, `C[i] = weighted_C[y_i]`, per-sample shrinking (`active_size_i`/`alpha_index`/`be_shrunk`), the simplex inner solve `fn cs_solve_sub_problem` (sort-descending breakpoint, `linear.cpp:541-564`), and the two-level `eps_shrink = max(10·tol, 1)` stopping (`linear.cpp:738-753`). Extraction: `coef_[m][feature] = w[feature*nr_class + m]`, `intercept_[m] = intercept_scaling·w[n_features*nr_class + m]` (`_base.py:1240-1245`). BINARY: collapse to a single weight vector `coef_ = row_1 − row_0`, `intercept_ = int_1 − int_0` (`_classes.py:340-344`), `is_binary=true`. ferrolearn sweeps natural order (no `bounded_rand_int` shuffle, `linear.cpp:629`); the CS optimum is unique so the limit is identical (documented RNG/shrink-path boundary). Pinned by `test_crammer_singer_smoke in linear_svc.rs` (live oracle 3-class set `coef [[-0.06762,-0.24341],[0.30048,0.02171],[-0.23286,0.22171]]`, `int [0.91078,-0.62206,-0.28873]`, predict all-correct; binary 8×2 collapse `coef [[0.15504,0.15504]]`, `int [-1.48062]`; within 1e-2). The rigorous oracle pin in `tests/divergence_linear_svc_fit.rs` is the critic's next step. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
60//! | REQ-7 (fit_intercept + intercept_scaling) | SHIPPED | `LinearSVC<F>` exposes `pub fit_intercept: bool` (default true) + `pub intercept_scaling: F` (default 1.0) + `#[must_use]` builders. When fitting an intercept the design matrix is augmented with a penalized constant column = `intercept_scaling`, and `intercept_ = intercept_scaling·w_last` (`_base.py:1188-1198,:1240-1245`); `intercept_scaling > 0` is validated. Pinned by `linear_svc_coef_parity` + module `test_fit_intercept_false_zero_intercept`/`test_invalid_intercept_scaling`. |
61//! | REQ-8 (dual param) | SHIPPED | `LinearSVC<F>` exposes `pub dual: DualMode` (default `Auto`) + `#[must_use] with_dual`. `fn resolve_dual` resolves `Auto→bool` (`_validate_dual_parameter`, `_classes.py:13-29`: `n<f`→prefer dual, else→prefer primal, with fallback) against `fn liblinear_solver_type` (the `_get_liblinear_solver_type` matrix, `_base.py:995-1018`), and `fn fit` validates the resolved combo (`hinge+dual=false`, `l1+dual=true`, `l1+hinge` all rejected → `FerroError::InvalidParameter`). R-DEV-7: the resolved `dual` is **observably immaterial for `penalty=l2`** — the l2 dual CD and l2 primal minimize the same `0.5·‖w‖² + C·Σ L` and reach the same `coef_`/`intercept_`, so `penalty=l2` keeps `fn solve_binary_dual` regardless of `dual`. Pinned by `test_unsupported_combinations_rejected` + `test_dual_auto_resolution`. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
62//! | REQ-9 (class_weight) | SHIPPED | `LinearSVC<F>` exposes `pub class_weight: ClassWeight<F>` (`None`/`Balanced`/`Explicit`, default `None`) + `#[must_use] with_class_weight`. `fn compute_class_weight` (mirroring `sklearn.utils.compute_class_weight`, `class_weight.py:63-81`, as called at `_base.py:1179`) expands per-class weights; `fn fit` scales `C` per class: binary `cp = C·weights[idx(classes[1])]`, `cn = C·weights[idx(classes[0])]` (`train_one(Cp=weighted_C[1], Cn=weighted_C[0])`, `linear.cpp:2543-2551`), OvR class `k` `cp = C·weights[k]`, `cn = C` base (the negative rest is UNWEIGHTED, `linear.cpp:2559-2571`). `SolverConfig` now carries `(cp, cn)`; `solve_binary_dual`/`solve_binary_l1r_l2` apply the per-sample `C_[i] = (y_i>0?cp:cn)` (`diag[i]`/`upper_bound[i]`/`C[i]`, `linear.cpp:843-858`,`:1504-1509`). When `cp == cn` (no class_weight) the math is identical to before (the 9 divergence pins stay green). Pinned by `test_class_weight_smoke in linear_svc.rs` (live oracle 8×2 imbalanced set, `squared_hinge,dual=True,C=1.0`: `None coef [[0.10056,0.15957]] int [-1.26346]`; `balanced coef [[0.09937,0.16666]] int [-1.21320]` weights `[0.6667,2.0]`; `{0:1,1:5} coef [[0.11059,0.17164]] int [-1.29547]`; ferrolearn within 1e-2). The rigorous oracle pin in `tests/divergence_linear_svc_fit.rs` is the critic's next step. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
63//! | REQ-10 (C-scaling convention) | SHIPPED | the `c / n_f` division is removed; the dual CD uses `upper_bound = C` (hinge) / `diag = 0.5/C` (squared_hinge), so `coef_` tracks `C` like liblinear. Pinned by `linear_svc_coef_c_dependence` (C=0.1 → `0.0784651864625997`, C=1.0 → `0.12835213611984458`). |
64//! | REQ-11 (n_iter_/n_features_in_ + param validation) | SHIPPED | `fn n_features_in` (returns the stored `n_features`, set by `_validate_data`, `_classes.py:302`) and `fn n_iter` (the max dual-CD outer-iteration count across the binary/OvR fits, `n_iter_ = n_iter_.max().item()`, `_classes.py:338`) on `FittedLinearSVC`; `fn fit` validates `tol > 0` (`Interval(Real, 0.0, None, closed="neither")`, `_classes.py:237`). Pinned by `linear_svc_attrs_and_tol_validation in tests/divergence_linear_svc_fit.rs` (#627). `n_features_in_` (oracle `2`) and the `tol <= 0` reject are exact; `n_iter_` is the documented shuffle-path RNG boundary (ferrolearn sweeps natural order, sklearn's liblinear shuffles `index` each sweep, cf. SGD), so the pin bounds `n_iter` in `[1, max_iter]` rather than exact-matching. |
65//! | REQ-12 (ferray substrate) | NOT-STARTED | open prereq blocker #628. Imports `ndarray`, not `ferray-core`/`ferray::linalg` (R-SUBSTRATE). |
66//! | REQ-13 (non-finite input rejected) | SHIPPED | `fn fit` rejects any NaN/+/-inf in X BEFORE the dual coordinate-descent solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`svm/_classes.py:302`, the liblinear `_fit_liblinear` path; cf. `svm/_base.py:190`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. `y` is `Array1<usize>` (integer labels), finite by type, and ferrolearn's `LinearSVC::fit` takes no `sample_weight` argument, so X is the only runtime check. The guard sits ahead of both the OvR and Crammer-Singer dispatch, so it covers every solver path. `.iter().any(|v| !v.is_finite())` catches NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `LinearSVC().fit` raises `ValueError` for NaN/+inf/-inf in X (`tests/divergence_linear_nonfinite_batch4.rs::linsvc_*`). Non-test consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). (#2263) |
67
68use ferrolearn_core::error::FerroError;
69use ferrolearn_core::introspection::{HasClasses, HasCoefficients};
70use ferrolearn_core::traits::{Fit, Predict};
71use ndarray::{Array1, Array2, ScalarOperand};
72use num_traits::Float;
73
74/// Penalty (regularizer) norm for [`LinearSVC`].
75///
76/// Mirrors `sklearn.svm.LinearSVC`'s `penalty` parameter
77/// (`sklearn/svm/_classes.py:51-54`): `'l2'` (default) is the standard SVC
78/// `0.5·‖w‖²` regularizer; `'l1'` is the `‖w‖₁` regularizer, which yields a
79/// sparse `coef_`. The penalty interacts with [`LinearSVCLoss`] / [`DualMode`]
80/// via liblinear's solver-selection matrix (`_get_liblinear_solver_type`,
81/// `_base.py:1011-1018`): `'l1'` is only supported with `squared_hinge` +
82/// `dual=False` (solver type 5).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum LinearSVCPenalty {
85    /// `‖w‖₁` regularizer — sparse `coef_`. Only valid with `squared_hinge` +
86    /// `dual=false` (liblinear solver type 5, `_base.py:1014`).
87    L1,
88    /// `0.5·‖w‖²` regularizer (default), the standard SVC penalty.
89    #[default]
90    L2,
91}
92
93/// Dual / primal optimization-problem selector for [`LinearSVC`].
94///
95/// Mirrors `sklearn.svm.LinearSVC`'s `dual` parameter
96/// (`sklearn/svm/_classes.py:62-71`, default `"auto"`). `Auto` resolves to a
97/// concrete `bool` via `_validate_dual_parameter` (`_classes.py:13-29`): when
98/// `n_samples < n_features` it prefers `dual=true` (falling back to `false` if
99/// that penalty×loss combination has no dual solver); otherwise it prefers
100/// `dual=false` (falling back to `true` if there is no primal solver, e.g.
101/// `hinge`). The resolved `dual` selects the liblinear solver type
102/// (`_get_liblinear_solver_type`, `_base.py:1011-1018`).
103///
104/// Under R-DEV-7 the resolved `dual` is **observably immaterial for
105/// `penalty=l2`**: the l2 dual coordinate descent and the l2 primal both
106/// minimize the same strongly convex `0.5·‖w‖² + C·Σ L` and reach the same
107/// `coef_`/`intercept_`. It is load-bearing only for the unsupported-combination
108/// rejects and for selecting the genuinely different `l1` primal solver.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum DualMode {
111    /// Resolve to `true`/`false` automatically via `_validate_dual_parameter`
112    /// (`_classes.py:13-29`); the default.
113    #[default]
114    Auto,
115    /// Solve the dual optimization problem.
116    True,
117    /// Solve the primal optimization problem.
118    False,
119}
120
121/// Multiclass strategy for [`LinearSVC`].
122///
123/// Mirrors `sklearn.svm.LinearSVC`'s `multi_class` parameter
124/// (`sklearn/svm/_classes.py:239`, constraint `{"ovr", "crammer_singer"}`,
125/// default `"ovr"`). `Ovr` trains one binary classifier per class (the default,
126/// using the penalty/loss/dual solver matrix); `CrammerSinger` runs the joint
127/// Crammer-Singer multiclass SVM solver (`Solver_MCSVM_CS`,
128/// `liblinear/linear.cpp:493-787`, solver type 4).
129///
130/// When `CrammerSinger` is selected, `_get_liblinear_solver_type` returns 4
131/// **regardless of penalty/loss/dual** (`_base.py:1017,1020-1021`), so the
132/// penalty/loss/dual parameters are ignored and the joint solver runs.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum MultiClass {
135    /// One-vs-rest: one binary sub-problem per class (the default).
136    #[default]
137    Ovr,
138    /// Joint Crammer-Singer multiclass SVM (`Solver_MCSVM_CS`, solver type 4,
139    /// `linear.cpp:493-787`). Ignores penalty/loss/dual (`_base.py:1017`).
140    CrammerSinger,
141}
142
143/// Loss function for [`LinearSVC`].
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum LinearSVCLoss {
146    /// Standard hinge loss: `max(0, 1 - y * f(x))`. liblinear solver
147    /// `L2R_L1LOSS_SVC_DUAL` (type 3): box `0 <= alpha <= C`, `diag = 0`.
148    Hinge,
149    /// Squared hinge loss: `max(0, 1 - y * f(x))^2` (default). liblinear solver
150    /// `L2R_L2LOSS_SVC_DUAL` (type 1): box `0 <= alpha <= +inf`,
151    /// `diag = 0.5 / C`.
152    SquaredHinge,
153}
154
155/// Per-class weighting strategy for [`LinearSVC`].
156///
157/// Mirrors `sklearn.svm.LinearSVC`'s `class_weight` parameter
158/// (`sklearn/svm/_classes.py:118-124`, constraint `{None, dict, 'balanced'}`):
159/// it scales the inverse-regularization `C` per class so the effective penalty
160/// for class `i` is `class_weight[i]·C` (`compute_class_weight`,
161/// `sklearn/svm/_base.py:1179`; `weighted_C[i] = C·class_weight[i]`,
162/// `liblinear/linear.cpp:2496-2507`). The expanded per-class weights are
163/// computed by [`compute_class_weight`] following
164/// `sklearn.utils.compute_class_weight` semantics
165/// (`sklearn/utils/class_weight.py:63-81`).
166///
167/// This mirrors `ferrolearn_linear::sgd::ClassWeight` for cross-estimator
168/// consistency, but is defined locally (no cross-import of `sgd` internals).
169#[derive(Debug, Clone, Default)]
170pub enum ClassWeight<F> {
171    /// Uniform weights (all classes weighted `1.0`). The default
172    /// (`class_weight=None`, `class_weight.py:63-65`).
173    #[default]
174    None,
175    /// Balanced weights `n_samples / (n_classes · count_c)` per class `c`,
176    /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
177    /// (`class_weight.py:66-74`).
178    Balanced,
179    /// Explicit class-label -> weight map. Classes absent from the map default
180    /// to `1.0`, matching the dict branch of `compute_class_weight`
181    /// (`class_weight.py:75-81`).
182    Explicit(Vec<(usize, F)>),
183}
184
185/// Compute the expanded per-class weight vector aligned to `classes`
186/// (sorted ascending, matching sklearn's `classes_ = np.unique(y)`).
187///
188/// Faithful to `sklearn.utils.compute_class_weight`
189/// (`sklearn/utils/class_weight.py:63-81`), as called by `_fit_liblinear`
190/// (`compute_class_weight(class_weight, classes=classes_, y=y)`,
191/// `sklearn/svm/_base.py:1179`):
192/// - `None` -> all `1.0` (`:63-65`).
193/// - `Balanced` -> `n_samples / (n_classes · count_c)` per class `c`,
194///   where `count_c` is the number of samples with label `c` (`:66-74`).
195/// - `Explicit(map)` -> `1.0` default, overridden by the map entries matched by
196///   class label (`:75-81`).
197///
198/// `classes` is the sorted unique label set; `y` is the per-sample label array.
199/// Mirrors `ferrolearn_linear::sgd::compute_class_weight` exactly.
200fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
201    match cw {
202        ClassWeight::None => vec![F::one(); classes.len()],
203        ClassWeight::Balanced => {
204            // `recip_freq = len(y) / (n_classes * bincount(y_ind))`
205            // (`class_weight.py:73`), indexed per class.
206            let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
207            let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
208            classes
209                .iter()
210                .map(|&c| {
211                    let count = y.iter().filter(|&&label| label == c).count();
212                    let count_f = F::from(count).unwrap_or_else(F::one);
213                    if count_f > F::zero() {
214                        n_samples / (n_classes * count_f)
215                    } else {
216                        F::one()
217                    }
218                })
219                .collect()
220        }
221        ClassWeight::Explicit(map) => classes
222            .iter()
223            .map(|&c| {
224                map.iter()
225                    .find(|(label, _)| *label == c)
226                    .map_or_else(F::one, |(_, w)| *w)
227            })
228            .collect(),
229    }
230}
231
232/// Confidence scores returned by [`FittedLinearSVC::decision_function`].
233///
234/// Mirrors `sklearn.svm.LinearSVC.decision_function`
235/// (`LinearClassifierMixin.decision_function`,
236/// `sklearn/linear_model/_base.py:341-365`): the binary case collapses the
237/// single-column score matrix to a 1-D `(n_samples,)` array
238/// (`return xp.reshape(scores, (-1,)) if scores.shape[1] == 1 else scores`,
239/// `_base.py:365`), the multiclass case returns `(n_samples, n_classes)`.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum DecisionScores<F> {
242    /// Binary scores `X · w + b` for the positive class (`classes_[1]`), shape
243    /// `(n_samples,)`. `> 0` predicts `classes_[1]`.
244    Binary(Array1<F>),
245    /// One-vs-rest scores, shape `(n_samples, n_classes)`. The argmax of each
246    /// row agrees with [`Predict`].
247    Multiclass(Array2<F>),
248}
249
250impl<F: Clone> DecisionScores<F> {
251    /// Number of samples scored (the leading axis length in both variants).
252    #[must_use]
253    pub fn n_samples(&self) -> usize {
254        match self {
255            DecisionScores::Binary(v) => v.len(),
256            DecisionScores::Multiclass(m) => m.nrows(),
257        }
258    }
259
260    /// Borrow the binary 1-D scores, if this is the binary case.
261    #[must_use]
262    pub fn as_binary(&self) -> Option<&Array1<F>> {
263        match self {
264            DecisionScores::Binary(v) => Some(v),
265            DecisionScores::Multiclass(_) => None,
266        }
267    }
268
269    /// Borrow the multiclass `(n_samples, n_classes)` scores, if this is the
270    /// multiclass case.
271    #[must_use]
272    pub fn as_multiclass(&self) -> Option<&Array2<F>> {
273        match self {
274            DecisionScores::Multiclass(m) => Some(m),
275            DecisionScores::Binary(_) => None,
276        }
277    }
278}
279
280/// Linear Support Vector Classifier (liblinear dual CD).
281///
282/// Solves the L2-regularized hinge or squared-hinge objective
283/// `0.5*||w||^2 + C * sum_i L(y_i, w.x_i)` via liblinear's dual coordinate
284/// descent. Supports binary and multiclass (one-vs-rest) classification.
285/// Mirrors `sklearn.svm.LinearSVC`.
286///
287/// # Type Parameters
288///
289/// - `F`: The floating-point type (`f32` or `f64`).
290#[derive(Debug, Clone)]
291pub struct LinearSVC<F> {
292    /// Inverse regularization strength. Larger values allow more
293    /// misclassification. Must be strictly positive.
294    pub c: F,
295    /// Maximum number of dual coordinate descent iterations.
296    pub max_iter: usize,
297    /// Convergence tolerance on the projected-gradient span.
298    pub tol: F,
299    /// Loss function to use.
300    pub loss: LinearSVCLoss,
301    /// Regularizer norm (`l1` / `l2`). Default `l2`. `l1` is only supported with
302    /// `squared_hinge` + `dual=false` (liblinear solver type 5,
303    /// `_get_liblinear_solver_type`, `_base.py:1014`).
304    pub penalty: LinearSVCPenalty,
305    /// Dual / primal selector. Default `Auto`, resolved via
306    /// `_validate_dual_parameter` (`_classes.py:13-29`). Observably immaterial
307    /// for `penalty=l2` (R-DEV-7 dual-invariance), load-bearing for the
308    /// unsupported-combination rejects and the `l1` primal solver.
309    pub dual: DualMode,
310    /// Whether to fit an intercept. When `true`, the design matrix is augmented
311    /// with a synthetic constant column equal to [`intercept_scaling`]; the
312    /// augmented weight is penalized like any feature (liblinear convention).
313    ///
314    /// [`intercept_scaling`]: Self::intercept_scaling
315    pub fit_intercept: bool,
316    /// Value of the synthetic intercept feature when [`fit_intercept`] is
317    /// `true`. Must be strictly positive. `intercept_ = intercept_scaling *
318    /// w_last`.
319    ///
320    /// [`fit_intercept`]: Self::fit_intercept
321    pub intercept_scaling: F,
322    /// Per-class scaling of `C`. Default [`ClassWeight::None`] (all classes
323    /// weighted `1.0`). The effective penalty for class `i` is
324    /// `class_weight[i]·C` (`compute_class_weight`, `_base.py:1179`;
325    /// `weighted_C[i] = C·class_weight[i]`, `linear.cpp:2496-2507`).
326    pub class_weight: ClassWeight<F>,
327    /// Multiclass strategy. Default [`MultiClass::Ovr`] (one-vs-rest).
328    /// [`MultiClass::CrammerSinger`] runs the joint Crammer-Singer solver,
329    /// ignoring penalty/loss/dual (`_base.py:1017`, `_classes.py:239`).
330    pub multi_class: MultiClass,
331}
332
333impl<F: Float> LinearSVC<F> {
334    /// Create a new `LinearSVC` with scikit-learn's default settings.
335    ///
336    /// Defaults (matching `sklearn.svm.LinearSVC`, `_classes.py`):
337    /// `C = 1.0`, `max_iter = 1000`, `tol = 1e-4`, `loss = SquaredHinge`,
338    /// `penalty = L2`, `dual = Auto`, `fit_intercept = true`,
339    /// `intercept_scaling = 1.0`, `class_weight = None`, `multi_class = Ovr`.
340    #[must_use]
341    pub fn new() -> Self {
342        // 1e-4/1.0 are exactly representable in f32/f64; the defensive fallback
343        // for `from(1e-4)` is never taken (no `.unwrap()` in lib code).
344        let one = F::one();
345        Self {
346            c: one,
347            max_iter: 1000,
348            tol: F::from(1e-4).unwrap_or_else(|| {
349                let ten = F::from(10).unwrap_or(one);
350                one / (ten * ten * ten * ten)
351            }),
352            loss: LinearSVCLoss::SquaredHinge,
353            penalty: LinearSVCPenalty::L2,
354            dual: DualMode::Auto,
355            fit_intercept: true,
356            intercept_scaling: one,
357            class_weight: ClassWeight::None,
358            multi_class: MultiClass::Ovr,
359        }
360    }
361
362    /// Set the regularization parameter C.
363    #[must_use]
364    pub fn with_c(mut self, c: F) -> Self {
365        self.c = c;
366        self
367    }
368
369    /// Set the maximum number of iterations.
370    #[must_use]
371    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
372        self.max_iter = max_iter;
373        self
374    }
375
376    /// Set the convergence tolerance.
377    #[must_use]
378    pub fn with_tol(mut self, tol: F) -> Self {
379        self.tol = tol;
380        self
381    }
382
383    /// Set the loss function.
384    #[must_use]
385    pub fn with_loss(mut self, loss: LinearSVCLoss) -> Self {
386        self.loss = loss;
387        self
388    }
389
390    /// Set the penalty (regularizer) norm (sklearn `penalty`). `l1` requires
391    /// `squared_hinge` + `dual=false` (`_base.py:1014`).
392    #[must_use]
393    pub fn with_penalty(mut self, penalty: LinearSVCPenalty) -> Self {
394        self.penalty = penalty;
395        self
396    }
397
398    /// Set the dual / primal selector (sklearn `dual`). `Auto` (default)
399    /// resolves via `_validate_dual_parameter` (`_classes.py:13-29`).
400    #[must_use]
401    pub fn with_dual(mut self, dual: DualMode) -> Self {
402        self.dual = dual;
403        self
404    }
405
406    /// Set whether to fit an intercept (sklearn `fit_intercept`).
407    #[must_use]
408    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
409        self.fit_intercept = fit_intercept;
410        self
411    }
412
413    /// Set the intercept scaling (sklearn `intercept_scaling`). Must be
414    /// strictly positive when `fit_intercept` is `true`.
415    #[must_use]
416    pub fn with_intercept_scaling(mut self, intercept_scaling: F) -> Self {
417        self.intercept_scaling = intercept_scaling;
418        self
419    }
420
421    /// Set the per-class `C` scaling (sklearn `class_weight`,
422    /// `_classes.py:118-124`). [`ClassWeight::None`] (default) leaves every
423    /// class at `1.0`; [`ClassWeight::Balanced`] uses
424    /// `n_samples / (n_classes · count_c)`; [`ClassWeight::Explicit`] takes a
425    /// `(label, weight)` map (unlisted classes default to `1.0`).
426    #[must_use]
427    pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
428        self.class_weight = class_weight;
429        self
430    }
431
432    /// Set the multiclass strategy (sklearn `multi_class`, `_classes.py:239`).
433    /// [`MultiClass::Ovr`] (default) trains one-vs-rest binary classifiers;
434    /// [`MultiClass::CrammerSinger`] runs the joint Crammer-Singer solver
435    /// (ignoring penalty/loss/dual, `_base.py:1017`).
436    #[must_use]
437    pub fn with_multi_class(mut self, multi_class: MultiClass) -> Self {
438        self.multi_class = multi_class;
439        self
440    }
441}
442
443impl<F: Float> Default for LinearSVC<F> {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449/// Fitted Linear Support Vector Classifier.
450///
451/// Stores the learned weight vectors, intercepts, and class labels.
452/// For binary classification a single weight vector is stored; for
453/// multiclass, one per class (one-vs-rest).
454#[derive(Debug, Clone)]
455pub struct FittedLinearSVC<F> {
456    /// Weight vectors: one per binary sub-problem.
457    /// Binary: `[w]`, Multiclass: `[w_0, w_1, ..., w_{k-1}]`.
458    weight_vectors: Vec<Array1<F>>,
459    /// Intercept for each sub-problem.
460    intercepts: Vec<F>,
461    /// Sorted unique class labels.
462    classes: Vec<usize>,
463    /// Whether this is a binary problem.
464    is_binary: bool,
465    /// Number of features.
466    n_features: usize,
467    /// Maximum dual-CD outer-iteration count across sub-problem fits
468    /// (`n_iter_ = n_iter_.max().item()`, `_classes.py:338`).
469    n_iter: usize,
470}
471
472impl<F: Float> FittedLinearSVC<F> {
473    /// Returns the weight vectors (one per binary sub-problem).
474    #[must_use]
475    pub fn weight_vectors(&self) -> &[Array1<F>] {
476        &self.weight_vectors
477    }
478
479    /// Returns the intercepts (one per binary sub-problem).
480    #[must_use]
481    pub fn intercepts(&self) -> &[F] {
482        &self.intercepts
483    }
484
485    /// Number of features seen during fit (`n_features_in_`).
486    ///
487    /// Mirrors sklearn's `n_features_in_`, set by `_validate_data`
488    /// (`sklearn/svm/_classes.py:302`); equals `X.ncols()`.
489    #[must_use]
490    pub fn n_features_in(&self) -> usize {
491        self.n_features
492    }
493
494    /// Maximum number of dual coordinate-descent outer iterations across the
495    /// (binary or one-vs-rest) sub-problem fits.
496    ///
497    /// Mirrors sklearn's `n_iter_ = n_iter_.max().item()`
498    /// (`sklearn/svm/_classes.py:338`). The exact value is shuffle-path
499    /// dependent (sklearn's liblinear shuffles the active index each sweep;
500    /// ferrolearn sweeps natural order), so it is bounded in `[1, max_iter]`
501    /// rather than exact-matching the oracle.
502    #[must_use]
503    pub fn n_iter(&self) -> usize {
504        self.n_iter
505    }
506}
507
508impl<F: Float + ScalarOperand + Send + Sync + 'static> FittedLinearSVC<F> {
509    /// Raw signed distance from the decision boundary. Mirrors sklearn
510    /// `LinearSVC.decision_function`.
511    ///
512    /// Binary: [`DecisionScores::Binary`] of shape `(n_samples,)` containing
513    /// `X @ w + b` for the positive class (`classes_[1]`); sklearn ravels the
514    /// single-column score to 1-D (`linear_model/_base.py:365`).
515    /// Multiclass: [`DecisionScores::Multiclass`] of shape
516    /// `(n_samples, n_classes)` of one-vs-rest scores; the argmax of each row
517    /// agrees with [`Predict`].
518    ///
519    /// # Errors
520    ///
521    /// Returns [`FerroError::ShapeMismatch`] if the number of features
522    /// does not match the fitted model.
523    pub fn decision_function(&self, x: &Array2<F>) -> Result<DecisionScores<F>, FerroError> {
524        let n_features = x.ncols();
525        if n_features != self.n_features {
526            return Err(FerroError::ShapeMismatch {
527                expected: vec![self.n_features],
528                actual: vec![n_features],
529                context: "number of features must match fitted model".into(),
530            });
531        }
532        let n_samples = x.nrows();
533        if self.is_binary {
534            // sklearn collapses the single-column binary score matrix to a 1-D
535            // (n_samples,) array (`linear_model/_base.py:365`).
536            let scores = x.dot(&self.weight_vectors[0]) + self.intercepts[0];
537            Ok(DecisionScores::Binary(scores))
538        } else {
539            let n_classes = self.classes.len();
540            let mut out = Array2::<F>::zeros((n_samples, n_classes));
541            for c in 0..n_classes {
542                for i in 0..n_samples {
543                    out[[i, c]] = x.row(i).dot(&self.weight_vectors[c]) + self.intercepts[c];
544                }
545            }
546            Ok(DecisionScores::Multiclass(out))
547        }
548    }
549}
550
551/// Resolved per-fit solver configuration passed to [`solve_binary_dual`]
552/// (groups the dual-CD knobs to keep the solver signature small).
553#[derive(Debug, Clone, Copy)]
554struct SolverConfig<F> {
555    /// Per-sample penalty for the positive (`y_i > 0`) group: `Cp = C·w[+]`
556    /// (`train_one(Cp, Cn)`, `linear.cpp:2543-2571`; `C_[i] = (y_i>0 ? Cp :
557    /// Cn)`, `linear.cpp:843-858`, `:1504-1509`).
558    cp: F,
559    /// Per-sample penalty for the negative (`y_i <= 0`) group: `Cn = C·w[-]`
560    /// (binary) or the base `C` (multiclass OvR; the negative group is the
561    /// unweighted rest, `linear.cpp:2559-2571`).
562    cn: F,
563    /// Maximum dual-CD outer iterations.
564    max_iter: usize,
565    /// Projected-gradient span stopping tolerance.
566    tol: F,
567    /// Hinge / squared-hinge loss selector.
568    loss: LinearSVCLoss,
569    /// Whether the design matrix is augmented with a penalized bias column.
570    fit_intercept: bool,
571    /// Value of the synthetic bias column when `fit_intercept` is set.
572    intercept_scaling: F,
573}
574
575/// Solve a single binary L2-SVM via liblinear's dual coordinate descent.
576///
577/// Minimizes `0.5 * ||w||^2 + C * sum_i L(y_i, w.x_i)` (NO `1/n` averaging,
578/// matching `sklearn/svm/_base.py` `_fit_liblinear`) with `y_i ∈ {-1, +1}`.
579/// The augmented weight vector has length `w_size`; when `fit_intercept` is
580/// set, `w_size = n_features + 1` and the trailing weight multiplies the
581/// synthetic constant column `intercept_scaling` (penalized like any feature).
582///
583/// This is liblinear's `solve_l2r_l1l2_svc` (`linear.cpp:819`): the dual is
584///
585/// ```text
586///   min_alpha  0.5 * alpha^T (Q + diag) alpha  -  e^T alpha,
587///     s.t.     0 <= alpha_i <= U,
588/// ```
589///
590/// where `Q_ij = y_i y_j x_i.x_j`, `w = sum_i alpha_i y_i x_i`,
591/// `QD[i] = diag + ||x_i||^2`. **hinge** (`L2R_L1LOSS_SVC_DUAL`): `diag = 0`,
592/// `U = C` (`linear.cpp:852-858`). **squared_hinge** (`L2R_L2LOSS_SVC_DUAL`):
593/// `diag = 0.5/C`, `U = +inf` (`linear.cpp:849-850`).
594///
595/// Returns `(w_augmented, n_iter, converged)`.
596fn solve_binary_dual<F: Float + 'static>(
597    x: &Array2<F>,
598    y_signed: &Array1<F>,
599    cfg: &SolverConfig<F>,
600) -> (Vec<F>, usize, bool) {
601    let SolverConfig {
602        cp,
603        cn,
604        max_iter,
605        tol,
606        loss,
607        fit_intercept,
608        intercept_scaling,
609    } = *cfg;
610
611    let (n_samples, n_features) = x.dim();
612    let w_size = if fit_intercept {
613        n_features + 1
614    } else {
615        n_features
616    };
617
618    let inf = F::infinity();
619    let two = F::one() + F::one();
620    let half = F::one() / two;
621    let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
622
623    // Per-sample penalty `C_[i] = (y_i > 0 ? Cp : Cn)` (`linear.cpp:843-858`,
624    // `GETI(i) ≡ i`); `class_weight` makes Cp/Cn differ. Per-sample diag /
625    // upper_bound follow the solver type: squared_hinge → `diag[i] = 0.5/C_[i]`,
626    // `U[i] = +inf`; hinge → `diag[i] = 0`, `U[i] = C_[i]`.
627    let mut diag = vec![F::zero(); n_samples];
628    let mut upper_bound = vec![inf; n_samples];
629    for i in 0..n_samples {
630        let c_i = if y_signed[i] > F::zero() { cp } else { cn };
631        match loss {
632            LinearSVCLoss::Hinge => {
633                diag[i] = F::zero();
634                upper_bound[i] = c_i;
635            }
636            LinearSVCLoss::SquaredHinge => {
637                diag[i] = half / c_i;
638                upper_bound[i] = inf;
639            }
640        }
641    }
642
643    // QD[i] = diag[i] + ||x_i||^2 (including the augmented bias column).
644    let mut qd = vec![F::zero(); n_samples];
645    for (i, qd_i) in qd.iter_mut().enumerate() {
646        let mut acc = diag[i];
647        let row = x.row(i);
648        for &v in row.iter() {
649            acc = acc + v * v;
650        }
651        if fit_intercept {
652            acc = acc + intercept_scaling * intercept_scaling;
653        }
654        *qd_i = acc;
655    }
656
657    let mut alpha = vec![F::zero(); n_samples];
658    let mut w = vec![F::zero(); w_size];
659    // alpha starts at 0 so w starts at 0; nothing to accumulate.
660
661    let mut index: Vec<usize> = (0..n_samples).collect();
662    let mut active_size = n_samples;
663    let mut pgmax_old = inf;
664    let mut pgmin_old = -inf;
665
666    // w . x_i over the (augmented) design matrix.
667    let dot_w_xi = |w: &[F], i: usize| -> F {
668        let mut acc = F::zero();
669        let row = x.row(i);
670        for (j, &v) in row.iter().enumerate() {
671            acc = acc + v * w[j];
672        }
673        if fit_intercept {
674            acc = acc + intercept_scaling * w[n_features];
675        }
676        acc
677    };
678
679    let mut n_iter: usize = 0;
680    let mut converged = false;
681
682    // liblinear shuffles `index` each sweep; the minimizer is unique so order
683    // only affects the path, not the limit. We sweep in natural order for
684    // determinism (no RNG), reaching the same converged optimum.
685    for iter in 0..max_iter {
686        n_iter = iter + 1;
687        let mut pgmax_new = -inf;
688        let mut pgmin_new = inf;
689
690        let mut s = 0;
691        while s < active_size {
692            let i = index[s];
693            let yi = y_signed[i];
694
695            // G = y_i*(w.x_i) - 1 + diag_i*alpha_i (`linear.cpp:909-921`).
696            let g = yi * dot_w_xi(&w, i) - F::one() + diag[i] * alpha[i];
697
698            // Projected gradient + shrinking (`linear.cpp:923-949`).
699            let mut pg = F::zero();
700            if alpha[i] == F::zero() {
701                if g > pgmax_old {
702                    active_size -= 1;
703                    index.swap(s, active_size);
704                    continue; // re-process the swapped-in element at `s`
705                } else if g < F::zero() {
706                    pg = g;
707                }
708            } else if alpha[i] == upper_bound[i] {
709                if g < pgmin_old {
710                    active_size -= 1;
711                    index.swap(s, active_size);
712                    continue;
713                } else if g > F::zero() {
714                    pg = g;
715                }
716            } else {
717                pg = g;
718            }
719
720            if pg > pgmax_new {
721                pgmax_new = pg;
722            }
723            if pg < pgmin_new {
724                pgmin_new = pg;
725            }
726
727            if pg.abs() > tiny {
728                let alpha_old = alpha[i];
729                // alpha_i <- clamp(alpha_i - G/QD[i], 0, U) (`linear.cpp:957`).
730                let mut new_alpha = alpha[i] - g / qd[i];
731                if new_alpha < F::zero() {
732                    new_alpha = F::zero();
733                } else if new_alpha > upper_bound[i] {
734                    new_alpha = upper_bound[i];
735                }
736                alpha[i] = new_alpha;
737                let d = (alpha[i] - alpha_old) * yi;
738                if d != F::zero() {
739                    let row = x.row(i);
740                    for (j, &v) in row.iter().enumerate() {
741                        w[j] = w[j] + d * v;
742                    }
743                    if fit_intercept {
744                        w[n_features] = w[n_features] + d * intercept_scaling;
745                    }
746                }
747            }
748
749            s += 1;
750        }
751
752        // Stopping: PGmax_new - PGmin_new <= tol on the full set
753        // (`linear.cpp:972-990`). Absolute tol (the dual SVC solver receives
754        // `param->eps` directly, `linear.cpp:2364`).
755        if pgmax_new - pgmin_new <= tol {
756            if active_size == n_samples {
757                converged = true;
758                break;
759            }
760            active_size = n_samples;
761            pgmax_old = inf;
762            pgmin_old = -inf;
763            continue;
764        }
765
766        pgmax_old = pgmax_new;
767        pgmin_old = pgmin_new;
768        if pgmax_old <= F::zero() {
769            pgmax_old = inf;
770        }
771        if pgmin_old >= F::zero() {
772            pgmin_old = -inf;
773        }
774    }
775
776    (w, n_iter, converged)
777}
778
779/// Resolve `(penalty, loss, dual)` to a liblinear solver "magic number" for the
780/// one-vs-rest `multi_class='ovr'` case, mirroring `_get_liblinear_solver_type`
781/// (`sklearn/svm/_base.py:995-1049`). Returns `Err` for the unsupported
782/// combinations sklearn raises `ValueError` on.
783///
784/// The supported (`multi_class='ovr'`) entries of `_solver_type_dict`
785/// (`_base.py:1013-1014`):
786///
787/// ```text
788///   hinge:         { l2: { dual=true: 3 } }
789///   squared_hinge: { l1: { dual=false: 5 }, l2: { dual=false: 2, dual=true: 1 } }
790/// ```
791///
792/// (`crammer_singer` = 4 is REQ-6/#623, out of scope; this function assumes the
793/// existing `'ovr'` multi-class.) The `Err` strings mirror sklearn's
794/// `error_string` (`_base.py:1033-1043`).
795fn liblinear_solver_type(
796    penalty: LinearSVCPenalty,
797    loss: LinearSVCLoss,
798    dual: bool,
799) -> Result<u8, FerroError> {
800    match (loss, penalty, dual) {
801        // hinge: { l2: { dual=true: 3 } }
802        (LinearSVCLoss::Hinge, LinearSVCPenalty::L2, true) => Ok(3),
803        (LinearSVCLoss::Hinge, LinearSVCPenalty::L2, false) => Err(FerroError::InvalidParameter {
804            name: "dual".into(),
805            reason: "The combination of penalty='l2' and loss='hinge' are not \
806                         supported when dual=false"
807                .into(),
808        }),
809        // hinge + l1: penalty has no entry under `hinge` → combination unsupported.
810        (LinearSVCLoss::Hinge, LinearSVCPenalty::L1, _) => Err(FerroError::InvalidParameter {
811            name: "penalty".into(),
812            reason: "The combination of penalty='l1' and loss='hinge' is not supported".into(),
813        }),
814        // squared_hinge: { l1: { dual=false: 5 } }
815        (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L1, false) => Ok(5),
816        (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L1, true) => {
817            Err(FerroError::InvalidParameter {
818                name: "dual".into(),
819                reason: "The combination of penalty='l1' and loss='squared_hinge' are not \
820                         supported when dual=true"
821                    .into(),
822            })
823        }
824        // squared_hinge: { l2: { dual=false: 2, dual=true: 1 } }
825        (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L2, false) => Ok(2),
826        (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L2, true) => Ok(1),
827    }
828}
829
830/// Resolve the [`DualMode`] to a concrete `bool`, mirroring
831/// `_validate_dual_parameter` (`sklearn/svm/_classes.py:13-29`).
832///
833/// For [`DualMode::Auto`]: when `n_samples < n_features` try `dual=true` (fall
834/// back to `false` if that penalty×loss combination has no dual solver); else
835/// (`n_samples >= n_features`) try `dual=false` (fall back to `true` if there is
836/// no primal solver, e.g. `hinge`). Resolution is checked against
837/// [`liblinear_solver_type`] so it is automatically consistent with the
838/// solver-selection matrix.
839fn resolve_dual(
840    dual: DualMode,
841    penalty: LinearSVCPenalty,
842    loss: LinearSVCLoss,
843    n_samples: usize,
844    n_features: usize,
845) -> bool {
846    match dual {
847        DualMode::True => true,
848        DualMode::False => false,
849        DualMode::Auto => {
850            if n_samples < n_features {
851                // Prefer dual=true; fall back to false if unsupported.
852                liblinear_solver_type(penalty, loss, true).is_ok()
853            } else {
854                // Prefer dual=false; fall back to true if no primal solver.
855                liblinear_solver_type(penalty, loss, false).is_err()
856            }
857        }
858    }
859}
860
861/// Solve a single binary L1-regularized L2-loss (squared-hinge) SVM via
862/// liblinear's feature-major coordinate descent, `solve_l1r_l2_svc`
863/// (`sklearn/svm/src/liblinear/linear.cpp:1467`). Minimizes
864///
865/// ```text
866///   ‖w‖₁  +  C · Σ_i  max(0, 1 − y_i·(w·x_i))²
867/// ```
868///
869/// (the `l1`-penalty objective — a genuinely different, sparse optimum from the
870/// l2 dual). The augmented intercept column (value `intercept_scaling`) is
871/// appended when `fit_intercept`, penalized in `‖w‖₁` like any feature
872/// (`coef_ = w[:n_features]`, `intercept_ = intercept_scaling·w[n_features]`).
873///
874/// State (`linear.cpp:1488-1526`): `b[i] = 1 − y_i·(w·x_i)` (running residual),
875/// `xj_sq[j] = Σ_i C·(y_i·x_ij)²`. Per feature `j`:
876/// `G_loss = −2·Σ_{i: b[i]>0} C·(y_i·x_ij)·b[i]`,
877/// `H = max(2·Σ_{i: b[i]>0} C·(y_i·x_ij)², 1e-12)` (`linear.cpp:1542-1562`).
878/// `Gp = G_loss+1`, `Gn = G_loss−1`. Newton direction with soft-threshold
879/// (`linear.cpp:1589-1595`): `d = −Gp/H` if `Gp < H·w[j]`, `d = −Gn/H` if
880/// `Gn > H·w[j]`, else `d = −w[j]`. Then a backtracking line search
881/// (`sigma=0.01`, ≤20 steps, halving `d`, `linear.cpp:1600-1661`) updating
882/// `b[]`, then `w[j] += d`. Shrinking via `active_size`/`Gmax_old`
883/// (`linear.cpp:1567-1579, 1691-1705`); stop when
884/// `Gnorm1_new ≤ eps·Gnorm1_init` on the full active set (`linear.cpp:1691`).
885///
886/// `C[i] = (y_i > 0 ? Cp : Cn)` per-sample (`class_weight` scales `C` per class,
887/// `linear.cpp:1504-1509`; `Cp = C·w[+]`, `Cn = C·w[-]` binary / base `C`
888/// multiclass, `:2543-2571`). liblinear shuffles
889/// `index` each sweep (`bounded_rand_int`, `linear.cpp:1535`); ferrolearn sweeps
890/// NATURAL ORDER for determinism (no RNG) — the l1 optimum is unique so the
891/// limit is identical (the documented RNG-path boundary, as `solve_binary_dual`).
892/// We use `eps = tol` directly: liblinear scales `primal_solver_tol`
893/// (`linear.cpp:2321,2374`) but the unique optimum is `tol`-invariant at the
894/// limit (the test drives `tol=1e-10` + huge `max_iter`).
895///
896/// Returns `(w_augmented, n_iter, converged)`.
897#[allow(
898    clippy::too_many_lines,
899    reason = "faithful transcription of liblinear solve_l1r_l2_svc (linear.cpp:1467)"
900)]
901fn solve_binary_l1r_l2<F: Float + 'static>(
902    x: &Array2<F>,
903    y_signed: &Array1<F>,
904    cfg: &SolverConfig<F>,
905) -> (Vec<F>, usize, bool) {
906    let SolverConfig {
907        cp,
908        cn,
909        max_iter,
910        tol,
911        fit_intercept,
912        intercept_scaling,
913        ..
914    } = *cfg;
915
916    let (n_samples, n_features) = x.dim();
917
918    // Per-sample penalty `C[i] = (y_i > 0 ? Cp : Cn)` (`solve_l1r_l2_svc`,
919    // `linear.cpp:1504-1509`); `class_weight` makes Cp/Cn differ.
920    let c_of = |i: usize| -> F { if y_signed[i] > F::zero() { cp } else { cn } };
921    let w_size = if fit_intercept {
922        n_features + 1
923    } else {
924        n_features
925    };
926
927    let inf = F::infinity();
928    let two = F::one() + F::one();
929    let sigma = F::from(0.01).unwrap_or_else(|| F::one() / (two * two * two * two * two * two));
930    let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
931    let max_num_linesearch = 20usize;
932    let nl = F::from(n_samples).unwrap_or_else(F::one);
933
934    // `yx(i, j)` = y_i·x_ij over the augmented design matrix (the j-th feature
935    // column entry for sample i). liblinear stores `x->value *= y[ind]`
936    // (`linear.cpp:1520`); we recompute it lazily for determinism / clarity.
937    let yx = |i: usize, j: usize| -> F {
938        let yi = y_signed[i];
939        if j < n_features {
940            yi * x[[i, j]]
941        } else {
942            yi * intercept_scaling
943        }
944    };
945
946    // b[i] = 1 − y_i·(w·x_i). w starts at 0 so b starts at 1 (`linear.cpp:1500`).
947    let mut b = vec![F::one(); n_samples];
948    let mut w = vec![F::zero(); w_size];
949
950    // xj_sq[j] = Σ_i C[i]·(y_i·x_ij)² (`linear.cpp:1523`, per-sample C[i]).
951    let mut xj_sq = vec![F::zero(); w_size];
952    for (j, xj_sq_j) in xj_sq.iter_mut().enumerate() {
953        let mut acc = F::zero();
954        for i in 0..n_samples {
955            let val = yx(i, j);
956            acc = acc + c_of(i) * val * val;
957        }
958        *xj_sq_j = acc;
959    }
960
961    let mut index: Vec<usize> = (0..w_size).collect();
962    let mut active_size = w_size;
963    let mut gmax_old = inf;
964    let mut gnorm1_init = -F::one();
965
966    let mut n_iter: usize = 0;
967    let mut converged = false;
968
969    while n_iter < max_iter {
970        let mut gmax_new = F::zero();
971        let mut gnorm1_new = F::zero();
972
973        // liblinear shuffles `index[0..active_size]` here (`linear.cpp:1533-1537`);
974        // ferrolearn sweeps natural order (no RNG); the unique optimum is
975        // unchanged at the limit.
976
977        let mut s = 0;
978        while s < active_size {
979            let j = index[s];
980
981            // G_loss = −2·Σ_{i: b[i]>0} C·(y_i·x_ij)·b[i];
982            // H = 2·Σ_{i: b[i]>0} C·(y_i·x_ij)² (`linear.cpp:1542-1561`).
983            let mut g_loss = F::zero();
984            let mut h = F::zero();
985            for (i, &bi) in b.iter().enumerate() {
986                if bi > F::zero() {
987                    let val = yx(i, j);
988                    let tmp = c_of(i) * val;
989                    g_loss = g_loss - tmp * bi;
990                    h = h + tmp * val;
991                }
992            }
993            g_loss = g_loss * two;
994            let g = g_loss;
995            h = h * two;
996            if h < tiny {
997                h = tiny;
998            }
999
1000            let gp = g + F::one();
1001            let gn = g - F::one();
1002            let wj = w[j];
1003
1004            // Violation + shrinking (`linear.cpp:1564-1587`).
1005            let mut violation = F::zero();
1006            if wj == F::zero() {
1007                if gp < F::zero() {
1008                    violation = -gp;
1009                } else if gn > F::zero() {
1010                    violation = gn;
1011                } else if gp > gmax_old / nl && gn < -(gmax_old / nl) {
1012                    active_size -= 1;
1013                    index.swap(s, active_size);
1014                    continue; // re-process the swapped-in element at `s`
1015                }
1016            } else if wj > F::zero() {
1017                violation = gp.abs();
1018            } else {
1019                violation = gn.abs();
1020            }
1021
1022            if violation > gmax_new {
1023                gmax_new = violation;
1024            }
1025            gnorm1_new = gnorm1_new + violation;
1026
1027            // Newton direction with soft-threshold (`linear.cpp:1589-1595`).
1028            let mut d = if gp < h * wj {
1029                -gp / h
1030            } else if gn > h * wj {
1031                -gn / h
1032            } else {
1033                -wj
1034            };
1035
1036            if d.abs() < tiny {
1037                s += 1;
1038                continue;
1039            }
1040
1041            // Backtracking line search (`linear.cpp:1600-1661`).
1042            let mut delta = (wj + d).abs() - wj.abs() + g * d;
1043            let mut d_old = F::zero();
1044            let mut num_linesearch = 0usize;
1045            while num_linesearch < max_num_linesearch {
1046                let d_diff = d_old - d;
1047                let mut cond = (wj + d).abs() - wj.abs() - sigma * delta;
1048
1049                let appxcond = xj_sq[j] * d * d + g_loss * d + cond;
1050                if appxcond <= F::zero() {
1051                    for (i, bi) in b.iter_mut().enumerate() {
1052                        *bi = *bi + d_diff * yx(i, j);
1053                    }
1054                    break;
1055                }
1056
1057                let mut loss_old = F::zero();
1058                let mut loss_new = F::zero();
1059                if num_linesearch == 0 {
1060                    for (i, bi) in b.iter_mut().enumerate() {
1061                        if *bi > F::zero() {
1062                            loss_old = loss_old + c_of(i) * *bi * *bi;
1063                        }
1064                        let b_new = *bi + d_diff * yx(i, j);
1065                        *bi = b_new;
1066                        if b_new > F::zero() {
1067                            loss_new = loss_new + c_of(i) * b_new * b_new;
1068                        }
1069                    }
1070                } else {
1071                    for (i, bi) in b.iter_mut().enumerate() {
1072                        let b_new = *bi + d_diff * yx(i, j);
1073                        *bi = b_new;
1074                        if b_new > F::zero() {
1075                            loss_new = loss_new + c_of(i) * b_new * b_new;
1076                        }
1077                    }
1078                }
1079
1080                cond = cond + loss_new - loss_old;
1081                if cond <= F::zero() {
1082                    break;
1083                }
1084                d_old = d;
1085                d = d / two;
1086                delta = delta / two;
1087                num_linesearch += 1;
1088            }
1089
1090            w[j] = w[j] + d;
1091
1092            // Recompute b[] if the line search took the maximum steps
1093            // (`linear.cpp:1665-1682`).
1094            if num_linesearch >= max_num_linesearch {
1095                for bi in b.iter_mut() {
1096                    *bi = F::one();
1097                }
1098                for (jj, &wjj) in w.iter().enumerate() {
1099                    if wjj == F::zero() {
1100                        continue;
1101                    }
1102                    for (i, bi) in b.iter_mut().enumerate() {
1103                        *bi = *bi - wjj * yx(i, jj);
1104                    }
1105                }
1106            }
1107
1108            s += 1;
1109        }
1110
1111        if n_iter == 0 {
1112            gnorm1_init = gnorm1_new;
1113        }
1114        n_iter += 1;
1115
1116        // Stop when Gnorm1_new ≤ eps·Gnorm1_init on the full active set
1117        // (`linear.cpp:1691-1702`).
1118        if gnorm1_new <= tol * gnorm1_init {
1119            if active_size == w_size {
1120                converged = true;
1121                break;
1122            }
1123            active_size = w_size;
1124            gmax_old = inf;
1125            continue;
1126        }
1127
1128        gmax_old = gmax_new;
1129    }
1130
1131    (w, n_iter, converged)
1132}
1133
1134/// Solve the joint Crammer-Singer multiclass SVM, transcribing liblinear's
1135/// `Solver_MCSVM_CS` (`sklearn/svm/src/liblinear/linear.cpp:493-787`,
1136/// solver type 4, `_base.py:1017`).
1137///
1138/// Minimizes the Crammer-Singer objective over a single joint weight matrix
1139/// `w` flattened as `(w_size × nr_class)` with `w[feature*nr_class + m]`. The
1140/// dual variables `alpha[i*nr_class + m]` satisfy `Σ_m alpha[i,m] = 0`,
1141/// `alpha[i,m] <= C[i]` if `y_i == m` else `alpha[i,m] <= 0`. Per sample,
1142/// `C[i] = W[i] · weighted_C[y_i]` (`linear.cpp:521-522`); here `W[i] = 1` and
1143/// `weighted_C[c] = C · class_weight[c]` (`= C` when `class_weight=None`).
1144///
1145/// `y_class[i]` is the class index (`0..nr_class`, the sorted-`classes`
1146/// position) of sample `i`. When `fit_intercept`, the design matrix is augmented
1147/// with the constant column `intercept_scaling` at feature index `n_features`
1148/// (it IS part of `QD` and `w`, `linear.cpp:512`/`608-618` over the augmented
1149/// row).
1150///
1151/// State / sweep / shrinking (`linear.cpp:576-754`) is transcribed faithfully,
1152/// including per-sample shrinking (`active_size_i`, `alpha_index`) and the
1153/// two-level stopping (`eps_shrink = max(10·eps, 1)`). liblinear shuffles the
1154/// `index` set each sweep (`bounded_rand_int`, `linear.cpp:629`); ferrolearn
1155/// sweeps NATURAL ORDER (no RNG) — the Crammer-Singer optimum is unique, so the
1156/// limit is identical (the documented RNG/shrink-path boundary, as the dual/l1
1157/// solvers). `eps = tol` (the solver receives `param->eps` directly,
1158/// `linear.cpp:2535`).
1159///
1160/// Returns `(w_flat, n_iter, converged)` where `w_flat[feature*nr_class + m]`.
1161#[allow(
1162    clippy::too_many_lines,
1163    clippy::too_many_arguments,
1164    reason = "faithful transcription of liblinear Solver_MCSVM_CS (linear.cpp:493-787); \
1165              the args mirror the solver's ctor + Solve() inputs (prob/nr_class/weighted_C/\
1166              eps/max_iter/bias) and grouping them would obscure the 1:1 transcription"
1167)]
1168fn solve_crammer_singer<F: Float + 'static>(
1169    x: &Array2<F>,
1170    y_class: &[usize],
1171    nr_class: usize,
1172    weighted_c: &[F],
1173    max_iter: usize,
1174    tol: F,
1175    fit_intercept: bool,
1176    intercept_scaling: F,
1177) -> (Vec<F>, usize, bool) {
1178    let (l, n_features) = x.dim();
1179    let w_size = if fit_intercept {
1180        n_features + 1
1181    } else {
1182        n_features
1183    };
1184
1185    let inf = F::infinity();
1186    let ten = F::from(10).unwrap_or_else(|| {
1187        let two = F::one() + F::one();
1188        two + two + two + two + two
1189    });
1190    let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
1191
1192    // `C[i] = W[i] · weighted_C[y_i]`; `W[i] = 1` (`linear.cpp:521-522`).
1193    let c_per_sample: Vec<F> = y_class.iter().map(|&yi| weighted_c[yi]).collect();
1194
1195    // `x_val(i, feat)` over the augmented row (feature index `n_features` is the
1196    // constant intercept_scaling column when fit_intercept).
1197    let x_val = |i: usize, feat: usize| -> F {
1198        if feat < n_features {
1199            x[[i, feat]]
1200        } else {
1201            intercept_scaling
1202        }
1203    };
1204
1205    // alpha[i*nr_class + m], w[feature*nr_class + m] (`linear.cpp:580-602`).
1206    let mut alpha = vec![F::zero(); l * nr_class];
1207    let mut w = vec![F::zero(); w_size * nr_class];
1208
1209    // alpha_index[i*nr_class + m] = m; QD[i] = ||x_i||^2 over the augmented row;
1210    // active_size_i[i] = nr_class; y_index[i] = y_class[i] (`linear.cpp:603-622`).
1211    let mut alpha_index = vec![0usize; l * nr_class];
1212    let mut qd = vec![F::zero(); l];
1213    let mut active_size_i = vec![nr_class; l];
1214    let mut y_index = y_class.to_vec();
1215    let mut index: Vec<usize> = (0..l).collect();
1216    for i in 0..l {
1217        for m in 0..nr_class {
1218            alpha_index[i * nr_class + m] = m;
1219        }
1220        let mut acc = F::zero();
1221        for feat in 0..w_size {
1222            let v = x_val(i, feat);
1223            acc = acc + v * v;
1224        }
1225        qd[i] = acc;
1226    }
1227
1228    // Scratch buffers reused per sample (`B`, `G`, `alpha_new`,
1229    // `linear.cpp:518-519,581`).
1230    let mut b_buf = vec![F::zero(); nr_class];
1231    let mut g_buf = vec![F::zero(); nr_class];
1232    let mut alpha_new = vec![F::zero(); nr_class];
1233
1234    let mut active_size = l;
1235    // eps_shrink = max(10·eps, 1) (`linear.cpp:590`).
1236    let mut eps_shrink = (ten * tol).max(F::one());
1237    let mut start_from_all = true;
1238
1239    let mut iter = 0usize;
1240    let mut converged = false;
1241
1242    while iter < max_iter {
1243        let mut stopping = -inf;
1244
1245        // liblinear shuffles index[0..active_size] here (`linear.cpp:627-631`);
1246        // ferrolearn sweeps natural order (no RNG); the unique optimum is
1247        // unchanged at the limit.
1248
1249        let mut s = 0;
1250        while s < active_size {
1251            let i = index[s];
1252            let ai = qd[i];
1253
1254            if ai > F::zero() {
1255                let asi = active_size_i[i];
1256                // G[m] = (m==y_i ? 0 : 1) + w_{alpha_index[m]} · x_i
1257                // (`linear.cpp:641-653`).
1258                for g in g_buf.iter_mut().take(asi) {
1259                    *g = F::one();
1260                }
1261                if y_index[i] < asi {
1262                    g_buf[y_index[i]] = F::zero();
1263                }
1264                for feat in 0..w_size {
1265                    let xv = x_val(i, feat);
1266                    if xv == F::zero() {
1267                        continue;
1268                    }
1269                    let base = feat * nr_class;
1270                    for m in 0..asi {
1271                        let idx = alpha_index[i * nr_class + m];
1272                        g_buf[m] = g_buf[m] + w[base + idx] * xv;
1273                    }
1274                }
1275
1276                // minG over {alpha_i[idx]<0} ∪ {y_i if alpha_i[y_i]<C[i]};
1277                // maxG over all active m (`linear.cpp:655-666`).
1278                let mut min_g = inf;
1279                let mut max_g = -inf;
1280                for m in 0..asi {
1281                    let idx = alpha_index[i * nr_class + m];
1282                    if alpha[i * nr_class + idx] < F::zero() && g_buf[m] < min_g {
1283                        min_g = g_buf[m];
1284                    }
1285                    if g_buf[m] > max_g {
1286                        max_g = g_buf[m];
1287                    }
1288                }
1289                if y_index[i] < asi
1290                    && alpha[i * nr_class + y_class[i]] < c_per_sample[i]
1291                    && g_buf[y_index[i]] < min_g
1292                {
1293                    min_g = g_buf[y_index[i]];
1294                }
1295
1296                // Per-sample shrinking via be_shrunk (`linear.cpp:668-697`).
1297                let mut m = 0;
1298                while m < active_size_i[i] {
1299                    let idx_m = alpha_index[i * nr_class + m];
1300                    if cs_be_shrunk(
1301                        c_per_sample[i],
1302                        m,
1303                        y_index[i],
1304                        alpha[i * nr_class + idx_m],
1305                        g_buf[m],
1306                        min_g,
1307                    ) {
1308                        active_size_i[i] -= 1;
1309                        while active_size_i[i] > m {
1310                            let asi_top = active_size_i[i];
1311                            let idx_top = alpha_index[i * nr_class + asi_top];
1312                            if !cs_be_shrunk(
1313                                c_per_sample[i],
1314                                asi_top,
1315                                y_index[i],
1316                                alpha[i * nr_class + idx_top],
1317                                g_buf[asi_top],
1318                                min_g,
1319                            ) {
1320                                alpha_index.swap(i * nr_class + m, i * nr_class + asi_top);
1321                                g_buf.swap(m, asi_top);
1322                                if y_index[i] == asi_top {
1323                                    y_index[i] = m;
1324                                } else if y_index[i] == m {
1325                                    y_index[i] = asi_top;
1326                                }
1327                                break;
1328                            }
1329                            active_size_i[i] -= 1;
1330                        }
1331                    }
1332                    m += 1;
1333                }
1334
1335                if active_size_i[i] <= 1 {
1336                    active_size -= 1;
1337                    index.swap(s, active_size);
1338                    continue; // re-process the swapped-in element at `s`
1339                }
1340
1341                if max_g - min_g <= tiny {
1342                    s += 1;
1343                    continue;
1344                }
1345                stopping = stopping.max(max_g - min_g);
1346
1347                // B[m] = G[m] - Ai·alpha_i[idx] (`linear.cpp:704-705`).
1348                let asi = active_size_i[i];
1349                for m in 0..asi {
1350                    let idx = alpha_index[i * nr_class + m];
1351                    b_buf[m] = g_buf[m] - ai * alpha[i * nr_class + idx];
1352                }
1353
1354                cs_solve_sub_problem(ai, y_index[i], c_per_sample[i], asi, &b_buf, &mut alpha_new);
1355
1356                // Apply d = alpha_new[m] - alpha_i[idx] and update w
1357                // (`linear.cpp:708-728`).
1358                let mut d_nz: Vec<(usize, F)> = Vec::new();
1359                for m in 0..asi {
1360                    let idx = alpha_index[i * nr_class + m];
1361                    let d = alpha_new[m] - alpha[i * nr_class + idx];
1362                    alpha[i * nr_class + idx] = alpha_new[m];
1363                    if d.abs() >= tiny {
1364                        d_nz.push((idx, d));
1365                    }
1366                }
1367                if !d_nz.is_empty() {
1368                    for feat in 0..w_size {
1369                        let xv = x_val(i, feat);
1370                        if xv == F::zero() {
1371                            continue;
1372                        }
1373                        let base = feat * nr_class;
1374                        for &(idx, d) in &d_nz {
1375                            w[base + idx] = w[base + idx] + d * xv;
1376                        }
1377                    }
1378                }
1379            }
1380
1381            s += 1;
1382        }
1383
1384        iter += 1;
1385
1386        // Two-level stopping (`linear.cpp:738-753`).
1387        if stopping < eps_shrink {
1388            if stopping < tol && start_from_all {
1389                converged = true;
1390                break;
1391            }
1392            active_size = l;
1393            for asi in active_size_i.iter_mut() {
1394                *asi = nr_class;
1395            }
1396            eps_shrink = (eps_shrink / (F::one() + F::one())).max(tol);
1397            start_from_all = true;
1398        } else {
1399            start_from_all = false;
1400        }
1401    }
1402
1403    (w, iter, converged)
1404}
1405
1406/// `Solver_MCSVM_CS::be_shrunk` (`linear.cpp:566-574`): shrink class `m` of
1407/// sample `i` when its dual variable is at its bound and the gradient is below
1408/// `minG`. `bound = C[i]` if `m == yi` (the class index), else `0`.
1409fn cs_be_shrunk<F: Float>(c_yi: F, m: usize, yi: usize, alpha_i: F, g_m: F, min_g: F) -> bool {
1410    let bound = if m == yi { c_yi } else { F::zero() };
1411    alpha_i == bound && g_m < min_g
1412}
1413
1414/// `Solver_MCSVM_CS::solve_sub_problem` (`linear.cpp:541-564`): the per-sample
1415/// simplex-projection inner solve. `B[..active_i]` is the gradient offset
1416/// buffer; the result is written to `alpha_new[..active_i]`.
1417///
1418/// Clones `B → D`, adds `Ai·C_yi` to `D[yi]` (when `yi < active_i`), sorts `D`
1419/// DESCENDING, then finds the breakpoint `beta` and projects each coordinate:
1420/// `alpha_new[r] = min(C_yi, (beta-B[r])/Ai)` for `r == yi`, else
1421/// `min(0, (beta-B[r])/Ai)`.
1422fn cs_solve_sub_problem<F: Float + 'static>(
1423    ai: F,
1424    yi: usize,
1425    c_yi: F,
1426    active_i: usize,
1427    b_buf: &[F],
1428    alpha_new: &mut [F],
1429) {
1430    // clone(D, B, active_i); D[yi] += Ai·C_yi (`linear.cpp:546-548`).
1431    let mut d: Vec<F> = b_buf[..active_i].to_vec();
1432    if yi < active_i {
1433        d[yi] = d[yi] + ai * c_yi;
1434    }
1435    // qsort DESCENDING (compare_double returns -1 when a > b, `linear.cpp:532-538`).
1436    d.sort_by(|a, b| match b.partial_cmp(a) {
1437        Some(ord) => ord,
1438        None => core::cmp::Ordering::Equal,
1439    });
1440
1441    // beta = D[0] - Ai·C_yi; for r=1; r<active_i && beta<r·D[r]; r++ { beta+=D[r]; }
1442    // beta /= r (`linear.cpp:551-554`).
1443    let mut beta = d[0] - ai * c_yi;
1444    let mut r = 1usize;
1445    while r < active_i {
1446        let r_f = F::from(r).unwrap_or_else(F::one);
1447        if beta < r_f * d[r] {
1448            beta = beta + d[r];
1449            r += 1;
1450        } else {
1451            break;
1452        }
1453    }
1454    let r_f = F::from(r).unwrap_or_else(F::one);
1455    beta = beta / r_f;
1456
1457    // Project each coordinate (`linear.cpp:556-562`).
1458    for (rr, an) in alpha_new.iter_mut().enumerate().take(active_i) {
1459        let cand = (beta - b_buf[rr]) / ai;
1460        *an = if rr == yi {
1461            c_yi.min(cand)
1462        } else {
1463            F::zero().min(cand)
1464        };
1465    }
1466}
1467
1468impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<usize>>
1469    for LinearSVC<F>
1470{
1471    type Fitted = FittedLinearSVC<F>;
1472    type Error = FerroError;
1473
1474    /// Fit the linear SVC model using liblinear's dual coordinate descent.
1475    ///
1476    /// Minimizes `0.5*||w||^2 + C * sum_i L(y_i, w.x_i)` (no `1/n`). When
1477    /// `fit_intercept` is set, the design matrix is augmented with a constant
1478    /// column equal to `intercept_scaling`, the augmented weight is penalized
1479    /// like any feature, and `intercept_ = intercept_scaling * w_last`
1480    /// (`_base.py:1188-1198,:1240-1245`).
1481    ///
1482    /// # Errors
1483    ///
1484    /// - [`FerroError::ShapeMismatch`] — sample count mismatch.
1485    /// - [`FerroError::InvalidParameter`] — `C` not positive, or
1486    ///   `intercept_scaling` not positive when fitting an intercept.
1487    /// - [`FerroError::InsufficientSamples`] — fewer than 2 distinct classes.
1488    fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedLinearSVC<F>, FerroError> {
1489        let (n_samples, n_features) = x.dim();
1490
1491        if n_samples != y.len() {
1492            return Err(FerroError::ShapeMismatch {
1493                expected: vec![n_samples],
1494                actual: vec![y.len()],
1495                context: "y length must match number of samples in X".into(),
1496            });
1497        }
1498
1499        if self.c <= F::zero() {
1500            return Err(FerroError::InvalidParameter {
1501                name: "C".into(),
1502                reason: "must be positive".into(),
1503            });
1504        }
1505
1506        // `_parameter_constraints["tol"] = Interval(Real, 0.0, None,
1507        // closed="neither")` (`_classes.py:237`) → `tol <= 0` raises.
1508        if self.tol <= F::zero() {
1509            return Err(FerroError::InvalidParameter {
1510                name: "tol".into(),
1511                reason: "must be positive".into(),
1512            });
1513        }
1514
1515        // liblinear raises when intercept_scaling <= 0 with fit_intercept
1516        // (`_base.py:1190-1196`).
1517        if self.fit_intercept && self.intercept_scaling <= F::zero() {
1518            return Err(FerroError::InvalidParameter {
1519                name: "intercept_scaling".into(),
1520                reason: "must be greater than 0 when fit_intercept is true".into(),
1521            });
1522        }
1523
1524        // Non-finite input validation (#2263). sklearn `LinearSVC.fit`
1525        // -> `self._validate_data(X, y, ...)` (`svm/_classes.py:302`, the shared
1526        // liblinear `_fit_liblinear` path; cf. `svm/_base.py:190`) keeps the
1527        // default `force_all_finite=True`, so `check_array` rejects any NaN or
1528        // +/-inf in X with a `ValueError("Input X contains NaN.")` / `"...
1529        // contains infinity ..."` BEFORE the dual coordinate-descent solve. `y`
1530        // is `Array1<usize>` (integer class labels), finite by type, and
1531        // ferrolearn's `LinearSVC::fit` takes no `sample_weight` argument, so X is
1532        // the only runtime check. `.iter().any(|v| !v.is_finite())` rejects both
1533        // NaN and Inf (bounds-safe, no panic, R-CODE-2); the finite path is
1534        // byte-identical.
1535        if x.iter().any(|v| !v.is_finite()) {
1536            return Err(FerroError::InvalidParameter {
1537                name: "X".into(),
1538                reason: "Input X contains NaN or infinity.".into(),
1539            });
1540        }
1541
1542        let y_vec: Vec<usize> = y.to_vec();
1543        let mut classes: Vec<usize> = y_vec.clone();
1544        classes.sort_unstable();
1545        classes.dedup();
1546
1547        if classes.len() < 2 {
1548            return Err(FerroError::InsufficientSamples {
1549                required: 2,
1550                actual: classes.len(),
1551                context: "LinearSVC requires at least 2 distinct classes".into(),
1552            });
1553        }
1554
1555        // `class_weight` scales `C` per class: `weighted_C[i] = C·w[class i]`
1556        // (`compute_class_weight(class_weight, classes=classes_, y=y)`,
1557        // `_base.py:1179`; `linear.cpp:2496-2507`). `weights` is aligned to
1558        // `classes` (sorted unique = LabelEncoder order). Used by BOTH the OvR
1559        // path (below) and the Crammer-Singer joint solver.
1560        let weights = compute_class_weight(&self.class_weight, &classes, &y_vec);
1561
1562        // `multi_class='crammer_singer'` selects liblinear solver type 4
1563        // REGARDLESS of penalty/loss/dual (`_get_liblinear_solver_type` returns
1564        // 4 for crammer_singer, `_base.py:1017,1020-1021`), so penalty/loss/dual
1565        // are IGNORED and the joint Crammer-Singer solver runs.
1566        if self.multi_class == MultiClass::CrammerSinger {
1567            return self.fit_crammer_singer(x, &classes, &y_vec, &weights);
1568        }
1569
1570        // Resolve `dual` (auto → bool via `_validate_dual_parameter`,
1571        // `_classes.py:13-29`) then validate the penalty×loss×dual combination
1572        // against the liblinear solver matrix (`_get_liblinear_solver_type`,
1573        // `_base.py:995-1049`). Unsupported combinations raise (ValueError →
1574        // `InvalidParameter`). The resolved solver type selects the per-
1575        // sub-problem solver below.
1576        let dual = resolve_dual(self.dual, self.penalty, self.loss, n_samples, n_features);
1577        let _solver_type = liblinear_solver_type(self.penalty, self.loss, dual)?;
1578
1579        // Solve one binary sub-problem (positive group penalty `cp`, negative
1580        // group penalty `cn`) and split the augmented weight vector into
1581        // (coef_, intercept_) per `_base.py:1240-1245`. For `penalty=l1`
1582        // use liblinear's L1 coordinate descent (`solve_l1r_l2_svc`, solver
1583        // type 5, `linear.cpp:1467`); for `penalty=l2` keep the dual CD — the
1584        // l2 optimum is dual-invariant (R-DEV-7), so dual=True and dual=False
1585        // reach the same `coef_`/`intercept_`.
1586        let penalty = self.penalty;
1587        let solve_one = |y_signed: &Array1<F>, cp: F, cn: F| -> (Array1<F>, F, usize, bool) {
1588            let cfg = SolverConfig {
1589                cp,
1590                cn,
1591                max_iter: self.max_iter,
1592                tol: self.tol,
1593                loss: self.loss,
1594                fit_intercept: self.fit_intercept,
1595                intercept_scaling: self.intercept_scaling,
1596            };
1597            let (w, n_iter, converged) = match penalty {
1598                LinearSVCPenalty::L1 => solve_binary_l1r_l2(x, y_signed, &cfg),
1599                LinearSVCPenalty::L2 => solve_binary_dual(x, y_signed, &cfg),
1600            };
1601            let coef = Array1::from_iter(w.iter().take(n_features).copied());
1602            let intercept = if self.fit_intercept {
1603                self.intercept_scaling * w[n_features]
1604            } else {
1605                F::zero()
1606            };
1607            (coef, intercept, n_iter, converged)
1608        };
1609
1610        let mut any_unconverged = false;
1611        // n_iter_ = n_iter_.max() across the sub-problem fits (`_classes.py:338`).
1612        let mut max_n_iter: usize = 0;
1613
1614        let fitted = if classes.len() == 2 {
1615            // Binary classification. liblinear's `prob.y` are the LabelEncoder
1616            // indices, mapped `y[i] = +1 if y_ind > 0 else -1`
1617            // (`linear.cpp:861-871`), so the positive class is `classes_[1]`
1618            // and `coef_` is for `classes_[1]` (`_base.py` / `_classes.py:311`).
1619            let y_signed: Array1<F> = y.mapv(|label| {
1620                if label == classes[1] {
1621                    F::one()
1622                } else {
1623                    -F::one()
1624                }
1625            });
1626
1627            // Binary (`train_one(Cp=weighted_C[1], Cn=weighted_C[0])`,
1628            // `linear.cpp:2543-2551`): Cp = C·w[classes[1]] (the +1 class),
1629            // Cn = C·w[classes[0]] (the −1 class).
1630            let cp = self.c * weights[1];
1631            let cn = self.c * weights[0];
1632            let (coef, intercept, n_iter, converged) = solve_one(&y_signed, cp, cn);
1633            if !converged {
1634                any_unconverged = true;
1635            }
1636            max_n_iter = max_n_iter.max(n_iter);
1637
1638            FittedLinearSVC {
1639                weight_vectors: vec![coef],
1640                intercepts: vec![intercept],
1641                classes,
1642                is_binary: true,
1643                n_features,
1644                n_iter: max_n_iter,
1645            }
1646        } else {
1647            // Multiclass: one-vs-rest. Each class is the positive (+1) class of
1648            // its own binary sub-problem.
1649            let mut weight_vectors = Vec::with_capacity(classes.len());
1650            let mut intercepts = Vec::with_capacity(classes.len());
1651
1652            for (k, &cls) in classes.iter().enumerate() {
1653                let y_signed: Array1<F> =
1654                    y.mapv(|label| if label == cls { F::one() } else { -F::one() });
1655                // Multiclass OvR (`train_one(Cp=weighted_C[k], Cn=param->C)`,
1656                // `linear.cpp:2559-2571`): Cp = C·w[class k] (the +1 class),
1657                // Cn = C (the BASE C — the negative rest is UNWEIGHTED).
1658                let cp = self.c * weights[k];
1659                let cn = self.c;
1660                let (coef, intercept, n_iter, converged) = solve_one(&y_signed, cp, cn);
1661                if !converged {
1662                    any_unconverged = true;
1663                }
1664                max_n_iter = max_n_iter.max(n_iter);
1665                weight_vectors.push(coef);
1666                intercepts.push(intercept);
1667            }
1668
1669            FittedLinearSVC {
1670                weight_vectors,
1671                intercepts,
1672                classes,
1673                is_binary: false,
1674                n_features,
1675                n_iter: max_n_iter,
1676            }
1677        };
1678
1679        // liblinear warns when any sub-problem reaches `max_iter` without
1680        // satisfying the stopping criterion (`_base.py:1234-1238`). The crate's
1681        // warning channel is `eprintln!` (cf. qda.rs / lda.rs warnings).
1682        if any_unconverged {
1683            eprintln!("Liblinear failed to converge, increase the number of iterations.");
1684        }
1685
1686        Ok(fitted)
1687    }
1688}
1689
1690impl<F: Float + Send + Sync + ScalarOperand + 'static> LinearSVC<F> {
1691    /// Fit via the joint Crammer-Singer multiclass solver (solver type 4,
1692    /// `_base.py:1017`). Runs ONE joint solve over all classes
1693    /// ([`solve_crammer_singer`], `Solver_MCSVM_CS`, `linear.cpp:493-787`),
1694    /// extracts per-class `coef_`/`intercept_` from the flattened
1695    /// `w[feature*nr_class + m]`, and applies the binary collapse
1696    /// (`_classes.py:340-344`).
1697    ///
1698    /// `classes` is the sorted unique label set; `y_vec` the per-sample labels;
1699    /// `weights` the per-class `class_weight` multipliers aligned to `classes`.
1700    fn fit_crammer_singer(
1701        &self,
1702        x: &Array2<F>,
1703        classes: &[usize],
1704        y_vec: &[usize],
1705        weights: &[F],
1706    ) -> Result<FittedLinearSVC<F>, FerroError> {
1707        let (_n_samples, n_features) = x.dim();
1708        let nr_class = classes.len();
1709
1710        // Map each sample's label to its class index (`0..nr_class`, the sorted
1711        // `classes` position — liblinear's LabelEncoder order). `y_class[i]`
1712        // indexes `w[..][m]` and `weighted_C[m]`.
1713        let y_class: Vec<usize> = y_vec
1714            .iter()
1715            .map(|label| classes.iter().position(|c| c == label).unwrap_or(0))
1716            .collect();
1717
1718        // weighted_C[c] = C · class_weight[c] (`linear.cpp:2496-2507`,
1719        // `weighted_C[i] = param->C` then `*= weight`). `weights` already holds
1720        // the per-class multipliers (1.0 when class_weight=None).
1721        let weighted_c: Vec<F> = weights.iter().map(|&wc| self.c * wc).collect();
1722
1723        let (w, n_iter, converged) = solve_crammer_singer(
1724            x,
1725            &y_class,
1726            nr_class,
1727            &weighted_c,
1728            self.max_iter,
1729            self.tol,
1730            self.fit_intercept,
1731            self.intercept_scaling,
1732        );
1733
1734        if !converged {
1735            eprintln!("Liblinear failed to converge, increase the number of iterations.");
1736        }
1737
1738        // Extract coef_[m][feature] = w[feature*nr_class + m] and
1739        // intercept_[m] = intercept_scaling · w[n_features*nr_class + m]
1740        // (the augmented-column weight), per `_base.py:1240-1245`.
1741        let mut weight_vectors: Vec<Array1<F>> = Vec::with_capacity(nr_class);
1742        let mut intercepts: Vec<F> = Vec::with_capacity(nr_class);
1743        for m in 0..nr_class {
1744            let mut row = Array1::<F>::zeros(n_features);
1745            for (feat, r) in row.iter_mut().enumerate() {
1746                *r = w[feat * nr_class + m];
1747            }
1748            weight_vectors.push(row);
1749            let intercept = if self.fit_intercept {
1750                self.intercept_scaling * w[n_features * nr_class + m]
1751            } else {
1752                F::zero()
1753            };
1754            intercepts.push(intercept);
1755        }
1756
1757        // Binary collapse (`_classes.py:340-344`): with 2 classes, the joint
1758        // solve yields two rows; sklearn collapses them to a single weight
1759        // vector `coef_ = row_1 - row_0` and `intercept_ = int_1 - int_0`, so
1760        // the binary sign decision path is used.
1761        if nr_class == 2 {
1762            let collapsed_coef = &weight_vectors[1] - &weight_vectors[0];
1763            let collapsed_intercept = if self.fit_intercept {
1764                intercepts[1] - intercepts[0]
1765            } else {
1766                F::zero()
1767            };
1768            return Ok(FittedLinearSVC {
1769                weight_vectors: vec![collapsed_coef],
1770                intercepts: vec![collapsed_intercept],
1771                classes: classes.to_vec(),
1772                is_binary: true,
1773                n_features,
1774                n_iter,
1775            });
1776        }
1777
1778        Ok(FittedLinearSVC {
1779            weight_vectors,
1780            intercepts,
1781            classes: classes.to_vec(),
1782            is_binary: false,
1783            n_features,
1784            n_iter,
1785        })
1786    }
1787}
1788
1789impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>> for FittedLinearSVC<F> {
1790    type Output = Array1<usize>;
1791    type Error = FerroError;
1792
1793    /// Predict class labels for the given feature matrix.
1794    ///
1795    /// Binary: `sign(X @ w + b)` mapped to class labels (`>= 0 → classes_[1]`).
1796    /// Multiclass: argmax of decision values across one-vs-rest classifiers.
1797    ///
1798    /// # Errors
1799    ///
1800    /// Returns [`FerroError::ShapeMismatch`] if the number of features
1801    /// does not match the fitted model.
1802    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
1803        let n_features = x.ncols();
1804        if n_features != self.n_features {
1805            return Err(FerroError::ShapeMismatch {
1806                expected: vec![self.n_features],
1807                actual: vec![n_features],
1808                context: "number of features must match fitted model".into(),
1809            });
1810        }
1811
1812        let n_samples = x.nrows();
1813        let mut predictions = Array1::<usize>::zeros(n_samples);
1814
1815        if self.is_binary {
1816            let scores = x.dot(&self.weight_vectors[0]) + self.intercepts[0];
1817            for i in 0..n_samples {
1818                predictions[i] = if scores[i] >= F::zero() {
1819                    self.classes[1]
1820                } else {
1821                    self.classes[0]
1822                };
1823            }
1824        } else {
1825            // Multiclass: pick class with highest decision value.
1826            for i in 0..n_samples {
1827                let mut best_class = 0;
1828                let mut best_score = F::neg_infinity();
1829                for (c, w) in self.weight_vectors.iter().enumerate() {
1830                    let score = x.row(i).dot(w) + self.intercepts[c];
1831                    if score > best_score {
1832                        best_score = score;
1833                        best_class = c;
1834                    }
1835                }
1836                predictions[i] = self.classes[best_class];
1837            }
1838        }
1839
1840        Ok(predictions)
1841    }
1842}
1843
1844impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F> for FittedLinearSVC<F> {
1845    /// Returns the coefficient vector of the first (or only) binary sub-problem.
1846    fn coefficients(&self) -> &Array1<F> {
1847        &self.weight_vectors[0]
1848    }
1849
1850    /// Returns the intercept of the first (or only) binary sub-problem.
1851    fn intercept(&self) -> F {
1852        self.intercepts[0]
1853    }
1854}
1855
1856impl<F: Float + Send + Sync + ScalarOperand + 'static> HasClasses for FittedLinearSVC<F> {
1857    fn classes(&self) -> &[usize] {
1858        &self.classes
1859    }
1860
1861    fn n_classes(&self) -> usize {
1862        self.classes.len()
1863    }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869    use ndarray::array;
1870
1871    #[test]
1872    fn test_default_constructor() {
1873        let m = LinearSVC::<f64>::new();
1874        assert_eq!(m.max_iter, 1000);
1875        assert!(m.c == 1.0);
1876        assert_eq!(m.loss, LinearSVCLoss::SquaredHinge);
1877        assert!(m.fit_intercept);
1878        assert!(m.intercept_scaling == 1.0);
1879    }
1880
1881    #[test]
1882    fn test_builder_setters() {
1883        let m = LinearSVC::<f64>::new()
1884            .with_c(10.0)
1885            .with_max_iter(500)
1886            .with_tol(1e-6)
1887            .with_loss(LinearSVCLoss::Hinge)
1888            .with_fit_intercept(false)
1889            .with_intercept_scaling(2.0);
1890        assert!(m.c == 10.0);
1891        assert_eq!(m.max_iter, 500);
1892        assert_eq!(m.loss, LinearSVCLoss::Hinge);
1893        assert!(!m.fit_intercept);
1894        assert!(m.intercept_scaling == 2.0);
1895    }
1896
1897    #[test]
1898    fn test_binary_classification() {
1899        let x = Array2::from_shape_vec(
1900            (8, 2),
1901            vec![
1902                1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0, 9.0, 9.0,
1903            ],
1904        )
1905        .unwrap();
1906        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1907
1908        let model = LinearSVC::<f64>::new().with_c(1.0).with_max_iter(5000);
1909        let fitted = model.fit(&x, &y).unwrap();
1910        let preds = fitted.predict(&x).unwrap();
1911
1912        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1913        assert!(correct >= 6, "expected at least 6 correct, got {correct}");
1914    }
1915
1916    #[test]
1917    fn test_binary_hinge_loss() {
1918        let x = Array2::from_shape_vec(
1919            (6, 2),
1920            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
1921        )
1922        .unwrap();
1923        let y = array![0, 0, 0, 1, 1, 1];
1924
1925        let model = LinearSVC::<f64>::new()
1926            .with_loss(LinearSVCLoss::Hinge)
1927            .with_max_iter(5000);
1928        let fitted = model.fit(&x, &y).unwrap();
1929        let preds = fitted.predict(&x).unwrap();
1930
1931        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1932        assert!(correct >= 4, "expected at least 4 correct, got {correct}");
1933    }
1934
1935    #[test]
1936    fn test_multiclass_classification() {
1937        let x = Array2::from_shape_vec(
1938            (9, 2),
1939            vec![
1940                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 0.0, 10.0, 0.5,
1941                10.0, 0.0, 10.5,
1942            ],
1943        )
1944        .unwrap();
1945        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1946
1947        let model = LinearSVC::<f64>::new().with_c(10.0).with_max_iter(5000);
1948        let fitted = model.fit(&x, &y).unwrap();
1949
1950        assert_eq!(fitted.n_classes(), 3);
1951        assert_eq!(fitted.classes(), &[0, 1, 2]);
1952
1953        let preds = fitted.predict(&x).unwrap();
1954        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1955        assert!(correct >= 7, "expected at least 7 correct, got {correct}");
1956    }
1957
1958    #[test]
1959    fn test_binary_decision_function_is_1d() {
1960        let x = Array2::from_shape_vec(
1961            (6, 2),
1962            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
1963        )
1964        .unwrap();
1965        let y = array![0, 0, 0, 1, 1, 1];
1966
1967        let fitted = LinearSVC::<f64>::new()
1968            .with_max_iter(5000)
1969            .fit(&x, &y)
1970            .unwrap();
1971        let df = fitted.decision_function(&x).unwrap();
1972        // sklearn ravels the binary decision_function to (n,) (`_base.py:365`).
1973        let binary = df.as_binary().expect("binary decision is 1-D");
1974        assert_eq!(binary.len(), 6);
1975        assert!(df.as_multiclass().is_none());
1976        assert_eq!(df.n_samples(), 6);
1977    }
1978
1979    #[test]
1980    fn test_multiclass_decision_function_is_2d() {
1981        let x = Array2::from_shape_vec(
1982            (9, 2),
1983            vec![
1984                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 0.0, 10.0, 0.5,
1985                10.0, 0.0, 10.5,
1986            ],
1987        )
1988        .unwrap();
1989        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1990
1991        let fitted = LinearSVC::<f64>::new()
1992            .with_c(10.0)
1993            .with_max_iter(5000)
1994            .fit(&x, &y)
1995            .unwrap();
1996        let df = fitted.decision_function(&x).unwrap();
1997        let scores = df.as_multiclass().expect("multiclass decision is 2-D");
1998        assert_eq!(scores.dim(), (9, 3));
1999        assert!(df.as_binary().is_none());
2000    }
2001
2002    #[test]
2003    fn test_shape_mismatch_fit() {
2004        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2005        let y = array![0, 1]; // Wrong length
2006
2007        let model = LinearSVC::<f64>::new();
2008        assert!(model.fit(&x, &y).is_err());
2009    }
2010
2011    #[test]
2012    fn test_invalid_c() {
2013        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2014        let y = array![0, 0, 1, 1];
2015
2016        let model = LinearSVC::<f64>::new().with_c(0.0);
2017        assert!(model.fit(&x, &y).is_err());
2018
2019        let model_neg = LinearSVC::<f64>::new().with_c(-1.0);
2020        assert!(model_neg.fit(&x, &y).is_err());
2021    }
2022
2023    #[test]
2024    fn test_invalid_intercept_scaling() {
2025        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2026        let y = array![0, 0, 1, 1];
2027
2028        // fit_intercept=true + intercept_scaling<=0 is rejected
2029        // (`_base.py:1190-1196`).
2030        let model = LinearSVC::<f64>::new().with_intercept_scaling(0.0);
2031        assert!(model.fit(&x, &y).is_err());
2032
2033        // But with fit_intercept=false, intercept_scaling is ignored.
2034        let model = LinearSVC::<f64>::new()
2035            .with_fit_intercept(false)
2036            .with_intercept_scaling(0.0);
2037        assert!(model.fit(&x, &y).is_ok());
2038    }
2039
2040    #[test]
2041    fn test_fit_intercept_false_zero_intercept() {
2042        let x = Array2::from_shape_vec(
2043            (6, 2),
2044            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2045        )
2046        .unwrap();
2047        let y = array![0, 0, 0, 1, 1, 1];
2048
2049        let fitted = LinearSVC::<f64>::new()
2050            .with_fit_intercept(false)
2051            .with_max_iter(5000)
2052            .fit(&x, &y)
2053            .unwrap();
2054        assert!(fitted.intercept() == 0.0);
2055    }
2056
2057    #[test]
2058    fn test_single_class_error() {
2059        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2060        let y = array![0, 0, 0];
2061
2062        let model = LinearSVC::<f64>::new();
2063        assert!(model.fit(&x, &y).is_err());
2064    }
2065
2066    #[test]
2067    fn test_has_coefficients() {
2068        let x = Array2::from_shape_vec(
2069            (6, 2),
2070            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2071        )
2072        .unwrap();
2073        let y = array![0, 0, 0, 1, 1, 1];
2074
2075        let model = LinearSVC::<f64>::new().with_max_iter(5000);
2076        let fitted = model.fit(&x, &y).unwrap();
2077        assert_eq!(fitted.coefficients().len(), 2);
2078    }
2079
2080    #[test]
2081    #[allow(
2082        clippy::assertions_on_constants,
2083        reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2084    )]
2085    fn test_l1_penalty_smoke() {
2086        // Smoke check that the `penalty=l1` path (solve_binary_l1r_l2,
2087        // `linear.cpp:1467`, solver type 5, `_base.py:1014`) lands near the live
2088        // sklearn 1.5.2 oracle. The rigorous oracle pin is the critic's to add.
2089        //
2090        // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2091        // ferrolearn):
2092        //   python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2093        //     X=np.array([[1.,1.],[1.,2.],[2.,1.],[2.,2.],[8.,8.],[8.,9.],[9.,8.],[9.,9.]]); \
2094        //     y=np.array([0,0,0,0,1,1,1,1]); \
2095        //     m=LinearSVC(penalty='l1',loss='squared_hinge',dual=False,C=1.0, \
2096        //       fit_intercept=True,max_iter=200000,tol=1e-10).fit(X,y); \
2097        //     print(m.coef_.tolist(), m.intercept_.tolist())"
2098        //   # coef [[0.1283185834966579, 0.12831858464059265]] int [-1.2079646017762715]
2099        const SK_COEF_0: f64 = 0.1283185834966579;
2100        const SK_COEF_1: f64 = 0.12831858464059265;
2101        const SK_INTERCEPT: f64 = -1.2079646017762715;
2102
2103        let x = array![
2104            [1.0, 1.0],
2105            [1.0, 2.0],
2106            [2.0, 1.0],
2107            [2.0, 2.0],
2108            [8.0, 8.0],
2109            [8.0, 9.0],
2110            [9.0, 8.0],
2111            [9.0, 9.0],
2112        ];
2113        let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
2114
2115        let result = LinearSVC::<f64>::new()
2116            .with_penalty(LinearSVCPenalty::L1)
2117            .with_loss(LinearSVCLoss::SquaredHinge)
2118            .with_dual(DualMode::False)
2119            .with_c(1.0)
2120            .with_max_iter(200_000)
2121            .with_tol(1e-10)
2122            .fit(&x, &y);
2123
2124        let Ok(fitted) = result else {
2125            assert!(false, "l1 fit must succeed");
2126            return;
2127        };
2128
2129        let coef = fitted.coefficients();
2130        assert!(
2131            (coef[0] - SK_COEF_0).abs() < 1e-2,
2132            "l1 coef[0] {} vs oracle {SK_COEF_0}",
2133            coef[0]
2134        );
2135        assert!(
2136            (coef[1] - SK_COEF_1).abs() < 1e-2,
2137            "l1 coef[1] {} vs oracle {SK_COEF_1}",
2138            coef[1]
2139        );
2140        assert!(
2141            (fitted.intercept() - SK_INTERCEPT).abs() < 1e-2,
2142            "l1 intercept {} vs oracle {SK_INTERCEPT}",
2143            fitted.intercept()
2144        );
2145    }
2146
2147    #[test]
2148    #[allow(
2149        clippy::assertions_on_constants,
2150        reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2151    )]
2152    fn test_class_weight_smoke() {
2153        // Smoke check that `class_weight` (scaling `C` per class —
2154        // `compute_class_weight`, `_base.py:1179`; `weighted_C[i] = C·w[i]`,
2155        // `linear.cpp:2496-2507`) lands near the live sklearn 1.5.2 oracle. The
2156        // rigorous oracle pin is the critic's to add.
2157        //
2158        // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2159        // ferrolearn):
2160        //   python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2161        //     X=np.array([[1.,1.],[1.,2.],[2.,1.],[2.,2.],[1.5,1.5],[2.,1.5],[8.,8.],[9.,9.]]); \
2162        //     y=np.array([0,0,0,0,0,0,1,1]); \
2163        //     for cw in (None,'balanced',{0:1,1:5}): \
2164        //       m=LinearSVC(C=1.0,loss='squared_hinge',dual=True,fit_intercept=True, \
2165        //         max_iter=200000,tol=1e-10,class_weight=cw).fit(X,y); \
2166        //       print(cw, m.coef_.tolist(), m.intercept_.tolist())"
2167        //   # None       coef [[0.10056447415875154, 0.15957404219329038]] int [-1.263461307484969]
2168        //   # balanced   coef [[0.09936888940946959, 0.16666283617002833]] int [-1.2132032194327564]
2169        //   #            (compute_class_weight('balanced',...) = [0.6667, 2.0])
2170        //   # {0:1,1:5}  coef [[0.11058720549912869, 0.17164468739390437]] int [-1.2954689964246575]
2171        let x = array![
2172            [1.0, 1.0],
2173            [1.0, 2.0],
2174            [2.0, 1.0],
2175            [2.0, 2.0],
2176            [1.5, 1.5],
2177            [2.0, 1.5],
2178            [8.0, 8.0],
2179            [9.0, 9.0],
2180        ];
2181        let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
2182
2183        // (class_weight, expected coef_, expected intercept_) from the oracle.
2184        let cases: [(ClassWeight<f64>, [f64; 2], f64); 3] = [
2185            (
2186                ClassWeight::None,
2187                [0.100_564_474_158_751_54, 0.159_574_042_193_290_38],
2188                -1.263_461_307_484_969,
2189            ),
2190            (
2191                ClassWeight::Balanced,
2192                [0.099_368_889_409_469_59, 0.166_662_836_170_028_33],
2193                -1.213_203_219_432_756_4,
2194            ),
2195            (
2196                ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]),
2197                [0.110_587_205_499_128_69, 0.171_644_687_393_904_37],
2198                -1.295_468_996_424_657_5,
2199            ),
2200        ];
2201
2202        for (cw, exp_coef, exp_int) in cases {
2203            let result = LinearSVC::<f64>::new()
2204                .with_loss(LinearSVCLoss::SquaredHinge)
2205                .with_dual(DualMode::True)
2206                .with_c(1.0)
2207                .with_class_weight(cw)
2208                .with_max_iter(200_000)
2209                .with_tol(1e-10)
2210                .fit(&x, &y);
2211
2212            let Ok(fitted) = result else {
2213                assert!(false, "class_weight fit must succeed");
2214                return;
2215            };
2216
2217            let coef = fitted.coefficients();
2218            assert!(
2219                (coef[0] - exp_coef[0]).abs() < 1e-2,
2220                "coef[0] {} vs oracle {}",
2221                coef[0],
2222                exp_coef[0]
2223            );
2224            assert!(
2225                (coef[1] - exp_coef[1]).abs() < 1e-2,
2226                "coef[1] {} vs oracle {}",
2227                coef[1],
2228                exp_coef[1]
2229            );
2230            assert!(
2231                (fitted.intercept() - exp_int).abs() < 1e-2,
2232                "intercept {} vs oracle {exp_int}",
2233                fitted.intercept()
2234            );
2235        }
2236    }
2237
2238    #[test]
2239    fn test_unsupported_combinations_rejected() {
2240        let x = array![[1.0, 1.0], [2.0, 2.0], [8.0, 8.0], [9.0, 9.0]];
2241        let y = array![0usize, 0, 1, 1];
2242
2243        // l1 + hinge is unsupported for any dual (`_base.py:1013` has no `l1`
2244        // under `hinge`).
2245        assert!(
2246            LinearSVC::<f64>::new()
2247                .with_penalty(LinearSVCPenalty::L1)
2248                .with_loss(LinearSVCLoss::Hinge)
2249                .fit(&x, &y)
2250                .is_err(),
2251            "l1 + hinge must be rejected"
2252        );
2253
2254        // l2 + hinge + dual=false is unsupported (`hinge: {l2: {dual=true: 3}}`,
2255        // no dual=false entry).
2256        assert!(
2257            LinearSVC::<f64>::new()
2258                .with_penalty(LinearSVCPenalty::L2)
2259                .with_loss(LinearSVCLoss::Hinge)
2260                .with_dual(DualMode::False)
2261                .fit(&x, &y)
2262                .is_err(),
2263            "l2 + hinge + dual=false must be rejected"
2264        );
2265
2266        // l1 + squared_hinge + dual=true is unsupported (`squared_hinge: {l1:
2267        // {dual=false: 5}}`, no dual=true entry).
2268        assert!(
2269            LinearSVC::<f64>::new()
2270                .with_penalty(LinearSVCPenalty::L1)
2271                .with_loss(LinearSVCLoss::SquaredHinge)
2272                .with_dual(DualMode::True)
2273                .fit(&x, &y)
2274                .is_err(),
2275            "l1 + squared_hinge + dual=true must be rejected"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_dual_auto_resolution() {
2281        // n_samples (4) >= n_features (2): auto prefers dual=false. hinge+l2:
2282        // dual=false has no solver, so auto must fall back to dual=true
2283        // (type 3, `_classes.py:22-27`) — the fit succeeds rather than rejecting.
2284        let x = array![[1.0, 1.0], [2.0, 2.0], [8.0, 8.0], [9.0, 9.0]];
2285        let y = array![0usize, 0, 1, 1];
2286
2287        assert!(
2288            LinearSVC::<f64>::new()
2289                .with_loss(LinearSVCLoss::Hinge)
2290                .with_dual(DualMode::Auto)
2291                .fit(&x, &y)
2292                .is_ok(),
2293            "auto must fall back to dual=true for hinge+l2"
2294        );
2295    }
2296
2297    #[test]
2298    #[allow(
2299        clippy::assertions_on_constants,
2300        reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2301    )]
2302    fn test_crammer_singer_smoke() {
2303        // Smoke check that the joint Crammer-Singer solver (solve_crammer_singer,
2304        // Solver_MCSVM_CS, `linear.cpp:493-787`, solver type 4, `_base.py:1017`)
2305        // lands near the live sklearn 1.5.2 oracle for BOTH multiclass and the
2306        // binary collapse (`_classes.py:340-344`). The rigorous oracle pin is the
2307        // critic's to add.
2308        //
2309        // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2310        // ferrolearn):
2311        //   python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2312        //     X=np.array([[0,0],[0.5,0.2],[0.2,0.5],[1,1],[4,4],[4.5,4.2],[4.2,4.5],[5,5],\
2313        //       [0,5],[0.5,5.2],[0.2,4.8],[1,6]],dtype=float); \
2314        //     y=np.array([0,0,0,0,1,1,1,1,2,2,2,2]); \
2315        //     m=LinearSVC(multi_class='crammer_singer',C=1.0,fit_intercept=True, \
2316        //       max_iter=200000,tol=1e-10).fit(X,y); \
2317        //     print(m.coef_.tolist(), m.intercept_.tolist(), m.predict(X).tolist())"
2318        //   # coef [[-0.06761865903618029,-0.24341085269880958],
2319        //   #       [0.30047599619312043,0.021705426371714007],
2320        //   #       [-0.23285733712058276,0.22170542636345167]]
2321        //   # int [0.9107847137145293,-0.622059023505724,-0.2887256901997209]
2322        //   # pred [0,0,0,0,1,1,1,1,2,2,2,2]
2323        let x = array![
2324            [0.0, 0.0],
2325            [0.5, 0.2],
2326            [0.2, 0.5],
2327            [1.0, 1.0],
2328            [4.0, 4.0],
2329            [4.5, 4.2],
2330            [4.2, 4.5],
2331            [5.0, 5.0],
2332            [0.0, 5.0],
2333            [0.5, 5.2],
2334            [0.2, 4.8],
2335            [1.0, 6.0],
2336        ];
2337        let y = array![0usize, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2];
2338
2339        // Per-class oracle coef_/intercept_ (rows aligned to classes_ [0,1,2]).
2340        let exp_coef: [[f64; 2]; 3] = [
2341            [-0.067_618_659_036_180_29, -0.243_410_852_698_809_58],
2342            [0.300_475_996_193_120_43, 0.021_705_426_371_714_007],
2343            [-0.232_857_337_120_582_76, 0.221_705_426_363_451_67],
2344        ];
2345        let exp_int: [f64; 3] = [
2346            0.910_784_713_714_529_3,
2347            -0.622_059_023_505_724,
2348            -0.288_725_690_199_720_9,
2349        ];
2350
2351        let Ok(fitted) = LinearSVC::<f64>::new()
2352            .with_multi_class(MultiClass::CrammerSinger)
2353            .with_c(1.0)
2354            .with_max_iter(200_000)
2355            .with_tol(1e-10)
2356            .fit(&x, &y)
2357        else {
2358            assert!(false, "crammer_singer multiclass fit must succeed");
2359            return;
2360        };
2361
2362        assert!(!fitted.is_binary, "3-class CS must be multiclass");
2363        let rows = fitted.weight_vectors();
2364        let ints = fitted.intercepts();
2365        assert_eq!(rows.len(), 3);
2366        for m in 0..3 {
2367            assert!(
2368                (rows[m][0] - exp_coef[m][0]).abs() < 1e-2,
2369                "CS coef[{m}][0] {} vs oracle {}",
2370                rows[m][0],
2371                exp_coef[m][0]
2372            );
2373            assert!(
2374                (rows[m][1] - exp_coef[m][1]).abs() < 1e-2,
2375                "CS coef[{m}][1] {} vs oracle {}",
2376                rows[m][1],
2377                exp_coef[m][1]
2378            );
2379            assert!(
2380                (ints[m] - exp_int[m]).abs() < 1e-2,
2381                "CS intercept[{m}] {} vs oracle {}",
2382                ints[m],
2383                exp_int[m]
2384            );
2385        }
2386
2387        // predict is all-correct (argmax over the per-class scores).
2388        let Ok(preds) = fitted.predict(&x) else {
2389            assert!(false, "CS multiclass predict must succeed");
2390            return;
2391        };
2392        assert_eq!(
2393            preds.to_vec(),
2394            y.to_vec(),
2395            "CS multiclass predict must be all-correct"
2396        );
2397
2398        // Binary collapse (`_classes.py:340-344`): with 2 classes the joint solve
2399        // collapses to a SINGLE weight vector (`coef_` shape (1,2)) +
2400        // `intercept_ = int_1 - int_0`.
2401        //   python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2402        //     X=np.array([[1,1],[1,2],[2,1],[2,2],[8,8],[8,9],[9,8],[9,9]],dtype=float); \
2403        //     y=np.array([0,0,0,0,1,1,1,1]); \
2404        //     m=LinearSVC(multi_class='crammer_singer',C=1.0,fit_intercept=True, \
2405        //       max_iter=200000,tol=1e-10).fit(X,y); \
2406        //     print(m.coef_.tolist(), m.intercept_.tolist())"
2407        //   # coef [[0.15503875968992287,0.15503875968992295]] int [-1.4806201550387597]
2408        const BIN_COEF_0: f64 = 0.155_038_759_689_922_87;
2409        const BIN_COEF_1: f64 = 0.155_038_759_689_922_95;
2410        const BIN_INT: f64 = -1.480_620_155_038_759_7;
2411
2412        let xb = array![
2413            [1.0, 1.0],
2414            [1.0, 2.0],
2415            [2.0, 1.0],
2416            [2.0, 2.0],
2417            [8.0, 8.0],
2418            [8.0, 9.0],
2419            [9.0, 8.0],
2420            [9.0, 9.0],
2421        ];
2422        let yb = array![0usize, 0, 0, 0, 1, 1, 1, 1];
2423
2424        let Ok(fitted_b) = LinearSVC::<f64>::new()
2425            .with_multi_class(MultiClass::CrammerSinger)
2426            .with_c(1.0)
2427            .with_max_iter(200_000)
2428            .with_tol(1e-10)
2429            .fit(&xb, &yb)
2430        else {
2431            assert!(false, "crammer_singer binary fit must succeed");
2432            return;
2433        };
2434
2435        assert!(fitted_b.is_binary, "2-class CS must collapse to binary");
2436        let coef = fitted_b.coefficients();
2437        assert_eq!(coef.len(), 2, "binary collapse → single (1,2) weight row");
2438        assert!(
2439            (coef[0] - BIN_COEF_0).abs() < 1e-2,
2440            "CS binary coef[0] {} vs oracle {BIN_COEF_0}",
2441            coef[0]
2442        );
2443        assert!(
2444            (coef[1] - BIN_COEF_1).abs() < 1e-2,
2445            "CS binary coef[1] {} vs oracle {BIN_COEF_1}",
2446            coef[1]
2447        );
2448        assert!(
2449            (fitted_b.intercept() - BIN_INT).abs() < 1e-2,
2450            "CS binary intercept {} vs oracle {BIN_INT}",
2451            fitted_b.intercept()
2452        );
2453
2454        let Ok(preds_b) = fitted_b.predict(&xb) else {
2455            assert!(false, "CS binary predict must succeed");
2456            return;
2457        };
2458        assert_eq!(
2459            preds_b.to_vec(),
2460            yb.to_vec(),
2461            "CS binary predict all-correct"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_predict_feature_mismatch() {
2467        let x = Array2::from_shape_vec(
2468            (6, 2),
2469            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2470        )
2471        .unwrap();
2472        let y = array![0, 0, 0, 1, 1, 1];
2473
2474        let fitted = LinearSVC::<f64>::new()
2475            .with_max_iter(5000)
2476            .fit(&x, &y)
2477            .unwrap();
2478
2479        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2480        assert!(fitted.predict(&x_bad).is_err());
2481    }
2482}