Skip to main content

ferrolearn_linear/
svm.rs

1//! Support Vector Machine with kernel trick.
2//!
3//! This module provides [`SVC`] (classification) and [`SVR`] (regression)
4//! support vector machines trained using the **Sequential Minimal Optimization
5//! (SMO)** algorithm (Platt, 1998).
6//!
7//! # Kernels
8//!
9//! Four built-in kernels are provided:
10//!
11//! - [`LinearKernel`]: `K(x, y) = x . y`
12//! - [`RbfKernel`]: `K(x, y) = exp(-gamma * ||x - y||^2)`
13//! - [`PolynomialKernel`]: `K(x, y) = (gamma * x . y + coef0)^degree`
14//! - [`SigmoidKernel`]: `K(x, y) = tanh(gamma * x . y + coef0)`
15//!
16//! Users can implement the [`Kernel`] trait for custom kernels.
17//!
18//! # Multiclass
19//!
20//! `SVC` uses a one-vs-one strategy for multiclass classification.
21//!
22//! # Examples
23//!
24//! ```
25//! use ferrolearn_linear::svm::{SVC, LinearKernel};
26//! use ferrolearn_core::{Fit, Predict};
27//! use ndarray::{array, Array2};
28//!
29//! let x = Array2::from_shape_vec((6, 2), vec![
30//!     1.0, 1.0,  2.0, 1.0,  1.0, 2.0,
31//!     5.0, 5.0,  6.0, 5.0,  5.0, 6.0,
32//! ]).unwrap();
33//! let y = array![0usize, 0, 0, 1, 1, 1];
34//!
35//! let model = SVC::<f64, LinearKernel>::new(LinearKernel);
36//! let fitted = model.fit(&x, &y).unwrap();
37//! let preds = fitted.predict(&x).unwrap();
38//! assert_eq!(preds.len(), 6);
39//! ```
40//!
41//! ## REQ status
42//!
43//! Binary (R-DEFER-2): SHIPPED = impl + non-test production consumer + tests +
44//! green oracle verification; NOT-STARTED = open blocker `#`. `SVC`/`SVR`/
45//! `FittedSVC`/`FittedSVR`/`Kernel` + the four kernels are boundary estimator
46//! types re-exported at the crate root (`pub use svm::{…}` in `lib.rs`) and
47//! consumed by `nu_svm.rs` (`NuSVC`/`NuSVR` delegate to `SVC`/`SVR`) and
48//! `one_class_svm.rs` (uses `Kernel`) — non-test production consumers; under
49//! S5/R-DEFER-1 the fitted-attribute accessors are part of that boundary public
50//! API surface. See `.design/linear/svm.md`.
51//!
52//! | REQ | Status | Evidence |
53//! |---|---|---|
54//! | REQ-1 (kernels + gamma scale/auto/float) | SHIPPED | The four kernel formulas + the three-way `pub enum Gamma<F> { Scale, Auto, Value }` (default `Scale`) resolved at fit time by `fn resolve_gamma in svm.rs` + `fn resolved_for_fit in svm.rs`: `Scale`=`1/(n_features·X.var())` (`_base.py:238-239`), `Auto`=`1/n_features` (`_base.py:240-241`), `Value(v)`=verbatim (`_base.py:242-243`). Builders `RbfKernel::with_gamma`/`with_gamma_scale`/`with_gamma_auto`. Non-test consumer: the kernel `gamma` field is resolved in the production `fn fit in svm.rs` (`self.kernel.resolved_for_fit(x)`). Pinned: `divergence_pin2_rbf_default_scale_gamma` (scale, green) + in-module `test_svc_gamma_auto_decision_function in svm.rs` (`_gamma=0.5`, df `[-0.9996,-0.9999,-0.9999,0.9999,0.9999,0.9996]` vs live `SVC(kernel='rbf',gamma='auto')`, R-CHAR-3, 1e-2) + `test_svc_gamma_scale_still_default` (`_gamma=0.118421`). |
55//! | REQ-2 (C-SVC SMO fit) | SHIPPED | `fn smo_binary in svm.rs` (Fan-Chen-Lin WSS) converges to libsvm's `α`; pinned by `divergence_pin5_binary_fitted_attributes in tests/divergence_svm_fit.rs` (`dual_coef_ [[-0.0408,-0.0408,0.0816]]`, `support_ [1,2,3]`, `intercept_ [-1.8565]` vs live `SVC(kernel='linear',C=1.0)`). |
56//! | REQ-3 (fitted attrs + binary sign flip) | SHIPPED | `FittedSVC::{support,support_vectors,n_support,dual_coef,intercept,coef} in svm.rs` emit the libsvm layout with the binary sign flip (`_base.py:258-262`); `coef_` is linear-only (`_base.py:642-666`). Pinned by `divergence_pin5_*` (binary) + `divergence_pin6_multiclass_dual_coef_packing` (multiclass `(n_class-1,n_SV)` packing). |
57//! | REQ-4 (decision_function shape/sign/ovr) | SHIPPED | `FittedSVC::decision_function in svm.rs` returns the `SvmScores<F>` enum: binary -> `SvmScores::Binary` 1-D `(n,)` = `-raw_ovo.ravel()` (positive -> `classes_[1]`, `_base.py:538-539`); multiclass -> `SvmScores::Multiclass` `(n, n_classes)` via `fn ovr_decision_function in svm.rs` (default `SvmDecisionShape::Ovr`, transcribed from `multiclass.py:520-562`) applied to `dec<0`/`-dec` (`_base.py:780`), or raw `(n, n·(n-1)/2)` for `SvmDecisionShape::Ovo`. `SVC::decision_function_shape` field + `with_decision_function_shape`. Sign normalized: `fn raw_ovo` negates `decision_value_binary` to restore libsvm's lower-index-class-`+1` ovo convention. Pinned by `divergence_pin8_multiclass_ovr_decision_function` (ovr `(9,3)` row0 `[2.2366,0.8167,-0.1833]`, row3 `[1.0606,2.2262,-0.2333]`), `divergence_pin9_multiclass_ovo_decision_function` (ovo `(9,3)` row0 `[1.2222,1.2222,0.0]`), `divergence_pin10_binary_shape_contract` (binary 1-D `(6,)`) in `tests/divergence_svm_fit.rs` (R-CHAR-3, 1e-2). Consumer: `FittedNuSVC::decision_function in nu_svm.rs` delegates (non-test, propagates `SvmScores`). |
58//! | REQ-5 (predict + tie-break) | SHIPPED | `fn predict in svm.rs` (FittedSVC) does libsvm ovo voting and breaks vote ties toward the LOWER class index via a strictly-greater first-max scan (keeps the first/lowest-index maximum since `classes` is `np.unique(y)`-sorted), matching libsvm/sklearn `super().predict` (`_base.py:813-814`) instead of `max_by_key`'s last-maximum. Pinned by `divergence_pin3_predict_labels` (separable-set labels) + `divergence_pin11_ovo_vote_tie_break_lower_index` (4-class vote tie `(0,2,2,2)` at `q=(-0.21,-8.976)` -> class 1) in `tests/divergence_svm_fit.rs` vs live `SVC(kernel='linear',C=1.0)`. |
59//! | REQ-6 (epsilon-SVR) | SHIPPED | `fn smo_svr in svm.rs` + `FittedSVR::{support,support_vectors,n_support,dual_coef,intercept}`; pinned by `divergence_pin4_svr_predict_values` (predict) + `divergence_pin7_svr_fitted_attributes` (`support_ [0,5]`, `dual_coef_ [[-0.392,0.392]]`, `intercept_ [0.14]` vs live `SVR(kernel='linear',C=100,epsilon=0.1)`). |
60//! | REQ-7 (multiclass one-vs-one) | SHIPPED | `fn fit in svm.rs` (SVC) trains one `smo_binary` per class pair, `classes` = `np.unique(y)`; pinned by `divergence_pin6_multiclass_dual_coef_packing` (3-class `dual_coef_ (2,6)` libsvm packing, `support_ [1,2,3,5,6,7]`, `n_support_ [2,2,2]`, `intercept_ [1.2222,1.2222,0.0]`). |
61//! | REQ-8 (constructor param surface + defaults) | SHIPPED | `shrinking` (`SVC`/`SVR`, default `true`, `with_shrinking`; accepted for API parity, shrinking-invariant optimum so DOES NOT alter results — R-DEV-7); `break_ties` (`SVC`, default `false`, `with_break_ties`; `fn predict in svm.rs` ovr-argmax branch for `break_ties=true`+ovr+`n_classes>2`, `InvalidParameter` for the ovo combo, `_base.py:801-814`); default alignment `cache_size=200`, `max_iter=0` (= sklearn `-1`, no iteration limit; the `smo_binary`/`smo_svr` loops treat `0` as unbounded); REQ-1's `gamma` enum (`scale`/`auto`/float); and now **`class_weight`** (`SVC`, `pub class_weight: ClassWeight<F>` default `None`, `with_class_weight`). `fn compute_class_weight in svm.rs` mirrors `sklearn.utils.compute_class_weight` as called by `BaseSVC._validate_targets` (`class_weight_ = compute_class_weight(class_weight, classes, y)`, `_base.py:740`): `None`→1.0, `Balanced`→`n_samples/(n_classes·count_c)` (`_classes.py:122-124`), `Explicit`→1.0 default overridden by map. `fn smo_binary in svm.rs` now takes per-class box bounds `(cp, cn)` (the `y=+1`/`y=-1` upper bounds) instead of a scalar `c`, applied in the WSS `in_up`/`in_low` tests, the analytic-update box clip, and the free-SV bias recovery (`0<alpha_i<C_i`); when `cp==cn` the math is identical to before (the 13 divergence pins stay green). `fn fit in svm.rs` (SVC) computes `weights = compute_class_weight(...)` ONCE over the full `y`, then per ovo pair `(ci,cj)`: `cp = C·weights[cj]`, `cn = C·weights[ci]` (libsvm `weighted_C`, `_base.py:740`). Non-test consumer: `fn fit in svm.rs` consumes `self.class_weight` (the boundary `SVC`/`FittedSVC` types are re-exported at the crate root + consumed by `nu_svm.rs`). Pinned: `test_svc_class_weight_smoke`/`test_compute_class_weight_balanced`/`test_svc_break_ties_changes_label`/`test_svc_break_ties_ovo_errors`/`test_svc_default_params in svm.rs` (live oracle on the imbalanced 8×2 set: None `dual_coef_ [[-0.5,-1,1,0.5]]`/`intercept_ [-2.0]`/`support_ [1,3,5,6]`; balanced `[[-0.8,-0.8,1.3333,0.2667]]`/`-1.6667`; `{0:1,1:5}` `support_ [1,3,4,5]`/`-2.0`; R-CHAR-3, 1e-2; None≠balanced intercept). **R-DEV-7 design difference (preserved contract, NOT a gap):** estimator-level `kernel`(string-select)/`degree`/`coef0` are the type parameter `K`, set by construction; `random_state` is unused (ferrolearn's SMO is deterministic). `class_weight` is SVC-only (sklearn SVR has no `class_weight`). |
62//! | REQ-9 (probability / predict_proba) | SHIPPED | `pub probability: bool` field on `SVC` (default `false`, `with_probability`) + `prob_a`/`prob_b`/`probability` on `FittedSVC`. The DETERMINISTIC Platt machinery is transcribed from libsvm: `fn sigmoid_train in svm.rs` (Newton iteration + prior init + target smoothing + step-halving line search, `svm.cpp:1919-2030`), `fn sigmoid_predict in svm.rs` (overflow-safe form, `svm.cpp:2032-2040`), `fn multiclass_probability in svm.rs` (Wu-Lin-Weng 2004 coupling, `svm.cpp:2043-2104`). `fn platt_cv_sigmoid in svm.rs` runs a per-ovo-pair 5-fold CV at fit time when `probability=true` (`svm.cpp:2107-2203`). `FittedSVC::predict_proba in svm.rs` builds the pairwise matrix via `sigmoid_predict` (clamped `[1e-7,1-1e-7]`, `svm.cpp:2937`) -> `multiclass_probability` -> `(n,n_classes)`; binary -> `[P(classes[0]),P(classes[1])]`; rows sum to 1. `FittedSVC::predict_log_proba` = `predict_proba.ln()` (`_base.py:866-894`). `probability=false` -> `InvalidParameter` carrying sklearn's `NotFittedError` text "predict_proba is not available when fitted with probability=False" (`_base.py:856-860`; no `NotFitted` variant by R-DEV-4 typestate). **RNG-CV boundary (documented divergence, NOT a gap):** libsvm's CV fold permutation is RNG-seeded, so sklearn's `probA_`/`probB_`/`predict_proba` are NON-DETERMINISTIC across `random_state` (`probA_` = -0.7749 at rs=0 vs -1.0541 at rs=1; the docstring admits CV-dependence). ferrolearn uses a DETERMINISTIC contiguous 5-fold split (analogous to the documented SGD shuffle boundary), so it does NOT bit-match sklearn's predict_proba VALUES — only the deterministic machinery + structural invariants + the raise contract are verified (R-CHAR-3: the asserted invariants are sklearn's DOCUMENTED contract, not copied values). Pinned by `test_svc_predict_proba_raises_when_probability_false`/`test_svc_predict_proba_binary_rows_sum_to_one`/`test_svc_predict_proba_binary_monotone_in_decision`/`test_svc_predict_log_proba_equals_log_of_proba`/`test_svc_predict_proba_multiclass_rows_sum_to_one`/`test_sigmoid_predict_overflow_safe`/`test_multiclass_probability_binary_reduces_to_pairwise in svm.rs`. Non-test consumer: `fn fit in svm.rs` (SVC) consumes `self.probability` (the boundary `SVC`/`FittedSVC` types are re-exported at the crate root + consumed by `nu_svm.rs`). |
63//! | REQ-10 (ferray substrate) | NOT-STARTED | open #643. `svm.rs` imports `ndarray::{Array1, Array2, ScalarOperand}`, not `ferray-core`/`ferray::linalg` (R-SUBSTRATE). |
64//! | REQ-11 (non-finite input rejected) | SHIPPED | Both fit entries reject any NaN/+/-inf BEFORE the SMO solve with `FerroError::InvalidParameter`, mirroring sklearn's `BaseLibSVM.fit` -> `_validate_data(X, y, …)` (`_base.py:190-197`, default `force_all_finite=True`) -> `ValueError("Input X contains NaN.")` / `"Input y contains NaN."` / `"… contains infinity …"`. `SVC::fit in svm.rs` checks `X` (`y` is `Array1<usize>` labels, finite by type); `SVR::fit in svm.rs` checks `X` AND the float target `y`. ferrolearn's `Fit::fit` signature has no `sample_weight` argument, so the sklearn `sample_weight`-finiteness raise has no fit-entry counterpart here. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the guard never fires on finite input — the 13+ SVC/SVR divergence pins stay green). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X for both, NaN/inf in y for SVR, all raise `ValueError` (`tests/divergence_svm_nonfinite.rs::{svc_*,svr_*}`). Non-test consumer: the existing `Fit::fit` consumers + the crate-root `pub use svm::{SVC, SVR, …}` re-exports. (#2269) |
65
66use std::collections::HashMap;
67
68use ferrolearn_core::error::FerroError;
69use ferrolearn_core::traits::{Fit, Predict};
70use ndarray::{Array1, Array2, ScalarOperand};
71use num_traits::Float;
72
73// ---------------------------------------------------------------------------
74// Kernel trait and built-in kernels
75// ---------------------------------------------------------------------------
76
77/// The `gamma` coefficient for the RBF / polynomial / sigmoid kernels,
78/// mirroring scikit-learn's three-way `gamma` parameter
79/// (`sklearn/svm/_base.py:235-243`,
80/// `StrOptions({"scale", "auto"}) | Interval(Real, 0.0, None)`).
81///
82/// Resolved at fit time against the training matrix `X`
83/// ([`Kernel::resolved_for_fit`]):
84///
85/// - [`Gamma::Scale`] (default): `1 / (n_features · X.var())` where `X.var()`
86///   is the population variance (ddof=0) of the whole flattened `X`
87///   (`_base.py:238-239`). When `X.var() == 0` sklearn falls back to `1.0`.
88/// - [`Gamma::Auto`]: `1 / n_features` (`_base.py:240-241`).
89/// - [`Gamma::Value`]: the float verbatim (`_base.py:242-243`).
90///
91/// The default is [`Gamma::Scale`], matching sklearn's `gamma='scale'`.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub enum Gamma<F> {
94    /// `gamma='scale'` (sklearn default): `1 / (n_features · X.var())`.
95    Scale,
96    /// `gamma='auto'`: `1 / n_features`.
97    Auto,
98    /// An explicit float gamma, used verbatim.
99    Value(F),
100}
101
102impl<F> Default for Gamma<F> {
103    /// sklearn's default is `gamma='scale'`.
104    fn default() -> Self {
105        Gamma::Scale
106    }
107}
108
109/// Per-class scaling of the regularization parameter `C` for [`SVC`].
110///
111/// Mirrors `sklearn.svm.SVC`'s `class_weight` parameter
112/// (`sklearn/svm/_classes.py:118-124`, constraint `{None, dict, 'balanced'}`):
113/// it sets the C of class `i` to `class_weight[i]·C` (libsvm's per-class
114/// `weighted_C[i] = C·class_weight_[i]`). The expanded per-class weights are
115/// computed by [`compute_class_weight`] following
116/// `sklearn.utils.compute_class_weight` semantics, as called from
117/// `BaseSVC._validate_targets`
118/// (`self.class_weight_ = compute_class_weight(self.class_weight, classes=cls,
119/// y=y_)`, `sklearn/svm/_base.py:740`).
120///
121/// This mirrors [`crate::linear_svc::ClassWeight`] for cross-estimator
122/// consistency, but is defined locally (no cross-import of `linear_svc`
123/// internals).
124#[derive(Debug, Clone, Default)]
125pub enum ClassWeight<F> {
126    /// Uniform weights (all classes weighted `1.0`). The default
127    /// (`class_weight=None`).
128    #[default]
129    None,
130    /// Balanced weights `n_samples / (n_classes · count_c)` per class `c`,
131    /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
132    /// (`_classes.py:122-124`: `n_samples / (n_classes * np.bincount(y))`).
133    Balanced,
134    /// Explicit class-label -> weight map. Classes absent from the map default
135    /// to `1.0`, matching the dict branch of `compute_class_weight`.
136    Explicit(Vec<(usize, F)>),
137}
138
139/// Compute the expanded per-class weight vector aligned to `classes`
140/// (sorted ascending, matching sklearn's `classes_ = np.unique(y)`).
141///
142/// Faithful to `sklearn.utils.compute_class_weight`, as called by
143/// `BaseSVC._validate_targets`
144/// (`compute_class_weight(self.class_weight, classes=cls, y=y_)`,
145/// `sklearn/svm/_base.py:740`):
146/// - `None` -> all `1.0`.
147/// - `Balanced` -> `n_samples / (n_classes · count_c)` per class `c`,
148///   where `count_c` is the number of samples with label `c`
149///   (`_classes.py:122-124`).
150/// - `Explicit(map)` -> `1.0` default, overridden by the map entries matched by
151///   class label.
152///
153/// `classes` is the sorted unique label set; `y` is the per-sample label array.
154/// Mirrors `ferrolearn_linear::linear_svc::compute_class_weight` exactly.
155fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
156    match cw {
157        ClassWeight::None => vec![F::one(); classes.len()],
158        ClassWeight::Balanced => {
159            // `recip_freq = len(y) / (n_classes * bincount(y))`, indexed per
160            // class (`_classes.py:124`).
161            let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
162            let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
163            classes
164                .iter()
165                .map(|&c| {
166                    let count = y.iter().filter(|&&label| label == c).count();
167                    let count_f = F::from(count).unwrap_or_else(F::one);
168                    if count_f > F::zero() {
169                        n_samples / (n_classes * count_f)
170                    } else {
171                        F::one()
172                    }
173                })
174                .collect()
175        }
176        ClassWeight::Explicit(map) => classes
177            .iter()
178            .map(|&c| {
179                map.iter()
180                    .find(|(label, _)| *label == c)
181                    .map_or_else(F::one, |(_, w)| *w)
182            })
183            .collect(),
184    }
185}
186
187/// A kernel function for SVM.
188///
189/// Computes the inner product of two vectors in a (possibly implicit)
190/// higher-dimensional feature space.
191pub trait Kernel<F: Float>: Clone + Send + Sync {
192    /// Compute the kernel value between two vectors.
193    fn compute(&self, x: &[F], y: &[F]) -> F;
194
195    /// Resolve any data-dependent kernel parameters against the training data
196    /// at fit time, returning a copy of the kernel with those parameters fixed.
197    ///
198    /// For kernels with a [`Gamma<F>`] parameter, the three-way `gamma`
199    /// resolution mirrors scikit-learn (`sklearn/svm/_base.py:235-243`):
200    /// [`Gamma::Scale`] (default) -> `1 / (n_features * X.var())` where
201    /// `X.var()` is the population variance (ddof=0) over the whole flattened
202    /// training matrix; [`Gamma::Auto`] -> `1 / n_features`; [`Gamma::Value`]
203    /// is left verbatim. After resolution the stored `gamma` is always a
204    /// concrete [`Gamma::Value`].
205    ///
206    /// The default implementation is a no-op (returns `self.clone()`), which is
207    /// correct for parameter-free kernels such as [`LinearKernel`].
208    #[must_use]
209    fn resolved_for_fit(&self, _x: &Array2<F>) -> Self
210    where
211        Self: Sized,
212    {
213        self.clone()
214    }
215
216    /// Whether this is the linear kernel `K(x, y) = x . y`.
217    ///
218    /// sklearn exposes `coef_` (the primal weight vector
219    /// `dual_coef_ @ support_vectors_`) ONLY for the linear kernel and raises
220    /// `AttributeError` otherwise (`sklearn/svm/_base.py:650-651`). The default
221    /// is `false`; [`LinearKernel`] overrides it to `true`.
222    #[must_use]
223    fn is_linear(&self) -> bool {
224        false
225    }
226}
227
228/// Compute the population variance (ddof=0) of all elements of `x`, mirroring
229/// numpy's `X.var()` (`mean((x - mean)^2)`). Returns `None` when `x` is empty.
230fn population_variance<F: Float>(x: &Array2<F>) -> Option<F> {
231    let n = x.len();
232    if n == 0 {
233        return None;
234    }
235    let count = F::from(n)?;
236    let sum = x.iter().fold(F::zero(), |acc, &v| acc + v);
237    let mean = sum / count;
238    let sq = x
239        .iter()
240        .fold(F::zero(), |acc, &v| acc + (v - mean) * (v - mean));
241    Some(sq / count)
242}
243
244/// Extract the concrete float from a [`Gamma<F>`] for a direct `compute` call
245/// without training data. After [`Kernel::resolved_for_fit`] the gamma is
246/// always a [`Gamma::Value`], so this is the live path; an unresolved
247/// `Scale`/`Auto` (e.g. a kernel used standalone outside a fit) has no `X` to
248/// resolve against and falls back to `1.0`, matching the prior default-gamma
249/// behavior of a directly-evaluated kernel.
250fn gamma_value_or_one<F: Float>(gamma: Gamma<F>) -> F {
251    match gamma {
252        Gamma::Value(v) => v,
253        Gamma::Scale | Gamma::Auto => F::one(),
254    }
255}
256
257/// Resolve a [`Gamma<F>`] spec against the training matrix `X`, returning the
258/// concrete float gamma, mirroring scikit-learn (`sklearn/svm/_base.py:235-243`):
259///
260/// - [`Gamma::Scale`] -> `1 / (n_features * X.var())` (`_base.py:238-239`).
261///   When `X.var() == 0` (constant `X`) or `X` is empty, sklearn falls back to
262///   `1.0` (`_base.py:239`: `if X_var != 0 else 1.0`), so we do the same
263///   (avoiding a non-finite gamma).
264/// - [`Gamma::Auto`] -> `1 / n_features` (`_base.py:240-241`).
265/// - [`Gamma::Value`] -> the float verbatim (`_base.py:242-243`).
266fn resolve_gamma<F: Float>(gamma: Gamma<F>, x: &Array2<F>) -> F {
267    match gamma {
268        Gamma::Value(v) => v,
269        Gamma::Auto => match F::from(x.ncols()) {
270            Some(nf) if nf > F::zero() => F::one() / nf,
271            _ => F::one(),
272        },
273        Gamma::Scale => {
274            let n_features = match F::from(x.ncols()) {
275                Some(nf) if nf > F::zero() => nf,
276                _ => return F::one(),
277            };
278            match population_variance(x) {
279                Some(var) if var > F::zero() => F::one() / (n_features * var),
280                // var == 0 (constant X) or empty: sklearn falls back to 1.0.
281                _ => F::one(),
282            }
283        }
284    }
285}
286
287/// Linear kernel: `K(x, y) = x . y`.
288#[derive(Debug, Clone, Copy)]
289pub struct LinearKernel;
290
291impl<F: Float> Kernel<F> for LinearKernel {
292    fn compute(&self, x: &[F], y: &[F]) -> F {
293        x.iter()
294            .zip(y.iter())
295            .fold(F::zero(), |acc, (&a, &b)| acc + a * b)
296    }
297
298    fn is_linear(&self) -> bool {
299        true
300    }
301}
302
303/// Radial Basis Function (Gaussian) kernel.
304///
305/// `K(x, y) = exp(-gamma * ||x - y||^2)`
306#[derive(Debug, Clone, Copy)]
307pub struct RbfKernel<F> {
308    /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
309    /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`]
310    /// (= `1 / (n_features * X.var())`); [`Gamma::Auto`] = `1 / n_features`;
311    /// [`Gamma::Value`] is used verbatim.
312    pub gamma: Gamma<F>,
313}
314
315impl<F: Float> RbfKernel<F> {
316    /// Create a new RBF kernel with the default `gamma='scale'`.
317    #[must_use]
318    pub fn new() -> Self {
319        Self {
320            gamma: Gamma::Scale,
321        }
322    }
323
324    /// Create a new RBF kernel with an explicit float gamma
325    /// (`gamma=<float>`, [`Gamma::Value`]).
326    #[must_use]
327    pub fn with_gamma(gamma: F) -> Self {
328        Self {
329            gamma: Gamma::Value(gamma),
330        }
331    }
332
333    /// Create a new RBF kernel with `gamma='scale'` ([`Gamma::Scale`],
334    /// sklearn's default = `1 / (n_features * X.var())`).
335    #[must_use]
336    pub fn with_gamma_scale() -> Self {
337        Self {
338            gamma: Gamma::Scale,
339        }
340    }
341
342    /// Create a new RBF kernel with `gamma='auto'` ([`Gamma::Auto`]
343    /// = `1 / n_features`).
344    #[must_use]
345    pub fn with_gamma_auto() -> Self {
346        Self { gamma: Gamma::Auto }
347    }
348}
349
350impl<F: Float> Default for RbfKernel<F> {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356impl<F: Float + Send + Sync> Kernel<F> for RbfKernel<F> {
357    fn compute(&self, x: &[F], y: &[F]) -> F {
358        let gamma = gamma_value_or_one(self.gamma);
359        let sq_dist = x.iter().zip(y.iter()).fold(F::zero(), |acc, (&a, &b)| {
360            let d = a - b;
361            acc + d * d
362        });
363        (-gamma * sq_dist).exp()
364    }
365
366    fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
367        Self {
368            gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
369        }
370    }
371}
372
373/// Polynomial kernel: `K(x, y) = (gamma * x . y + coef0)^degree`.
374#[derive(Debug, Clone, Copy)]
375pub struct PolynomialKernel<F> {
376    /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
377    /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`].
378    pub gamma: Gamma<F>,
379    /// Polynomial degree.
380    pub degree: usize,
381    /// Independent term.
382    pub coef0: F,
383}
384
385impl<F: Float> PolynomialKernel<F> {
386    /// Create a new polynomial kernel with defaults (`gamma='scale'`,
387    /// `degree=3`, `coef0=0`).
388    #[must_use]
389    pub fn new() -> Self {
390        Self {
391            gamma: Gamma::Scale,
392            degree: 3,
393            coef0: F::zero(),
394        }
395    }
396}
397
398impl<F: Float> Default for PolynomialKernel<F> {
399    fn default() -> Self {
400        Self::new()
401    }
402}
403
404impl<F: Float + Send + Sync> Kernel<F> for PolynomialKernel<F> {
405    fn compute(&self, x: &[F], y: &[F]) -> F {
406        let gamma = gamma_value_or_one(self.gamma);
407        let dot: F = x
408            .iter()
409            .zip(y.iter())
410            .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
411        let val = gamma * dot + self.coef0;
412        let mut result = F::one();
413        for _ in 0..self.degree {
414            result = result * val;
415        }
416        result
417    }
418
419    fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
420        Self {
421            gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
422            degree: self.degree,
423            coef0: self.coef0,
424        }
425    }
426}
427
428/// Sigmoid kernel: `K(x, y) = tanh(gamma * x . y + coef0)`.
429#[derive(Debug, Clone, Copy)]
430pub struct SigmoidKernel<F> {
431    /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
432    /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`].
433    pub gamma: Gamma<F>,
434    /// Independent term.
435    pub coef0: F,
436}
437
438impl<F: Float> SigmoidKernel<F> {
439    /// Create a new sigmoid kernel with defaults (`gamma='scale'`, `coef0=0`).
440    #[must_use]
441    pub fn new() -> Self {
442        Self {
443            gamma: Gamma::Scale,
444            coef0: F::zero(),
445        }
446    }
447}
448
449impl<F: Float> Default for SigmoidKernel<F> {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl<F: Float + Send + Sync> Kernel<F> for SigmoidKernel<F> {
456    fn compute(&self, x: &[F], y: &[F]) -> F {
457        let gamma = gamma_value_or_one(self.gamma);
458        let dot: F = x
459            .iter()
460            .zip(y.iter())
461            .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
462        (gamma * dot + self.coef0).tanh()
463    }
464
465    fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
466        Self {
467            gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
468            coef0: self.coef0,
469        }
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Kernel cache (LRU)
475// ---------------------------------------------------------------------------
476
477/// Simple LRU cache for kernel evaluations.
478struct KernelCache<F> {
479    cache: HashMap<(usize, usize), F>,
480    order: Vec<(usize, usize)>,
481    capacity: usize,
482}
483
484impl<F: Float> KernelCache<F> {
485    fn new(capacity: usize) -> Self {
486        Self {
487            cache: HashMap::with_capacity(capacity),
488            order: Vec::with_capacity(capacity),
489            capacity,
490        }
491    }
492
493    fn get_or_compute<K: Kernel<F>>(
494        &mut self,
495        i: usize,
496        j: usize,
497        kernel: &K,
498        data: &[Vec<F>],
499    ) -> F {
500        let key = if i <= j { (i, j) } else { (j, i) };
501        if let Some(&val) = self.cache.get(&key) {
502            return val;
503        }
504        let val = kernel.compute(&data[i], &data[j]);
505        if self.order.len() >= self.capacity
506            && let Some(old_key) = self.order.first().copied()
507        {
508            self.cache.remove(&old_key);
509            self.order.remove(0);
510        }
511        self.cache.insert(key, val);
512        self.order.push(key);
513        val
514    }
515}
516
517// ---------------------------------------------------------------------------
518// SMO solver for binary SVM
519// ---------------------------------------------------------------------------
520
521/// Result of a binary SMO solve.
522struct SmoResult<F> {
523    alphas: Vec<F>,
524    bias: F,
525}
526
527/// SMO implementation (Platt 1998, Fan-Chen-Lin 2005 WSS).
528///
529/// Uses the dual gradient `grad_i = (Q * alpha)_i - 1` where
530/// `Q_{ij} = y_i * y_j * K(x_i, x_j)`. Bias is computed after
531/// convergence from the KKT conditions.
532#[allow(
533    clippy::too_many_arguments,
534    reason = "the per-class box bounds (cp, cn) are separate args mirroring \
535              libsvm's per-sample upper bound C_i (Cp for y=+1, Cn for y=-1)"
536)]
537fn smo_binary<F: Float, K: Kernel<F>>(
538    data: &[Vec<F>],
539    labels: &[F],
540    kernel: &K,
541    cp: F,
542    cn: F,
543    tol: F,
544    max_iter: usize,
545    cache_size: usize,
546) -> Result<SmoResult<F>, FerroError> {
547    let n = data.len();
548    let mut alphas = vec![F::zero(); n];
549    let mut cache = KernelCache::new(cache_size);
550
551    // Per-sample box upper bound `C_i = (y_i > 0 ? Cp : Cn)` (libsvm `GETI`):
552    // `class_weight` scales C per class so the +1 group (class_pos) gets `Cp`
553    // and the -1 group (class_neg) gets `Cn`. When `cp == cn` the box is the
554    // uniform `[0, C]` of the no-class-weight case.
555    let c_of = |i: usize| -> F { if labels[i] > F::zero() { cp } else { cn } };
556
557    // Gradient of the dual objective: grad_i = (Q*alpha)_i - 1
558    // where Q_{ij} = y_i * y_j * K(x_i, x_j).
559    // Initially alpha = 0, so grad_i = -1 for all i.
560    let mut grad: Vec<F> = vec![-F::one(); n];
561
562    let two = F::one() + F::one();
563    let eps = F::from(1e-12).unwrap_or_else(F::epsilon);
564
565    // `max_iter == 0` is the sklearn `max_iter=-1` ("no iteration limit",
566    // libsvm runs to convergence) sentinel — the SMO loop then runs until the
567    // KKT gap closes. A non-zero `max_iter` caps the iteration count.
568    let mut iter = 0usize;
569    loop {
570        if max_iter != 0 && iter >= max_iter {
571            break;
572        }
573        iter += 1;
574        // Working set selection (Fan-Chen-Lin 2005):
575        // I_up  = {i : (y_i=+1 and alpha_i < C) or (y_i=-1 and alpha_i > 0)}
576        // I_low = {j : (y_j=+1 and alpha_j > 0) or (y_j=-1 and alpha_j < C)}
577        // Select i = argmax_{t in I_up}  -y_t * grad_t
578        // Select j = argmin_{t in I_low} -y_t * grad_t
579
580        let mut i_up = None;
581        let mut max_val = F::neg_infinity();
582        let mut j_low = None;
583        let mut min_val = F::infinity();
584
585        for t in 0..n {
586            let val = -labels[t] * grad[t];
587            let c_t = c_of(t);
588
589            let in_up = (labels[t] > F::zero() && alphas[t] < c_t - eps)
590                || (labels[t] < F::zero() && alphas[t] > eps);
591
592            let in_low = (labels[t] > F::zero() && alphas[t] > eps)
593                || (labels[t] < F::zero() && alphas[t] < c_t - eps);
594
595            if in_up && val > max_val {
596                max_val = val;
597                i_up = Some(t);
598            }
599            if in_low && val < min_val {
600                min_val = val;
601                j_low = Some(t);
602            }
603        }
604
605        // Stopping criterion: KKT gap < tol
606        if i_up.is_none() || j_low.is_none() || max_val - min_val < tol {
607            break;
608        }
609
610        let i = i_up.unwrap();
611        let j = j_low.unwrap();
612
613        if i == j {
614            break;
615        }
616
617        // Compute second-order info
618        let kii = cache.get_or_compute(i, i, kernel, data);
619        let kjj = cache.get_or_compute(j, j, kernel, data);
620        let kij = cache.get_or_compute(i, j, kernel, data);
621        let eta = kii + kjj - two * kij;
622
623        if eta <= eps {
624            continue;
625        }
626
627        // Bounds for alpha_j, respecting the per-sample box bounds
628        // `0 <= alpha_i <= C_i` and `0 <= alpha_j <= C_j` (libsvm allows a
629        // different upper bound per sample under `class_weight`).
630        let old_ai = alphas[i];
631        let old_aj = alphas[j];
632        let ci = c_of(i);
633        let cj = c_of(j);
634
635        let (lo, hi) = if labels[i] == labels[j] {
636            // alpha_i + alpha_j = sum (const): alpha_j in
637            // [max(0, sum - C_i), min(C_j, sum)].
638            let sum = old_ai + old_aj;
639            ((sum - ci).max(F::zero()), sum.min(cj))
640        } else {
641            // alpha_i = alpha_j - diff (const diff): alpha_j in
642            // [max(0, diff), min(C_j, C_i + diff)].
643            let diff = old_aj - old_ai;
644            (diff.max(F::zero()), (ci + diff).min(cj))
645        };
646
647        if (hi - lo).abs() < eps {
648            continue;
649        }
650
651        // Analytic update for alpha_j (Platt 1998).
652        // E_k = y_k * grad_k (dual error, where grad = Q*alpha - e).
653        // alpha_j_new = alpha_j + y_j * (E_i - E_j) / eta
654        //             = alpha_j + y_j * (y_i * grad_i - y_j * grad_j) / eta
655        let mut new_aj = old_aj + labels[j] * (labels[i] * grad[i] - labels[j] * grad[j]) / eta;
656
657        // Clip to [lo, hi]
658        if new_aj > hi {
659            new_aj = hi;
660        }
661        if new_aj < lo {
662            new_aj = lo;
663        }
664
665        if (new_aj - old_aj).abs() < eps {
666            continue;
667        }
668
669        let new_ai = old_ai + labels[i] * labels[j] * (old_aj - new_aj);
670
671        alphas[i] = new_ai;
672        alphas[j] = new_aj;
673
674        // Update dual gradient: grad_k += delta_alpha_i * Q_{k,i} + delta_alpha_j * Q_{k,j}
675        // where Q_{k,t} = y_k * y_t * K(k,t)
676        let delta_ai = new_ai - old_ai;
677        let delta_aj = new_aj - old_aj;
678
679        for (k, grad_k) in grad.iter_mut().enumerate() {
680            let kki = cache.get_or_compute(k, i, kernel, data);
681            let kkj = cache.get_or_compute(k, j, kernel, data);
682            *grad_k = *grad_k
683                + delta_ai * labels[k] * labels[i] * kki
684                + delta_aj * labels[k] * labels[j] * kkj;
685        }
686    }
687
688    // Compute bias from KKT conditions.
689    // For support vectors with 0 < alpha_i < C:
690    //   y_i * (sum_j alpha_j * y_j * K(i,j) + b) = 1
691    //   b = y_i - sum_j alpha_j * y_j * K(i,j)
692    // (since y_i^2 = 1, y_i * (y_i * f) = f, so b = 1/y_i - sum = y_i - sum)
693    let mut b_sum = F::zero();
694    let mut b_count = 0usize;
695
696    for i in 0..n {
697        if alphas[i] > eps && alphas[i] < c_of(i) - eps {
698            // This is a free support vector (`0 < alpha_i < C_i`).
699            let mut f_no_b = F::zero();
700            for j in 0..n {
701                if alphas[j] > eps {
702                    f_no_b =
703                        f_no_b + alphas[j] * labels[j] * cache.get_or_compute(i, j, kernel, data);
704                }
705            }
706            b_sum = b_sum + labels[i] - f_no_b;
707            b_count += 1;
708        }
709    }
710
711    let bias = if b_count > 0 {
712        b_sum / F::from(b_count).unwrap()
713    } else {
714        // Fallback: use all support vectors (bounded ones too)
715        let mut b_sum_all = F::zero();
716        let mut b_count_all = 0usize;
717        for i in 0..n {
718            if alphas[i] > eps {
719                let mut f_no_b = F::zero();
720                for j in 0..n {
721                    if alphas[j] > eps {
722                        f_no_b = f_no_b
723                            + alphas[j] * labels[j] * cache.get_or_compute(i, j, kernel, data);
724                    }
725                }
726                b_sum_all = b_sum_all + labels[i] - f_no_b;
727                b_count_all += 1;
728            }
729        }
730        if b_count_all > 0 {
731            b_sum_all / F::from(b_count_all).unwrap()
732        } else {
733            F::zero()
734        }
735    };
736
737    Ok(SmoResult { alphas, bias })
738}
739
740// ---------------------------------------------------------------------------
741// Platt scaling (probability estimates)
742// ---------------------------------------------------------------------------
743
744/// Fit the Platt sigmoid `P(y=+1 | f) = 1 / (1 + exp(A·f + B))` to a set of
745/// decision values `dec_values` with binary labels `labels` (`+1` / `-1`),
746/// returning the `(A, B)` parameters.
747///
748/// A faithful transcription of libsvm's `sigmoid_train`
749/// (`sklearn/svm/src/libsvm/svm.cpp:1919-2030`): the prior-based initial point
750/// (`A=0`, `B=log((prior0+1)/(prior1+1))`), the `t` target smoothing
751/// (`hiTarget=(prior1+1)/(prior1+2)`, `loTarget=1/(prior0+2)`), the Newton
752/// iteration with the regularized Hessian (`H' = H + sigma·I`,
753/// `sigma=1e-12`), the gradient/Hessian accumulation, the step-halving line
754/// search (`min_step=1e-10`, sufficient-decrease constant `0.0001`),
755/// `max_iter=100`, and the `eps=1e-5` gradient stopping criterion. The
756/// overflow-safe `fApB>=0` branching matches the C code exactly.
757#[allow(
758    clippy::too_many_lines,
759    reason = "a faithful one-to-one transcription of libsvm's sigmoid_train \
760              Newton loop (svm.cpp:1919-2030); splitting it would obscure the \
761              line-by-line correspondence to the C oracle"
762)]
763fn sigmoid_train<F: Float>(dec_values: &[F], labels: &[F]) -> (F, F) {
764    let l = dec_values.len();
765    let zero = F::zero();
766    let one = F::one();
767    let two = one + one;
768
769    let mut prior1 = zero;
770    let mut prior0 = zero;
771    for &lab in labels {
772        if lab > zero {
773            prior1 = prior1 + one;
774        } else {
775            prior0 = prior0 + one;
776        }
777    }
778
779    let max_iter = 100usize;
780    let min_step = F::from(1e-10).unwrap_or_else(F::epsilon);
781    let sigma = F::from(1e-12).unwrap_or_else(F::epsilon);
782    let eps = F::from(1e-5).unwrap_or_else(F::epsilon);
783    let suff = F::from(0.0001).unwrap_or_else(F::epsilon);
784
785    let hi_target = (prior1 + one) / (prior1 + two);
786    let lo_target = one / (prior0 + two);
787
788    // Per-sample target smoothed labels `t`.
789    let t: Vec<F> = labels
790        .iter()
791        .map(|&lab| if lab > zero { hi_target } else { lo_target })
792        .collect();
793
794    // Initial point and initial function value.
795    let mut a = zero;
796    let mut b = ((prior0 + one) / (prior1 + one)).ln();
797
798    let funcval = |a: F, b: F| -> F {
799        let mut fval = zero;
800        for i in 0..l {
801            let f_ap_b = dec_values[i] * a + b;
802            if f_ap_b >= zero {
803                fval = fval + t[i] * f_ap_b + (one + (-f_ap_b).exp()).ln();
804            } else {
805                fval = fval + (t[i] - one) * f_ap_b + (one + f_ap_b.exp()).ln();
806            }
807        }
808        fval
809    };
810
811    let mut fval = funcval(a, b);
812
813    for _iter in 0..max_iter {
814        // Update gradient and Hessian (H' = H + sigma·I).
815        let mut h11 = sigma;
816        let mut h22 = sigma;
817        let mut h21 = zero;
818        let mut g1 = zero;
819        let mut g2 = zero;
820        for i in 0..l {
821            let f_ap_b = dec_values[i] * a + b;
822            let (p, q) = if f_ap_b >= zero {
823                let e = (-f_ap_b).exp();
824                (e / (one + e), one / (one + e))
825            } else {
826                let e = f_ap_b.exp();
827                (one / (one + e), e / (one + e))
828            };
829            let d2 = p * q;
830            h11 = h11 + dec_values[i] * dec_values[i] * d2;
831            h22 = h22 + d2;
832            h21 = h21 + dec_values[i] * d2;
833            let d1 = t[i] - p;
834            g1 = g1 + dec_values[i] * d1;
835            g2 = g2 + d1;
836        }
837
838        // Stopping criterion.
839        if g1.abs() < eps && g2.abs() < eps {
840            break;
841        }
842
843        // Newton direction: -inv(H')·g.
844        let det = h11 * h22 - h21 * h21;
845        let d_a = -(h22 * g1 - h21 * g2) / det;
846        let d_b = -(-h21 * g1 + h11 * g2) / det;
847        let gd = g1 * d_a + g2 * d_b;
848
849        // Line search (step halving).
850        let mut stepsize = one;
851        while stepsize >= min_step {
852            let new_a = a + stepsize * d_a;
853            let new_b = b + stepsize * d_b;
854            let newf = funcval(new_a, new_b);
855            if newf < fval + suff * stepsize * gd {
856                a = new_a;
857                b = new_b;
858                fval = newf;
859                break;
860            }
861            stepsize = stepsize / two;
862        }
863
864        if stepsize < min_step {
865            // Line search failed — libsvm bails out of the Newton loop.
866            break;
867        }
868    }
869
870    (a, b)
871}
872
873/// Evaluate the Platt sigmoid `P(y=+1 | f) = 1 / (1 + exp(A·f + B))` at a single
874/// decision value, in the overflow-safe form of libsvm's `sigmoid_predict`
875/// (`sklearn/svm/src/libsvm/svm.cpp:2032-2040`):
876/// `fApB = decision·A + B`; if `fApB >= 0` return `exp(-fApB)/(1+exp(-fApB))`,
877/// else `1/(1+exp(fApB))` (avoiding `exp` overflow / catastrophic
878/// cancellation).
879fn sigmoid_predict<F: Float>(decision: F, a: F, b: F) -> F {
880    let f_ap_b = decision * a + b;
881    if f_ap_b >= F::zero() {
882        let e = (-f_ap_b).exp();
883        e / (F::one() + e)
884    } else {
885        F::one() / (F::one() + f_ap_b.exp())
886    }
887}
888
889/// Wu-Lin-Weng (2004) pairwise coupling ("Method 2"): given the `k×k` pairwise
890/// probability matrix `r` (where `r[i][j] = P(class i | class i or j)`),
891/// produce the `k` coupled class probabilities `p`.
892///
893/// A faithful transcription of libsvm's `multiclass_probability`
894/// (`sklearn/svm/src/libsvm/svm.cpp:2043-2104`): build the `Q` matrix from the
895/// pairwise probabilities, then run the fixed-point iteration
896/// (`max_iter = max(100, k)`, `eps = 0.005/k`) that minimizes the coupling
897/// objective, normalized so the returned probabilities sum to 1.
898fn multiclass_probability<F: Float>(k: usize, r: &Array2<F>) -> Vec<F> {
899    let zero = F::zero();
900    let one = F::one();
901    let k_f = F::from(k).unwrap_or(one);
902
903    let mut p = vec![one / k_f; k];
904    // Q[t][j].
905    let mut q = Array2::<F>::zeros((k, k));
906    for t in 0..k {
907        for j in 0..t {
908            q[[t, t]] = q[[t, t]] + r[[j, t]] * r[[j, t]];
909            q[[t, j]] = q[[j, t]];
910        }
911        for j in (t + 1)..k {
912            q[[t, t]] = q[[t, t]] + r[[j, t]] * r[[j, t]];
913            q[[t, j]] = -r[[j, t]] * r[[t, j]];
914        }
915    }
916
917    let max_iter = 100.max(k);
918    let eps = F::from(0.005).unwrap_or_else(F::epsilon) / k_f;
919    let mut qp = vec![zero; k];
920
921    for _iter in 0..max_iter {
922        // Recompute Qp, pQp for numerical accuracy.
923        let mut p_qp = zero;
924        for t in 0..k {
925            qp[t] = zero;
926            for j in 0..k {
927                qp[t] = qp[t] + q[[t, j]] * p[j];
928            }
929            p_qp = p_qp + p[t] * qp[t];
930        }
931        let mut max_error = zero;
932        for &qpt in qp.iter().take(k) {
933            let error = (qpt - p_qp).abs();
934            if error > max_error {
935                max_error = error;
936            }
937        }
938        if max_error < eps {
939            break;
940        }
941
942        for t in 0..k {
943            let qtt = q[[t, t]];
944            if qtt == zero {
945                continue;
946            }
947            let diff = (-qp[t] + p_qp) / qtt;
948            p[t] = p[t] + diff;
949            p_qp = (p_qp + diff * (diff * qtt + two_qp(qp[t]))) / (one + diff) / (one + diff);
950            for j in 0..k {
951                qp[j] = (qp[j] + diff * q[[t, j]]) / (one + diff);
952                p[j] = p[j] / (one + diff);
953            }
954        }
955    }
956
957    p
958}
959
960/// `2·x` helper for [`multiclass_probability`] (libsvm `2*Qp[t]`).
961#[inline]
962fn two_qp<F: Float>(x: F) -> F {
963    x + x
964}
965
966/// Decision value of a freshly-trained binary SMO sub-model on a query sample,
967/// in ferrolearn's sign convention (positive favors the `+1` label, i.e. the
968/// higher-index `class_pos` group).
969fn sub_decision_value<F: Float, K: Kernel<F>>(
970    sv_data: &[Vec<F>],
971    sv_coefs: &[F],
972    bias: F,
973    kernel: &K,
974    q: &[F],
975) -> F {
976    let mut val = bias;
977    for (sv, &coef) in sv_data.iter().zip(sv_coefs.iter()) {
978        val = val + coef * kernel.compute(sv, q);
979    }
980    val
981}
982
983/// A freshly-trained binary sub-model in this crate's (ferrolearn) sign
984/// convention: support-vector feature rows, their coefficients
985/// (`alpha_i·y_i`, `class_pos = +1` side), and the decision bias such that
986/// [`sub_decision_value`] is positive favoring `class_pos`. Returned by the
987/// per-solver TRAINER closure that [`platt_cv_sigmoid`] invokes on each CV
988/// training fold.
989pub(crate) type SubModel<F> = (Vec<Vec<F>>, Vec<F>, F);
990
991/// Fit the per-ovo-pair Platt sigmoid `(A, B)` via a DETERMINISTIC 5-fold CV
992/// over the pair's samples, mirroring libsvm's `svm_binary_svc_probability`
993/// (`sklearn/svm/src/libsvm/svm.cpp:2107-2203`) EXCEPT for the fold
994/// permutation.
995///
996/// libsvm shuffles the fold assignment with an RNG seeded by `random_state`
997/// (`svm.cpp:2116-2122`), which makes the resulting `(A, B)` (sklearn's
998/// `probA_`/`probB_`) and thus `predict_proba` NON-DETERMINISTIC across
999/// `random_state`. To keep ferrolearn deterministic (it has no libsvm RNG
1000/// seed; cf. the documented SGD shuffle boundary, R-DEV-4), the folds here use a
1001/// DETERMINISTIC CLASS-STRATIFIED assignment instead of libsvm's random shuffle:
1002/// each sample's fold is its WITHIN-CLASS running index modulo `nr_fold`. Because
1003/// the per-ovo-pair samples arrive GROUPED by class, a naive contiguous
1004/// `[i·l/5, (i+1)·l/5)` split would make whole folds single-class — so the 4-fold
1005/// training set could miss a class entirely and `sigmoid_train` would collapse to
1006/// the degenerate `(A, B) = (0, 0)`. Stratifying within class keeps both classes
1007/// in every training set (when each class has ≥2 samples), restoring libsvm's
1008/// structural contract (a non-degenerate sigmoid) without its randomness. The
1009/// rest is a faithful transcription: train a binary sub-model on the 4 training
1010/// folds,
1011/// `predict_values` the held-out fold (in libsvm sign), with the degenerate
1012/// one-class-fold fallbacks (`+1` / `-1` / `0`, `svm.cpp:2161-2169`), then
1013/// [`sigmoid_train`] over all out-of-fold decisions.
1014///
1015/// # The `train_fold` trainer abstraction
1016///
1017/// libsvm's `svm_binary_svc_probability` trains each CV sub-model with the
1018/// SAME `svm_type` as the outer model (`svm.cpp:2147-2150`, a copy of the
1019/// outer `svm_parameter` with `probability=0`): C-SVC sub-models for `SVC`,
1020/// NU-SVC sub-models for `NuSVC`. ferrolearn threads that choice through a
1021/// `train_fold` closure: given the training-fold `(data, labels)` (in
1022/// ferrolearn sign, `class_pos = +1`), it returns the fitted [`SubModel`]
1023/// (`Some`) or `None` on a degenerate/failed sub-solve. `SVC` passes a
1024/// closure wrapping [`smo_binary`] (C-SVC); `NuSVC` passes a closure wrapping
1025/// [`solve_nu_svc`] (the genuine `Solver_NU`). The CV split, degenerate-fold
1026/// fallbacks, held-out scoring via [`sub_decision_value`], and the final
1027/// [`sigmoid_train`] are SOLVER-AGNOSTIC, so SVC's `(A, B)` is byte-identical
1028/// to the pre-refactor inline-`smo_binary` path.
1029///
1030/// `sub_labels` is ferrolearn's sign (`+1` = higher-index `class_pos`,
1031/// `-1` = lower-index `class_neg`). The decision values and labels passed to
1032/// [`sigmoid_train`] are converted to libsvm sign (`+1` = lower-index
1033/// `class_neg`, matching `raw_ovo`) so the fitted `(A, B)` is consistent with
1034/// the raw ovo decision used by [`FittedSVC::predict_proba`].
1035pub(crate) fn platt_cv_sigmoid<F: Float, K: Kernel<F>>(
1036    sub_data: &[Vec<F>],
1037    sub_labels: &[F],
1038    kernel: &K,
1039    train_fold: impl Fn(&[Vec<F>], &[F]) -> Option<SubModel<F>>,
1040) -> (F, F) {
1041    let l = sub_data.len();
1042    let nr_fold = 5usize;
1043    // Out-of-fold decision value per sample, in libsvm sign (+1 = class_neg).
1044    let mut dec_values = vec![F::zero(); l];
1045
1046    // DETERMINISTIC class-stratified fold assignment. libsvm shuffles the fold
1047    // permutation with an RNG (`svm.cpp:2116-2122`) so each fold mixes both
1048    // classes; ferrolearn stays deterministic (no libsvm RNG seed; cf. the
1049    // sanctioned SGD-shuffle boundary, R-DEV-4) by instead assigning each sample
1050    // to a fold by its WITHIN-CLASS running index modulo `nr_fold`. The
1051    // per-ovo-pair samples arrive GROUPED by class (`[class_neg..., class_pos...]`,
1052    // built by the `FittedSVC::fit` loop), so a CONTIGUOUS `[i·l/5, (i+1)·l/5)`
1053    // split would make whole folds single-class and the 4-fold training set could
1054    // MISS a class entirely → a trivial sub-model → constant held-out decisions →
1055    // `sigmoid_train` returns the degenerate `(A, B) = (0, 0)`. Spreading each
1056    // class proportionally across all folds keeps BOTH classes in every training
1057    // set whenever each class has ≥2 samples, restoring the structural contract
1058    // (a non-degenerate sigmoid) at every input — matching libsvm's intent
1059    // without its randomness.
1060    let mut pos_seen = 0usize;
1061    let mut neg_seen = 0usize;
1062    let mut fold_of = vec![0usize; l];
1063    for (j, &lab) in sub_labels.iter().enumerate() {
1064        if lab > F::zero() {
1065            fold_of[j] = pos_seen % nr_fold;
1066            pos_seen += 1;
1067        } else {
1068            fold_of[j] = neg_seen % nr_fold;
1069            neg_seen += 1;
1070        }
1071    }
1072
1073    for fold in 0..nr_fold {
1074        // Training set = all samples NOT assigned to this fold.
1075        let mut tr_data: Vec<Vec<F>> = Vec::with_capacity(l);
1076        let mut tr_labels: Vec<F> = Vec::with_capacity(l);
1077        for (j, row) in sub_data.iter().enumerate() {
1078            if fold_of[j] != fold {
1079                tr_data.push(row.clone());
1080                tr_labels.push(sub_labels[j]);
1081            }
1082        }
1083
1084        // Count classes in the training folds (ferrolearn sign).
1085        let mut p_count = 0usize;
1086        let mut n_count = 0usize;
1087        for &lab in &tr_labels {
1088            if lab > F::zero() {
1089                p_count += 1;
1090            } else {
1091                n_count += 1;
1092            }
1093        }
1094
1095        // Degenerate folds: libsvm assigns a constant decision
1096        // (`svm.cpp:2161-2169`). In ferrolearn sign a held-out sample gets
1097        // +1 (all-positive train), -1 (all-negative train), or 0 (empty); we
1098        // store the libsvm-sign value = negation. The held-out fold is now the
1099        // (non-contiguous) set `{ j : fold_of[j] == fold }`, not a slice.
1100        let held_out = (0..l).filter(|&j| fold_of[j] == fold);
1101        if p_count == 0 && n_count == 0 {
1102            for j in held_out {
1103                dec_values[j] = F::zero();
1104            }
1105            continue;
1106        } else if n_count == 0 {
1107            // train all +1 (class_pos) -> ferrolearn dec +1 -> libsvm -1.
1108            for j in held_out {
1109                dec_values[j] = -F::one();
1110            }
1111            continue;
1112        } else if p_count == 0 {
1113            for j in held_out {
1114                dec_values[j] = F::one();
1115            }
1116            continue;
1117        }
1118
1119        // Train a probability-free sub-model on the training folds via the
1120        // per-solver trainer (C-SVC for SVC, NU-SVC for NuSVC).
1121        let Some((sv_data, sv_coefs, bias)) = train_fold(&tr_data, &tr_labels) else {
1122            // A failed/degenerate sub-solve falls back to a neutral 0 decision.
1123            for j in held_out {
1124                dec_values[j] = F::zero();
1125            }
1126            continue;
1127        };
1128
1129        // Score the held-out fold; store in libsvm sign (negate ferrolearn).
1130        for j in held_out {
1131            let dec_ferro = sub_decision_value(&sv_data, &sv_coefs, bias, kernel, &sub_data[j]);
1132            dec_values[j] = -dec_ferro;
1133        }
1134    }
1135
1136    // libsvm labels for sigmoid_train: +1 = lower-index class_neg, matching
1137    // the libsvm-sign decision values (so `-sub_labels`).
1138    let libsvm_labels: Vec<F> = sub_labels.iter().map(|&lab| -lab).collect();
1139    sigmoid_train(&dec_values, &libsvm_labels)
1140}
1141
1142// ---------------------------------------------------------------------------
1143// decision_function shape + scores
1144// ---------------------------------------------------------------------------
1145
1146/// The shape convention for [`FittedSVC::decision_function`] in the multiclass
1147/// case, mirroring scikit-learn's `SVC.decision_function_shape`
1148/// (`sklearn/svm/_base.py:778-781`).
1149///
1150/// - [`SvmDecisionShape::Ovr`] (default): one-vs-rest scores, shape
1151///   `(n_samples, n_classes)`, produced by the `_ovr_decision_function`
1152///   transform (`sklearn/utils/multiclass.py:520-562`).
1153/// - [`SvmDecisionShape::Ovo`]: the raw one-vs-one decision values, shape
1154///   `(n_samples, n_class·(n_class-1)/2)`.
1155///
1156/// The binary case is unaffected (it always collapses to a 1-D `(n_samples,)`
1157/// score, `_base.py:538-539`).
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1159pub enum SvmDecisionShape {
1160    /// One-vs-rest: `(n_samples, n_classes)` via `_ovr_decision_function`
1161    /// (sklearn's default).
1162    #[default]
1163    Ovr,
1164    /// One-vs-one: raw `(n_samples, n_class·(n_class-1)/2)` decision values.
1165    Ovo,
1166}
1167
1168/// The result of [`FittedSVC::decision_function`].
1169///
1170/// Mirrors scikit-learn's polymorphic `SVC.decision_function` return
1171/// (`sklearn/svm/_base.py:536-541, 778-781`): the binary case collapses the
1172/// single ovo column to a 1-D `(n_samples,)` array (`-dec.ravel()`,
1173/// `_base.py:538-539`), while the multiclass case returns
1174/// `(n_samples, n_classes)` (ovr, default) or
1175/// `(n_samples, n·(n-1)/2)` (ovo). Structurally parallels
1176/// [`crate::linear_svc::DecisionScores`] for cross-estimator consistency.
1177#[derive(Debug, Clone, PartialEq)]
1178pub enum SvmScores<F> {
1179    /// Binary decision values, shape `(n_samples,)`. A POSITIVE value predicts
1180    /// `classes_[1]` (`-dec.ravel()`, `_base.py:538-539`).
1181    Binary(Array1<F>),
1182    /// Multiclass decision values: `(n_samples, n_classes)` for
1183    /// [`SvmDecisionShape::Ovr`] or `(n_samples, n·(n-1)/2)` for
1184    /// [`SvmDecisionShape::Ovo`].
1185    Multiclass(Array2<F>),
1186}
1187
1188impl<F: Clone> SvmScores<F> {
1189    /// Number of samples scored (the leading axis length in both variants).
1190    #[must_use]
1191    pub fn n_samples(&self) -> usize {
1192        match self {
1193            SvmScores::Binary(v) => v.len(),
1194            SvmScores::Multiclass(m) => m.nrows(),
1195        }
1196    }
1197
1198    /// Borrow the binary 1-D scores, if this is the binary case.
1199    #[must_use]
1200    pub fn as_binary(&self) -> Option<&Array1<F>> {
1201        match self {
1202            SvmScores::Binary(v) => Some(v),
1203            SvmScores::Multiclass(_) => None,
1204        }
1205    }
1206
1207    /// Borrow the multiclass score matrix, if this is the multiclass case.
1208    #[must_use]
1209    pub fn as_multiclass(&self) -> Option<&Array2<F>> {
1210        match self {
1211            SvmScores::Multiclass(m) => Some(m),
1212            SvmScores::Binary(_) => None,
1213        }
1214    }
1215}
1216
1217// ---------------------------------------------------------------------------
1218// SVC (Support Vector Classifier)
1219// ---------------------------------------------------------------------------
1220
1221/// Support Vector Classifier.
1222///
1223/// Uses Sequential Minimal Optimization (SMO) to solve the dual QP.
1224/// Supports multiclass via one-vs-one strategy.
1225///
1226/// # Type Parameters
1227///
1228/// - `F`: The floating-point type (`f32` or `f64`).
1229/// - `K`: The kernel type (e.g., [`LinearKernel`], [`RbfKernel`]).
1230#[derive(Debug, Clone)]
1231pub struct SVC<F, K> {
1232    /// The kernel function.
1233    pub kernel: K,
1234    /// Regularization parameter (penalty for misclassification).
1235    pub c: F,
1236    /// Convergence tolerance.
1237    pub tol: F,
1238    /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
1239    /// sentinel meaning **no iteration limit** (the SMO runs to convergence);
1240    /// a non-zero value caps the iteration count
1241    /// (`sklearn/svm/_classes.py`, `max_iter` default `-1`).
1242    pub max_iter: usize,
1243    /// Size of the kernel evaluation LRU cache (perf-only; default `200` to
1244    /// match sklearn's `cache_size=200`).
1245    pub cache_size: usize,
1246    /// Whether to use libsvm's shrinking heuristic
1247    /// (`sklearn/svm/_base.py:339`, `_classes.py` `shrinking=True`).
1248    ///
1249    /// ferrolearn's SMO has no shrinking heuristic: shrinking is a libsvm
1250    /// performance optimization that does NOT change the converged optimum
1251    /// (R-DEV-7). This flag is accepted for API parity (default `true`,
1252    /// matching sklearn) but DOES NOT alter the fitted result — the converged
1253    /// `α`/`dual_coef_`/`intercept_` are shrinking-invariant.
1254    pub shrinking: bool,
1255    /// The multiclass `decision_function` shape convention
1256    /// (`sklearn/svm/_base.py:778-781`); default
1257    /// [`SvmDecisionShape::Ovr`] (sklearn's `decision_function_shape='ovr'`).
1258    pub decision_function_shape: SvmDecisionShape,
1259    /// Whether `predict` breaks ties by the one-vs-rest decision confidence
1260    /// instead of the libsvm vote (`break_ties`, `sklearn/svm/_classes.py`
1261    /// default `False`; semantics in `BaseSVC.predict`,
1262    /// `sklearn/svm/_base.py:801-814`).
1263    ///
1264    /// When `true` AND [`SvmDecisionShape::Ovr`] AND `n_classes > 2`,
1265    /// `predict = argmax(decision_function(X))` (the ovr decision, which breaks
1266    /// ties by confidence); otherwise the libsvm ovo vote (with lower-index
1267    /// tie-break) is used. `break_ties=true` with [`SvmDecisionShape::Ovo`] is
1268    /// rejected at predict time (`InvalidParameter`), matching sklearn
1269    /// (`_base.py:801-804`).
1270    pub break_ties: bool,
1271    /// Per-class scaling of `C` (`class_weight`, `sklearn/svm/_classes.py:118-124`).
1272    /// Default [`ClassWeight::None`] (all classes weighted `1.0`). For an ovo
1273    /// pair `(a, b)` with `a < b`, the C of the `y=+1` group (class `b`) is
1274    /// `C·class_weight_[b]` and the C of the `y=-1` group (class `a`) is
1275    /// `C·class_weight_[a]`; the weights are computed ONCE over the full `y`
1276    /// by [`compute_class_weight`] (`_base.py:740`).
1277    pub class_weight: ClassWeight<F>,
1278    /// Whether to enable Platt-scaling probability estimates
1279    /// (`probability`, `sklearn/svm/_classes.py`, default `False`).
1280    ///
1281    /// When `true`, [`Fit::fit`] runs an internal 5-fold cross-validation per
1282    /// one-vs-one pair, fits a sigmoid `1/(1+exp(A·f+B))` over the out-of-fold
1283    /// decision values ([`sigmoid_train`], libsvm `svm_binary_svc_probability`,
1284    /// `svm.cpp:2107-2203`), and stores the per-pair `(A, B)` so
1285    /// [`FittedSVC::predict_proba`]/[`FittedSVC::predict_log_proba`] are
1286    /// available. When `false` (the default) `predict_proba` returns an error
1287    /// (`_base.py:820-827`).
1288    ///
1289    /// **RNG boundary (documented divergence).** libsvm's
1290    /// `svm_binary_svc_probability` shuffles the CV fold assignment with an
1291    /// RNG seeded by `random_state`, so sklearn's `probA_`/`probB_` (and hence
1292    /// the exact `predict_proba` values) are NON-DETERMINISTIC across
1293    /// `random_state` — the docstring itself warns "the results can be slightly
1294    /// different than those obtained by predict". ferrolearn instead uses a
1295    /// DETERMINISTIC 5-fold split (contiguous folds, no RNG shuffle), so it
1296    /// CANNOT and DOES NOT bit-match sklearn's `predict_proba` values. What is
1297    /// reproduced exactly is the DETERMINISTIC machinery ([`sigmoid_train`],
1298    /// [`sigmoid_predict`], [`multiclass_probability`]) and the STRUCTURAL
1299    /// contract (rows sum to 1, entries in `[0, 1]`, monotone in the binary
1300    /// decision value, the raise-when-`probability=false`). This is analogous
1301    /// to the SGD shuffle boundary already documented in this codebase.
1302    pub probability: bool,
1303}
1304
1305impl<F: Float, K: Kernel<F>> SVC<F, K> {
1306    /// Create a new `SVC` with the given kernel and default hyperparameters
1307    /// matching sklearn (`sklearn/svm/_classes.py` `SVC.__init__`).
1308    ///
1309    /// Defaults: `C = 1.0`, `tol = 1e-3`, `max_iter = 0` (= sklearn `-1`, no
1310    /// iteration limit), `cache_size = 200`, `shrinking = true`,
1311    /// `decision_function_shape = Ovr`, `break_ties = false`,
1312    /// `class_weight = None`, `probability = false`.
1313    #[must_use]
1314    pub fn new(kernel: K) -> Self {
1315        Self {
1316            kernel,
1317            c: F::one(),
1318            tol: F::from(1e-3).unwrap_or_else(F::epsilon),
1319            max_iter: 0,
1320            cache_size: 200,
1321            shrinking: true,
1322            decision_function_shape: SvmDecisionShape::Ovr,
1323            break_ties: false,
1324            class_weight: ClassWeight::None,
1325            probability: false,
1326        }
1327    }
1328
1329    /// Enable/disable Platt-scaling probability estimates (`sklearn`
1330    /// `probability`, default `false`). When `true`, [`Fit::fit`] runs the
1331    /// internal per-pair 5-fold CV + [`sigmoid_train`] so
1332    /// [`FittedSVC::predict_proba`]/[`FittedSVC::predict_log_proba`] are
1333    /// available; when `false` they return an error.
1334    ///
1335    /// See the [`SVC::probability`] field doc for the documented RNG-CV
1336    /// exact-value boundary (sklearn is non-deterministic across
1337    /// `random_state`; only the deterministic machinery + structural
1338    /// invariants + the raise contract are reproduced).
1339    #[must_use]
1340    pub fn with_probability(mut self, probability: bool) -> Self {
1341        self.probability = probability;
1342        self
1343    }
1344
1345    /// Set the per-class `C` scaling (`sklearn` `class_weight`,
1346    /// `_classes.py:118-124`). [`ClassWeight::None`] (default) leaves every
1347    /// class at `1.0`; [`ClassWeight::Balanced`] uses
1348    /// `n_samples / (n_classes · count_c)`; [`ClassWeight::Explicit`] takes a
1349    /// `(label, weight)` map (unlisted classes default to `1.0`).
1350    #[must_use]
1351    pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
1352        self.class_weight = class_weight;
1353        self
1354    }
1355
1356    /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
1357    ///
1358    /// Accepted for API parity; does NOT alter the converged optimum
1359    /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
1360    #[must_use]
1361    pub fn with_shrinking(mut self, shrinking: bool) -> Self {
1362        self.shrinking = shrinking;
1363        self
1364    }
1365
1366    /// Set the `break_ties` flag (`sklearn` `break_ties`, default `false`,
1367    /// `sklearn/svm/_base.py:801-814`).
1368    #[must_use]
1369    pub fn with_break_ties(mut self, break_ties: bool) -> Self {
1370        self.break_ties = break_ties;
1371        self
1372    }
1373
1374    /// Set the multiclass `decision_function` shape convention
1375    /// (`'ovr'` default / `'ovo'`, `sklearn/svm/_base.py:778-781`).
1376    #[must_use]
1377    pub fn with_decision_function_shape(mut self, shape: SvmDecisionShape) -> Self {
1378        self.decision_function_shape = shape;
1379        self
1380    }
1381
1382    /// Set the regularization parameter C.
1383    #[must_use]
1384    pub fn with_c(mut self, c: F) -> Self {
1385        self.c = c;
1386        self
1387    }
1388
1389    /// Set the convergence tolerance.
1390    #[must_use]
1391    pub fn with_tol(mut self, tol: F) -> Self {
1392        self.tol = tol;
1393        self
1394    }
1395
1396    /// Set the maximum number of SMO iterations.
1397    #[must_use]
1398    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
1399        self.max_iter = max_iter;
1400        self
1401    }
1402
1403    /// Set the kernel cache size.
1404    #[must_use]
1405    pub fn with_cache_size(mut self, cache_size: usize) -> Self {
1406        self.cache_size = cache_size;
1407        self
1408    }
1409}
1410
1411/// A single binary SVM model (one pair of classes in one-vs-one).
1412#[derive(Debug, Clone)]
1413struct BinarySvm<F> {
1414    /// Support vectors (stored as rows).
1415    support_vectors: Vec<Vec<F>>,
1416    /// Original training-row index of each support vector (parallel to
1417    /// `support_vectors`/`dual_coefs`). Used to build the global, per-class
1418    /// grouped `support_` set (`sklearn/svm/_base.py:318-410`).
1419    sv_indices: Vec<usize>,
1420    /// Dual coefficients: `alpha_i * y_i` for each support vector, where this
1421    /// crate maps the lower-index class (`class_neg`) to `y = -1` and the
1422    /// higher-index class (`class_pos`) to `y = +1`. NOTE this is the OPPOSITE
1423    /// sign convention to libsvm internally (libsvm gives the lower-index class
1424    /// `+1`); the public-attribute layout compensates in
1425    /// [`FittedSVC::dual_coef`].
1426    dual_coefs: Vec<F>,
1427    /// Bias term.
1428    bias: F,
1429    /// The two class labels: (negative_class, positive_class). `class_neg` is
1430    /// the lower class index and `class_pos` the higher (the ovo pair `(a, b)`
1431    /// with `a < b`).
1432    class_neg: usize,
1433    class_pos: usize,
1434}
1435
1436/// Fitted Support Vector Classifier.
1437///
1438/// Stores one binary SVM per pair of classes (one-vs-one). Implements
1439/// [`Predict`] to produce class labels.
1440#[derive(Debug, Clone)]
1441pub struct FittedSVC<F, K> {
1442    /// The kernel used for predictions.
1443    kernel: K,
1444    /// One binary SVM per class pair, in libsvm ovo pair order
1445    /// `(0,1),(0,2),...,(0,k-1),(1,2),...` (the `(ci,cj)` double loop).
1446    binary_models: Vec<BinarySvm<F>>,
1447    /// Sorted unique classes (`classes_ = np.unique(y)`).
1448    classes: Vec<usize>,
1449    /// The training feature matrix, retained so the libsvm-layout fitted
1450    /// attributes (`support_`, `support_vectors_`) can index back into the
1451    /// original rows (`sklearn/svm/_base.py:318-410`).
1452    x_train: Array2<F>,
1453    /// The training labels (class index per row), retained so `support_` can
1454    /// be grouped by class.
1455    y_train: Vec<usize>,
1456    /// The multiclass `decision_function` shape convention carried over from
1457    /// the unfitted [`SVC`] (`sklearn/svm/_base.py:778-781`).
1458    decision_function_shape: SvmDecisionShape,
1459    /// The `break_ties` flag carried over from the unfitted [`SVC`]
1460    /// (`sklearn/svm/_base.py:801-814`).
1461    break_ties: bool,
1462    /// Whether probability estimates were fitted (`probability`,
1463    /// `sklearn/svm/_classes.py`). When `false`, [`Self::predict_proba`]
1464    /// returns an error (`_base.py:820-827`).
1465    probability: bool,
1466    /// Per-ovo-pair Platt sigmoid `A` parameter (`probA_`,
1467    /// `sklearn/svm/src/libsvm/svm.cpp:2200` via `sigmoid_train`), parallel to
1468    /// `binary_models`. Empty when `probability == false`.
1469    prob_a: Vec<F>,
1470    /// Per-ovo-pair Platt sigmoid `B` parameter (`probB_`), parallel to
1471    /// `binary_models`. Empty when `probability == false`.
1472    prob_b: Vec<F>,
1473}
1474
1475/// One ovo binary sub-model in **this crate's sign convention** (higher-index
1476/// `class_pos` is the `+1` side, matching [`BinarySvm`] and
1477/// [`FittedSVC::decision_value_binary`]). Used by [`FittedSVC::from_nu_ovo`] to
1478/// assemble a nu-SVC fitted model that reuses all of [`FittedSVC`]'s accessors
1479/// / `decision_function` / `predict`.
1480///
1481/// The nu-SVC solver ([`solve_nu_svc`]) is fed the per-pair labels in this same
1482/// convention (`class_pos = +1`), so `sv_coefs`/`bias_internal` are already in
1483/// this-crate sign and `from_nu_ovo` stores them verbatim.
1484pub(crate) struct NuOvoPair<F> {
1485    /// Support-vector feature rows for this pair.
1486    pub sv_data: Vec<Vec<F>>,
1487    /// Per-SV coefficient `alpha·y/r` (this-crate sign, `class_pos = +1`),
1488    /// equal to the public binary `dual_coef_` value (the nu_svc binary flip
1489    /// `public = -internal` cancels with `internal = -stored`).
1490    pub sv_coefs: Vec<F>,
1491    /// Original training-row index of each support vector.
1492    pub sv_indices: Vec<usize>,
1493    /// Decision bias for the `+1`-side (`class_pos`) in this crate's
1494    /// convention (`f(x) = Σ sv_coef·K + bias_internal`).
1495    pub bias_internal: F,
1496    /// Lower-index class label (this crate's `-1` side).
1497    pub class_neg: usize,
1498    /// Higher-index class label (this crate's `+1` side).
1499    pub class_pos: usize,
1500}
1501
1502impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> FittedSVC<F, K> {
1503    /// Assemble a [`FittedSVC`] from per-ovo-pair nu-SVC sub-models (in libsvm
1504    /// sign convention) so that [`NuSVC`](crate::nu_svm::NuSVC) reuses the full
1505    /// libsvm-layout fitted-attribute machinery (`support_`/`dual_coef_`/
1506    /// `intercept_`/`coef_`/`decision_function`/`predict`) without duplicating
1507    /// it (`sklearn/svm/_base.py:318-410`).
1508    ///
1509    /// Each [`NuOvoPair`] is already in this crate's [`BinarySvm`] sign
1510    /// convention (higher-index `class_pos` as the `+1` side, because
1511    /// [`solve_nu_svc`] is fed labels in that convention), so the coefficients
1512    /// and bias are stored verbatim. The resulting public `dual_coef_`/
1513    /// `intercept_` then carry the binary nu_svc sign flip exactly as `c_svc`
1514    /// does (`_base.py:258-262`, predicate `_impl in ["c_svc","nu_svc"]`).
1515    ///
1516    /// When `probability` is `true`, `prob_a`/`prob_b` are the per-ovo-pair
1517    /// sigmoid `(A, B)` parameters fitted by [`platt_cv_sigmoid`] with the
1518    /// NU-SVC sub-solver ([`solve_nu_svc`]) ([`NuSVC`](crate::nu_svm::NuSVC)
1519    /// REQ-9), so the assembled [`FittedSVC`]'s
1520    /// [`Self::predict_proba`]/[`Self::predict_log_proba`] work identically to
1521    /// a `probability=true` C-SVC fit — the coupling/`sigmoid_predict` path is
1522    /// solver-agnostic (it consumes only `raw_ovo` + `prob_a`/`prob_b`). When
1523    /// `probability` is `false`, `prob_a`/`prob_b` are empty.
1524    #[allow(
1525        clippy::too_many_arguments,
1526        reason = "carries the full nu-ovo assembly plus the probability state \
1527                  (probability flag + per-pair probA/probB) in one constructor"
1528    )]
1529    pub(crate) fn from_nu_ovo(
1530        kernel: K,
1531        pairs: Vec<NuOvoPair<F>>,
1532        classes: Vec<usize>,
1533        x_train: Array2<F>,
1534        y_train: Vec<usize>,
1535        decision_function_shape: SvmDecisionShape,
1536        break_ties: bool,
1537        probability: bool,
1538        prob_a: Vec<F>,
1539        prob_b: Vec<F>,
1540    ) -> Self {
1541        let binary_models = pairs
1542            .into_iter()
1543            .map(|pair| BinarySvm {
1544                support_vectors: pair.sv_data,
1545                sv_indices: pair.sv_indices,
1546                dual_coefs: pair.sv_coefs,
1547                bias: pair.bias_internal,
1548                class_neg: pair.class_neg,
1549                class_pos: pair.class_pos,
1550            })
1551            .collect();
1552
1553        FittedSVC {
1554            kernel,
1555            binary_models,
1556            classes,
1557            x_train,
1558            y_train,
1559            decision_function_shape,
1560            break_ties,
1561            probability,
1562            prob_a,
1563            prob_b,
1564        }
1565    }
1566}
1567
1568impl<F: Float, K: Kernel<F>> FittedSVC<F, K> {
1569    /// Compute the decision function value for a single sample against a
1570    /// binary model.
1571    fn decision_value_binary(&self, x: &[F], model: &BinarySvm<F>) -> F {
1572        let mut val = model.bias;
1573        for (sv, &coef) in model.support_vectors.iter().zip(model.dual_coefs.iter()) {
1574            val = val + coef * self.kernel.compute(sv, x);
1575        }
1576        val
1577    }
1578
1579    /// Raw one-vs-one decision values in **libsvm sign convention**, shape
1580    /// `(n_samples, n·(n-1)/2)`, columns in ovo pair order
1581    /// `(0,1),(0,2),...,(1,2),...` (the `(ci,cj)` double loop).
1582    ///
1583    /// libsvm/sklearn use the LOWER-index class as the `+1` side, so a POSITIVE
1584    /// value means the lower-index class wins (`sklearn/svm/_base.py:520-524`).
1585    /// This crate's [`Self::decision_value_binary`] uses the HIGHER-index class
1586    /// (`class_pos`) as `+1`, the opposite sign — so the raw ovo value is the
1587    /// negation of `decision_value_binary` to restore libsvm's convention.
1588    fn raw_ovo(&self, x: &Array2<F>) -> Array2<F> {
1589        let n_samples = x.nrows();
1590        let n_models = self.binary_models.len();
1591        let mut result = Array2::<F>::zeros((n_samples, n_models));
1592
1593        for s in 0..n_samples {
1594            let xi: Vec<F> = x.row(s).to_vec();
1595            for (m, model) in self.binary_models.iter().enumerate() {
1596                // Negate to match libsvm's "lower-index class = +1" sign.
1597                result[[s, m]] = self.decision_value_binary(&xi, model).neg();
1598            }
1599        }
1600
1601        result
1602    }
1603
1604    /// The continuous one-vs-rest decision function transformed from the
1605    /// one-vs-one scores, mirroring `_ovr_decision_function`
1606    /// (`sklearn/utils/multiclass.py:520-562`), shape `(n_samples, n_classes)`.
1607    ///
1608    /// `predictions[s,k] = if raw_ovo[s,k] < 0 { 1 } else { 0 }` and
1609    /// `confidences[s,k] = -raw_ovo[s,k]`, matching sklearn's call
1610    /// `_ovr_decision_function(dec < 0, -dec, n_classes)`
1611    /// (`sklearn/svm/_base.py:780`). The ovo pair iteration `(i,j)` with `i<j`,
1612    /// `k = 0,1,...`, is the SAME order as the `raw_ovo` columns.
1613    fn ovr_decision_function(&self, raw_ovo: &Array2<F>) -> Array2<F> {
1614        let n_samples = raw_ovo.nrows();
1615        let n_classes = self.classes.len();
1616        let mut votes = Array2::<F>::zeros((n_samples, n_classes));
1617        let mut sum_of_confidences = Array2::<F>::zeros((n_samples, n_classes));
1618        let one = F::one();
1619
1620        let mut k = 0usize;
1621        for i in 0..n_classes {
1622            for j in (i + 1)..n_classes {
1623                for s in 0..n_samples {
1624                    let dec = raw_ovo[[s, k]];
1625                    let confidence = dec.neg(); // -dec
1626                    // sum_of_confidences[:, i] -= confidences[:, k]
1627                    sum_of_confidences[[s, i]] = sum_of_confidences[[s, i]] - confidence;
1628                    // sum_of_confidences[:, j] += confidences[:, k]
1629                    sum_of_confidences[[s, j]] = sum_of_confidences[[s, j]] + confidence;
1630                    // predictions[s,k] = (dec < 0) ? 1 : 0
1631                    // votes[predictions==0, i] += 1; votes[predictions==1, j] += 1
1632                    if dec < F::zero() {
1633                        votes[[s, j]] = votes[[s, j]] + one;
1634                    } else {
1635                        votes[[s, i]] = votes[[s, i]] + one;
1636                    }
1637                }
1638                k += 1;
1639            }
1640        }
1641
1642        // transformed = sum_of_confidences / (3 * (|sum_of_confidences| + 1))
1643        // return votes + transformed.
1644        let three = match F::from(3.0) {
1645            Some(v) => v,
1646            None => one + one + one,
1647        };
1648        let mut out = votes;
1649        for s in 0..n_samples {
1650            for c in 0..n_classes {
1651                let soc = sum_of_confidences[[s, c]];
1652                let transformed = soc / (three * (soc.abs() + one));
1653                out[[s, c]] = out[[s, c]] + transformed;
1654            }
1655        }
1656        out
1657    }
1658
1659    /// The decision function for the samples in `x`
1660    /// (`sklearn/svm/_base.py:536-541, 778-781`).
1661    ///
1662    /// - **Binary** (`n_classes == 2`): [`SvmScores::Binary`], shape
1663    ///   `(n_samples,)` = `-raw_ovo.ravel()` (sklearn flips the sign for
1664    ///   `c_svc`/`nu_svc` binary, `_base.py:538-539`), so a POSITIVE value
1665    ///   predicts `classes_[1]`. Because this crate's `decision_value_binary`
1666    ///   already uses the higher-index class as `+1`, `-raw_ovo` equals
1667    ///   `decision_value_binary` directly.
1668    /// - **Multiclass [`SvmDecisionShape::Ovr`]** (default):
1669    ///   [`SvmScores::Multiclass`], shape `(n_samples, n_classes)` =
1670    ///   `_ovr_decision_function(raw_ovo)` (`_base.py:780`).
1671    /// - **Multiclass [`SvmDecisionShape::Ovo`]**: [`SvmScores::Multiclass`],
1672    ///   shape `(n_samples, n·(n-1)/2)` = the raw ovo values.
1673    ///
1674    /// # Errors
1675    ///
1676    /// Returns `Ok` for valid input.
1677    pub fn decision_function(&self, x: &Array2<F>) -> Result<SvmScores<F>, FerroError> {
1678        let raw_ovo = self.raw_ovo(x);
1679
1680        if self.classes.len() == 2 {
1681            // Binary: -raw_ovo.ravel() = +decision_value_binary (1-D).
1682            let n_samples = raw_ovo.nrows();
1683            let mut binary = Array1::<F>::zeros(n_samples);
1684            for s in 0..n_samples {
1685                binary[s] = raw_ovo[[s, 0]].neg();
1686            }
1687            return Ok(SvmScores::Binary(binary));
1688        }
1689
1690        match self.decision_function_shape {
1691            SvmDecisionShape::Ovo => Ok(SvmScores::Multiclass(raw_ovo)),
1692            SvmDecisionShape::Ovr => {
1693                Ok(SvmScores::Multiclass(self.ovr_decision_function(&raw_ovo)))
1694            }
1695        }
1696    }
1697
1698    /// Class probability estimates, shape `(n_samples, n_classes)`; columns
1699    /// correspond to `classes_` in sorted order
1700    /// (`sklearn/svm/_base.py:829-864`, `libsvm.predict_probability`,
1701    /// `svm.cpp:2918-2955`).
1702    ///
1703    /// Built from the per-pair Platt sigmoids fitted at `fit` time when
1704    /// `probability=true`: the raw one-vs-one decision values (libsvm sign,
1705    /// lower-index class `+1`) are mapped to pairwise probabilities via
1706    /// [`sigmoid_predict`] (clamped to `[1e-7, 1-1e-7]`, `svm.cpp:2929-2938`),
1707    /// then coupled by [`multiclass_probability`] (Wu-Lin-Weng 2004,
1708    /// `svm.cpp:2941`). For the binary case `multiclass_probability` reduces to
1709    /// `[sigmoid_predict(dec), 1 - sigmoid_predict(dec)]` =
1710    /// `[P(classes_[0]), P(classes_[1])]`. Each row sums to 1.
1711    ///
1712    /// **RNG-CV exact-value boundary (documented divergence).** Because the
1713    /// underlying `(A, B)` come from a cross-validation whose fold assignment
1714    /// is RNG-dependent in libsvm/sklearn (sklearn's `probA_`/`probB_` and
1715    /// `predict_proba` values change with `random_state`), ferrolearn uses a
1716    /// DETERMINISTIC contiguous 5-fold split instead and therefore does NOT
1717    /// bit-match sklearn's `predict_proba` values. The reproduced contract is
1718    /// structural: rows sum to 1, entries in `[0, 1]`, and (binary) the
1719    /// `classes_[1]` column is monotone non-decreasing in the
1720    /// `decision_function` value. See [`SVC::probability`].
1721    ///
1722    /// # Errors
1723    ///
1724    /// Returns [`FerroError::InvalidParameter`] when the model was fitted with
1725    /// `probability=false`, with the message mirroring sklearn's
1726    /// `NotFittedError` text "predict_proba is not available when fitted with
1727    /// probability=False" (`_base.py:856-860`). (This crate has no `NotFitted`
1728    /// variant — predict-before-fit is a compile error via the typestate,
1729    /// R-DEV-4 — so the "fitted-without-probability" runtime condition maps to
1730    /// `InvalidParameter`; the binding maps it to the matching `PyErr`.)
1731    pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1732        if !self.probability {
1733            return Err(FerroError::InvalidParameter {
1734                name: "probability".into(),
1735                reason: "predict_proba is not available when fitted with probability=False".into(),
1736            });
1737        }
1738
1739        let raw_ovo = self.raw_ovo(x);
1740        let n_samples = raw_ovo.nrows();
1741        let n_classes = self.classes.len();
1742        let min_prob = F::from(1e-7).unwrap_or_else(F::epsilon);
1743        let max_prob = F::one() - min_prob;
1744
1745        let mut out = Array2::<F>::zeros((n_samples, n_classes));
1746
1747        for s in 0..n_samples {
1748            // Build the k×k pairwise probability matrix for this sample.
1749            let mut pairwise = Array2::<F>::zeros((n_classes, n_classes));
1750            let mut k = 0usize;
1751            for i in 0..n_classes {
1752                for j in (i + 1)..n_classes {
1753                    // dec_values[k] is the raw ovo value (libsvm sign: positive
1754                    // favors the lower-index class i = classes_[i]).
1755                    let dec = raw_ovo[[s, k]];
1756                    let (a, b) = (self.prob_a[k], self.prob_b[k]);
1757                    let mut pij = sigmoid_predict(dec, a, b);
1758                    // Clamp to [min_prob, 1-min_prob] (`svm.cpp:2937`).
1759                    if pij < min_prob {
1760                        pij = min_prob;
1761                    }
1762                    if pij > max_prob {
1763                        pij = max_prob;
1764                    }
1765                    pairwise[[i, j]] = pij;
1766                    pairwise[[j, i]] = F::one() - pij;
1767                    k += 1;
1768                }
1769            }
1770            let probs = multiclass_probability(n_classes, &pairwise);
1771            for (c, &pc) in probs.iter().enumerate() {
1772                out[[s, c]] = pc;
1773            }
1774        }
1775
1776        Ok(out)
1777    }
1778
1779    /// Natural-log class probability estimates, shape `(n_samples, n_classes)`
1780    /// = `predict_proba(x).ln()` elementwise (`sklearn/svm/_base.py:866-894`:
1781    /// `np.log(self.predict_proba(X))`).
1782    ///
1783    /// # Errors
1784    ///
1785    /// Returns [`FerroError::NotFitted`] when the model was fitted with
1786    /// `probability=false` (delegated from [`Self::predict_proba`]).
1787    pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1788        self.predict_proba(x).map(|p| p.mapv(F::ln))
1789    }
1790
1791    /// Whether Platt-scaling probability estimates were fitted
1792    /// (`probability`, `sklearn/svm/_classes.py`); when `false`,
1793    /// [`Self::predict_proba`]/[`Self::predict_log_proba`] raise.
1794    #[must_use]
1795    pub fn probability(&self) -> bool {
1796        self.probability
1797    }
1798
1799    /// The per-ovo-pair Platt sigmoid `A` parameters (`probA_`,
1800    /// `sklearn/svm/_base.py`), length `n_class·(n_class-1)/2`. Empty when
1801    /// fitted with `probability=false`.
1802    #[must_use]
1803    pub fn prob_a(&self) -> Array1<F> {
1804        Array1::from_vec(self.prob_a.clone())
1805    }
1806
1807    /// The per-ovo-pair Platt sigmoid `B` parameters (`probB_`,
1808    /// `sklearn/svm/_base.py`), length `n_class·(n_class-1)/2`. Empty when
1809    /// fitted with `probability=false`.
1810    #[must_use]
1811    pub fn prob_b(&self) -> Array1<F> {
1812        Array1::from_vec(self.prob_b.clone())
1813    }
1814}
1815
1816impl<F: Float + ScalarOperand + 'static, K: Kernel<F>> FittedSVC<F, K> {
1817    /// Build the global, per-class-grouped support-vector index set, mirroring
1818    /// libsvm's `support_` layout (`sklearn/svm/_base.py:318-410`): the indices
1819    /// of the training rows that are a support vector in AT LEAST ONE ovo
1820    /// binary problem, deduplicated, grouped by class (all of class
1821    /// `classes_[0]` first, then `classes_[1]`, ...), ascending within a class.
1822    ///
1823    /// Returns `(support, per_class_indices)` where `support` is the flat
1824    /// grouped index vector and `per_class_indices[c]` is the (ascending)
1825    /// list of global training-row indices that are SVs for class
1826    /// `classes_[c]`.
1827    fn build_support(&self) -> (Vec<usize>, Vec<Vec<usize>>) {
1828        let n_classes = self.classes.len();
1829        // Per-class set of training-row indices that are an SV anywhere.
1830        let mut per_class: Vec<Vec<usize>> = vec![Vec::new(); n_classes];
1831        let mut seen: Vec<bool> = vec![false; self.y_train.len()];
1832
1833        for model in &self.binary_models {
1834            for &idx in &model.sv_indices {
1835                if !seen[idx] {
1836                    seen[idx] = true;
1837                    let cls = self.y_train[idx];
1838                    if let Some(ci) = self.classes.iter().position(|&c| c == cls) {
1839                        per_class[ci].push(idx);
1840                    }
1841                }
1842            }
1843        }
1844
1845        for group in &mut per_class {
1846            group.sort_unstable();
1847        }
1848
1849        let support: Vec<usize> = per_class.iter().flatten().copied().collect();
1850        (support, per_class)
1851    }
1852
1853    /// Indices of the support vectors into the training set, **grouped by
1854    /// class** (all class-`classes_[0]` SVs first, then `classes_[1]`, ...),
1855    /// ascending within each class.
1856    ///
1857    /// Mirrors `SVC.support_` (`sklearn/svm/_base.py:318-410`).
1858    #[must_use]
1859    pub fn support(&self) -> Array1<usize> {
1860        let (support, _) = self.build_support();
1861        Array1::from_vec(support)
1862    }
1863
1864    /// The support vectors `X[support_]`, shape `(n_SV, n_features)`.
1865    ///
1866    /// Mirrors `SVC.support_vectors_` (`sklearn/svm/_base.py:318-410`).
1867    #[must_use]
1868    pub fn support_vectors(&self) -> Array2<F> {
1869        let (support, _) = self.build_support();
1870        let n_features = self.x_train.ncols();
1871        let mut out = Array2::<F>::zeros((support.len(), n_features));
1872        for (row, &idx) in support.iter().enumerate() {
1873            out.row_mut(row).assign(&self.x_train.row(idx));
1874        }
1875        out
1876    }
1877
1878    /// Number of support vectors per class (`n_support_`,
1879    /// `sklearn/svm/_base.py:668-682`), parallel to `classes_`.
1880    #[must_use]
1881    pub fn n_support(&self) -> Vec<usize> {
1882        let (_, per_class) = self.build_support();
1883        per_class.iter().map(Vec::len).collect()
1884    }
1885
1886    /// Dual coefficients in the libsvm public layout, shape
1887    /// `(n_class - 1, n_SV)` (`sklearn/svm/_base.py:318-410`, the `dual_coef_`
1888    /// attribute), columns in `support_` (per-class-grouped) order.
1889    ///
1890    /// For an SV belonging to class `i`, row `m` holds its coefficient in the
1891    /// binary classifier between class `i` and the `m`-th OTHER class (the
1892    /// other classes in increasing index order, skipping `i`). In the ovo pair
1893    /// `(a, b)` with `a < b`, libsvm uses class `a` as the `+1` side and `b` as
1894    /// `-1`; the stored coefficient is `alpha * y_libsvm`.
1895    ///
1896    /// This crate stores `alpha * y` per pair with the OPPOSITE sign
1897    /// (`class_neg = a` mapped to `-1`), so the libsvm-internal coefficient is
1898    /// the negation of the stored value. For `n_class == 2` sklearn negates the
1899    /// internal coefficient again to form the PUBLIC binary attribute
1900    /// (`sklearn/svm/_base.py:258-262`: `dual_coef_ = -dual_coef_`), which
1901    /// leaves the public binary value equal to this crate's stored value; for
1902    /// `n_class > 2` the public value IS the libsvm-internal value (no flip).
1903    #[must_use]
1904    pub fn dual_coef(&self) -> Array2<F> {
1905        let n_classes = self.classes.len();
1906        let (support, _per_class) = self.build_support();
1907        let n_sv = support.len();
1908
1909        if n_classes == 2 {
1910            // Binary: public dual_coef_ = -internal, and internal = -stored,
1911            // so public = stored. The single ovo model holds one stored coef
1912            // per SV keyed by training index; map them into support_ column
1913            // order.
1914            let mut out = Array2::<F>::zeros((1, n_sv));
1915            if let Some(model) = self.binary_models.first() {
1916                for (sv_idx, &coef) in model.sv_indices.iter().zip(model.dual_coefs.iter()) {
1917                    if let Some(col) = support.iter().position(|&s| s == *sv_idx) {
1918                        out[[0, col]] = coef;
1919                    }
1920                }
1921            }
1922            return out;
1923        }
1924
1925        // Multiclass: public dual_coef_ = libsvm-internal = -(stored). Row m
1926        // for an SV of class i is its coefficient in the pair (i, m-th other).
1927        let mut out = Array2::<F>::zeros((n_classes - 1, n_sv));
1928
1929        // Column index in `support_` for a given training-row index.
1930        let col_of: HashMap<usize, usize> =
1931            support.iter().enumerate().map(|(c, &i)| (i, c)).collect();
1932
1933        for model in &self.binary_models {
1934            let a = model.class_neg; // lower-index class in the pair
1935            let b = model.class_pos; // higher-index class
1936            let ai = self.classes.iter().position(|&c| c == a);
1937            let bi = self.classes.iter().position(|&c| c == b);
1938            let (ai, bi) = match (ai, bi) {
1939                (Some(ai), Some(bi)) => (ai, bi),
1940                _ => continue,
1941            };
1942
1943            for (sv_idx, &stored) in model.sv_indices.iter().zip(model.dual_coefs.iter()) {
1944                let Some(&col) = col_of.get(sv_idx) else {
1945                    continue;
1946                };
1947                let cls = self.y_train[*sv_idx];
1948                let internal = stored.neg(); // libsvm internal = -(stored)
1949                // Determine which row this pair occupies for class `cls`:
1950                // the count of OTHER classes with index < the partner's index.
1951                let (own_class_index, partner_class_index) =
1952                    if cls == a { (ai, bi) } else { (bi, ai) };
1953                // Row m = number of other classes (excluding own) with class
1954                // index < partner_class_index.
1955                let mut row = 0usize;
1956                for ci in 0..n_classes {
1957                    if ci == own_class_index {
1958                        continue;
1959                    }
1960                    if ci < partner_class_index {
1961                        row += 1;
1962                    }
1963                }
1964                out[[row, col]] = internal;
1965            }
1966        }
1967
1968        out
1969    }
1970
1971    /// The per-ovo-problem intercepts, length `n_class * (n_class - 1) / 2`,
1972    /// in pair order `(0,1),(0,2),(1,2),...` (`intercept_`,
1973    /// `sklearn/svm/_base.py:318-410`). For `n_class == 2` sklearn negates the
1974    /// internal bias to form the public attribute
1975    /// (`sklearn/svm/_base.py:258-262`: `intercept_ *= -1`).
1976    ///
1977    /// This crate's per-pair `bias` is recovered for the decision function
1978    /// `sum coef*K + bias` with `class_pos` (the higher index) as the `+1`
1979    /// side. libsvm/sklearn use the lower-index class as `+1`, so the
1980    /// libsvm-internal intercept is the negation of this crate's `bias`. For
1981    /// binary, the public attribute negates the internal again, leaving the
1982    /// public value equal to this crate's stored `bias`; for multiclass the
1983    /// public value IS the internal `-bias`.
1984    #[must_use]
1985    pub fn intercept(&self) -> Array1<F> {
1986        let n_classes = self.classes.len();
1987        let vals: Vec<F> = if n_classes == 2 {
1988            self.binary_models.iter().map(|m| m.bias).collect()
1989        } else {
1990            self.binary_models.iter().map(|m| m.bias.neg()).collect()
1991        };
1992        Array1::from_vec(vals)
1993    }
1994
1995    /// Primal weight vector `coef_ = dual_coef_ @ support_vectors_`, shape
1996    /// `(n_class - 1, n_features)` — available ONLY for the linear kernel
1997    /// (`sklearn/svm/_base.py:650-666`). Returns `None` for any other kernel
1998    /// (sklearn raises `AttributeError`).
1999    #[must_use]
2000    pub fn coef(&self) -> Option<Array2<F>> {
2001        if !self.kernel.is_linear() {
2002            return None;
2003        }
2004        let dual = self.dual_coef(); // (n_class-1, n_SV)
2005        let svs = self.support_vectors(); // (n_SV, n_features)
2006        Some(dual.dot(&svs))
2007    }
2008}
2009
2010impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
2011    Fit<Array2<F>, Array1<usize>> for SVC<F, K>
2012{
2013    type Fitted = FittedSVC<F, K>;
2014    type Error = FerroError;
2015
2016    /// Fit the SVC model using SMO.
2017    ///
2018    /// # Errors
2019    ///
2020    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
2021    /// sample counts.
2022    /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
2023    /// Returns [`FerroError::InsufficientSamples`] if fewer than 2 classes.
2024    fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedSVC<F, K>, FerroError> {
2025        let (n_samples, _n_features) = x.dim();
2026
2027        if self.c <= F::zero() {
2028            return Err(FerroError::InvalidParameter {
2029                name: "C".into(),
2030                reason: "must be positive".into(),
2031            });
2032        }
2033
2034        // Reject non-finite input (NaN / +/-inf) in X BEFORE the X/y length
2035        // (`ShapeMismatch`) check, mirroring sklearn's `BaseLibSVM.fit` ->
2036        // `_validate_data(X, y, ...)` (`sklearn/svm/_base.py:190`) which routes to
2037        // `check_X_y`: `check_array(X, force_all_finite=True)` runs BEFORE
2038        // `check_consistent_length(X, y)` (`_base.py:208`), so on an input that is
2039        // BOTH non-finite AND length-mismatched sklearn raises the finiteness
2040        // `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`,
2041        // NOT a consistency error (#2270). `y` is class labels
2042        // (`Array1<usize>`), already finite by type, so only `X` needs the float
2043        // finiteness check. `.iter().any(|v| !v.is_finite())` catches both NaN and
2044        // +/-inf; on finite input the guard never fires, so the fitted SVC
2045        // attributes (`support_`/`dual_coef_`/`intercept_`/`coef_`) are
2046        // byte-identical and a finite length-mismatch still yields `ShapeMismatch`.
2047        if x.iter().any(|v| !v.is_finite()) {
2048            return Err(FerroError::InvalidParameter {
2049                name: "X".into(),
2050                reason: "Input X contains NaN or infinity.".into(),
2051            });
2052        }
2053
2054        if n_samples != y.len() {
2055            return Err(FerroError::ShapeMismatch {
2056                expected: vec![n_samples],
2057                actual: vec![y.len()],
2058                context: "y length must match number of samples in X".into(),
2059            });
2060        }
2061
2062        // Determine unique classes.
2063        let mut classes: Vec<usize> = y.to_vec();
2064        classes.sort_unstable();
2065        classes.dedup();
2066
2067        if classes.len() < 2 {
2068            return Err(FerroError::InsufficientSamples {
2069                required: 2,
2070                actual: classes.len(),
2071                context: "SVC requires at least 2 distinct classes".into(),
2072            });
2073        }
2074
2075        // Resolve any data-dependent kernel parameters (e.g. a `None` gamma ->
2076        // sklearn's default `gamma='scale'` = 1/(n_features * X.var()),
2077        // `_base.py:236-239`) against the training data BEFORE fitting, and use
2078        // this resolved kernel for both fitting and prediction.
2079        let kernel = self.kernel.resolved_for_fit(x);
2080
2081        // Convert data to Vec<Vec<F>> for kernel cache.
2082        let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();
2083
2084        // Per-class weights computed ONCE over the FULL y (libsvm's
2085        // `class_weight_ = compute_class_weight(class_weight, classes, y)`,
2086        // `_base.py:740`); `weighted_C[c] = C·class_weight_[c]`.
2087        let y_vec: Vec<usize> = y.to_vec();
2088        let weights = compute_class_weight(&self.class_weight, &classes, &y_vec);
2089
2090        // One-vs-one: train one binary SVM per pair.
2091        let n_classes = classes.len();
2092        let mut binary_models = Vec::new();
2093        // Per-pair Platt sigmoid params (only filled when `probability`).
2094        let mut prob_a: Vec<F> = Vec::new();
2095        let mut prob_b: Vec<F> = Vec::new();
2096
2097        for ci in 0..n_classes {
2098            for cj in (ci + 1)..n_classes {
2099                let class_neg = classes[ci];
2100                let class_pos = classes[cj];
2101
2102                // Per-class box bounds for this ovo pair `(class_neg, class_pos)`:
2103                // the `y=+1` group (class_pos = classes[cj]) gets `Cp = C·w[cj]`
2104                // and the `y=-1` group (class_neg = classes[ci]) gets
2105                // `Cn = C·w[ci]` (`weighted_C`, `_base.py:740`). The `weights`
2106                // vector is aligned to `classes`, so the class-index = the
2107                // position in `classes` (`ci`/`cj`).
2108                let cp = self.c * weights[cj];
2109                let cn = self.c * weights[ci];
2110
2111                // Extract samples for these two classes.
2112                let mut sub_data = Vec::new();
2113                let mut sub_labels = Vec::new();
2114                let mut sub_indices = Vec::new();
2115
2116                for s in 0..n_samples {
2117                    let label = y[s];
2118                    if label == class_neg {
2119                        sub_data.push(data[s].clone());
2120                        sub_labels.push(-F::one());
2121                        sub_indices.push(s);
2122                    } else if label == class_pos {
2123                        sub_data.push(data[s].clone());
2124                        sub_labels.push(F::one());
2125                        sub_indices.push(s);
2126                    }
2127                }
2128
2129                let result = smo_binary(
2130                    &sub_data,
2131                    &sub_labels,
2132                    &kernel,
2133                    cp,
2134                    cn,
2135                    self.tol,
2136                    self.max_iter,
2137                    self.cache_size,
2138                )?;
2139
2140                // Extract support vectors (non-zero alphas).
2141                let eps = F::from(1e-8).unwrap_or_else(F::epsilon);
2142                let mut sv_data = Vec::new();
2143                let mut sv_coefs = Vec::new();
2144                let mut sv_idx = Vec::new();
2145
2146                for (k, &alpha) in result.alphas.iter().enumerate() {
2147                    if alpha > eps {
2148                        sv_data.push(sub_data[k].clone());
2149                        sv_coefs.push(alpha * sub_labels[k]);
2150                        // Record the ORIGINAL training-row index of this SV
2151                        // (sub_indices maps the per-pair row k back to X).
2152                        sv_idx.push(sub_indices[k]);
2153                    }
2154                }
2155
2156                // Platt-scaling CV for this ovo pair (only when probability).
2157                // The CV sub-models are C-SVC (the SAME svm_type as the outer
2158                // model, libsvm `svm.cpp:2147-2150`): the `train_fold` closure
2159                // wraps `smo_binary` + SV extraction, returning the fitted
2160                // sub-model in this crate's sign (`class_pos = +1`).
2161                if self.probability {
2162                    let (tol, max_iter, cache_size) = (self.tol, self.max_iter, self.cache_size);
2163                    let sub_eps = F::from(1e-8).unwrap_or_else(F::epsilon);
2164                    let (a, b) = platt_cv_sigmoid(
2165                        &sub_data,
2166                        &sub_labels,
2167                        &kernel,
2168                        |tr_data: &[Vec<F>], tr_labels: &[F]| {
2169                            let sub = smo_binary(
2170                                tr_data, tr_labels, &kernel, cp, cn, tol, max_iter, cache_size,
2171                            )
2172                            .ok()?;
2173                            let mut sv_d: Vec<Vec<F>> = Vec::new();
2174                            let mut sv_c: Vec<F> = Vec::new();
2175                            for (k, &alpha) in sub.alphas.iter().enumerate() {
2176                                if alpha > sub_eps {
2177                                    sv_d.push(tr_data[k].clone());
2178                                    sv_c.push(alpha * tr_labels[k]);
2179                                }
2180                            }
2181                            Some((sv_d, sv_c, sub.bias))
2182                        },
2183                    );
2184                    prob_a.push(a);
2185                    prob_b.push(b);
2186                }
2187
2188                binary_models.push(BinarySvm {
2189                    support_vectors: sv_data,
2190                    sv_indices: sv_idx,
2191                    dual_coefs: sv_coefs,
2192                    bias: result.bias,
2193                    class_neg,
2194                    class_pos,
2195                });
2196            }
2197        }
2198
2199        Ok(FittedSVC {
2200            kernel,
2201            binary_models,
2202            classes,
2203            x_train: x.clone(),
2204            y_train: y.to_vec(),
2205            decision_function_shape: self.decision_function_shape,
2206            break_ties: self.break_ties,
2207            probability: self.probability,
2208            prob_a,
2209            prob_b,
2210        })
2211    }
2212}
2213
2214impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
2215    for FittedSVC<F, K>
2216{
2217    type Output = Array1<usize>;
2218    type Error = FerroError;
2219
2220    /// Predict class labels for the given feature matrix.
2221    ///
2222    /// Uses one-vs-one voting (each binary model casts a vote, the most-voted
2223    /// class wins, ties broken toward the lower class index), matching libsvm's
2224    /// `super().predict` (`sklearn/svm/_base.py:813-814`).
2225    ///
2226    /// When `break_ties == true` AND [`SvmDecisionShape::Ovr`] AND
2227    /// `n_classes > 2`, ties are instead broken by the one-vs-rest decision
2228    /// confidence: `predict = argmax(decision_function(X))`
2229    /// (`sklearn/svm/_base.py:806-811`).
2230    ///
2231    /// # Errors
2232    ///
2233    /// Returns [`FerroError::ShapeMismatch`] if the number of features
2234    /// does not match the training data. Returns
2235    /// [`FerroError::InvalidParameter`] when `break_ties == true` and the
2236    /// decision-function shape is [`SvmDecisionShape::Ovo`]
2237    /// (`sklearn/svm/_base.py:801-804`).
2238    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
2239        let n_samples = x.nrows();
2240        let n_classes = self.classes.len();
2241
2242        // sklearn raises when break_ties=True and decision_function_shape='ovo'
2243        // (`_base.py:801-804`), regardless of n_classes.
2244        if self.break_ties && self.decision_function_shape == SvmDecisionShape::Ovo {
2245            return Err(FerroError::InvalidParameter {
2246                name: "break_ties".into(),
2247                reason: "break_ties must be False when decision_function_shape is 'ovo'".into(),
2248            });
2249        }
2250
2251        // break_ties=True, ovr, multiclass: predict = argmax(decision_function)
2252        // (`_base.py:806-811`). The ovr decision breaks vote ties by confidence.
2253        if self.break_ties && self.decision_function_shape == SvmDecisionShape::Ovr && n_classes > 2
2254        {
2255            let scores = self.decision_function(x)?;
2256            let mc = match scores {
2257                SvmScores::Multiclass(m) => m,
2258                // n_classes > 2 always yields the multiclass variant.
2259                SvmScores::Binary(_) => {
2260                    return Err(FerroError::InvalidParameter {
2261                        name: "break_ties".into(),
2262                        reason: "ovr decision function unavailable for break-tie predict".into(),
2263                    });
2264                }
2265            };
2266            let mut predictions = Array1::<usize>::zeros(n_samples);
2267            for s in 0..n_samples {
2268                // argmax over the row; ties keep the first (lowest) index via a
2269                // strictly-greater scan, matching numpy's argmax.
2270                let mut best_idx = 0usize;
2271                let mut best_val = mc[[s, 0]];
2272                for c in 1..n_classes {
2273                    if mc[[s, c]] > best_val {
2274                        best_val = mc[[s, c]];
2275                        best_idx = c;
2276                    }
2277                }
2278                predictions[s] = self.classes[best_idx];
2279            }
2280            return Ok(predictions);
2281        }
2282
2283        let mut predictions = Array1::<usize>::zeros(n_samples);
2284
2285        for s in 0..n_samples {
2286            let xi: Vec<F> = x.row(s).to_vec();
2287            let mut votes = vec![0usize; n_classes];
2288
2289            for model in &self.binary_models {
2290                let val = self.decision_value_binary(&xi, model);
2291                let winner = if val >= F::zero() {
2292                    model.class_pos
2293                } else {
2294                    model.class_neg
2295                };
2296                if let Some(idx) = self.classes.iter().position(|&c| c == winner) {
2297                    votes[idx] += 1;
2298                }
2299            }
2300
2301            // libsvm/sklearn break ovo vote ties toward the LOWER class index
2302            // (`sklearn/svm/_base.py:813-814`: `super().predict` -> libsvm
2303            // `svm_predict` keeps the first argmax). `classes` is ascending
2304            // (`np.unique(y)`), so a strictly-greater scan keeps the
2305            // first/lowest-index maximum — unlike `max_by_key`, which returns
2306            // the LAST maximum (the higher index).
2307            let mut best_class_idx = 0usize;
2308            let mut best_votes = 0usize;
2309            for (i, &v) in votes.iter().enumerate() {
2310                if v > best_votes {
2311                    best_votes = v;
2312                    best_class_idx = i;
2313                }
2314            }
2315
2316            predictions[s] = self.classes[best_class_idx];
2317        }
2318
2319        Ok(predictions)
2320    }
2321}
2322
2323// ---------------------------------------------------------------------------
2324// SVR (Support Vector Regressor)
2325// ---------------------------------------------------------------------------
2326
2327/// Support Vector Regressor.
2328///
2329/// Uses SMO to solve the epsilon-SVR dual problem.
2330///
2331/// # Type Parameters
2332///
2333/// - `F`: The floating-point type (`f32` or `f64`).
2334/// - `K`: The kernel type.
2335#[derive(Debug, Clone)]
2336pub struct SVR<F, K> {
2337    /// The kernel function.
2338    pub kernel: K,
2339    /// Regularization parameter.
2340    pub c: F,
2341    /// Epsilon tube width (insensitive loss zone).
2342    pub epsilon: F,
2343    /// Convergence tolerance.
2344    pub tol: F,
2345    /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
2346    /// sentinel meaning **no iteration limit** (the SMO runs to convergence).
2347    pub max_iter: usize,
2348    /// Size of the kernel evaluation LRU cache (perf-only; default `200`).
2349    pub cache_size: usize,
2350    /// Whether to use libsvm's shrinking heuristic (`sklearn` `shrinking`,
2351    /// default `true`). Accepted for API parity; does NOT alter the converged
2352    /// optimum (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
2353    pub shrinking: bool,
2354}
2355
2356impl<F: Float, K: Kernel<F>> SVR<F, K> {
2357    /// Create a new `SVR` with the given kernel and default hyperparameters
2358    /// matching sklearn (`sklearn/svm/_classes.py` `SVR.__init__`).
2359    ///
2360    /// Defaults: `C = 1.0`, `epsilon = 0.1`, `tol = 1e-3`, `max_iter = 0`
2361    /// (= sklearn `-1`, no iteration limit), `cache_size = 200`,
2362    /// `shrinking = true`.
2363    #[must_use]
2364    pub fn new(kernel: K) -> Self {
2365        Self {
2366            kernel,
2367            c: F::one(),
2368            epsilon: F::from(0.1).unwrap_or_else(F::epsilon),
2369            tol: F::from(1e-3).unwrap_or_else(F::epsilon),
2370            max_iter: 0,
2371            cache_size: 200,
2372            shrinking: true,
2373        }
2374    }
2375
2376    /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
2377    ///
2378    /// Accepted for API parity; does NOT alter the converged optimum
2379    /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
2380    #[must_use]
2381    pub fn with_shrinking(mut self, shrinking: bool) -> Self {
2382        self.shrinking = shrinking;
2383        self
2384    }
2385
2386    /// Set the regularization parameter C.
2387    #[must_use]
2388    pub fn with_c(mut self, c: F) -> Self {
2389        self.c = c;
2390        self
2391    }
2392
2393    /// Set the epsilon tube width.
2394    #[must_use]
2395    pub fn with_epsilon(mut self, epsilon: F) -> Self {
2396        self.epsilon = epsilon;
2397        self
2398    }
2399
2400    /// Set the convergence tolerance.
2401    #[must_use]
2402    pub fn with_tol(mut self, tol: F) -> Self {
2403        self.tol = tol;
2404        self
2405    }
2406
2407    /// Set the maximum number of SMO iterations.
2408    #[must_use]
2409    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
2410        self.max_iter = max_iter;
2411        self
2412    }
2413
2414    /// Set the kernel cache size.
2415    #[must_use]
2416    pub fn with_cache_size(mut self, cache_size: usize) -> Self {
2417        self.cache_size = cache_size;
2418        self
2419    }
2420}
2421
2422/// Fitted Support Vector Regressor.
2423///
2424/// Stores the support vectors, dual coefficients, and bias.
2425#[derive(Debug, Clone)]
2426pub struct FittedSVR<F, K> {
2427    /// The kernel used for predictions.
2428    kernel: K,
2429    /// Support vectors.
2430    support_vectors: Vec<Vec<F>>,
2431    /// Original training-row index of each support vector (parallel to
2432    /// `support_vectors`/`dual_coefs`), for the `support_` attribute.
2433    sv_indices: Vec<usize>,
2434    /// Dual coefficients (alpha_i* - alpha_i) for each support vector.
2435    dual_coefs: Vec<F>,
2436    /// Bias term.
2437    bias: F,
2438}
2439
2440impl<F: Float, K: Kernel<F>> FittedSVR<F, K> {
2441    /// Compute the decision function value for a single sample.
2442    fn decision_value(&self, x: &[F]) -> F {
2443        let mut val = self.bias;
2444        for (sv, &coef) in self.support_vectors.iter().zip(self.dual_coefs.iter()) {
2445            val = val + coef * self.kernel.compute(sv, x);
2446        }
2447        val
2448    }
2449
2450    /// Compute the raw decision function values for each sample.
2451    ///
2452    /// # Errors
2453    ///
2454    /// Returns `Ok` always (provided for API symmetry).
2455    pub fn decision_function(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
2456        let n_samples = x.nrows();
2457        let mut result = Array1::<F>::zeros(n_samples);
2458        for s in 0..n_samples {
2459            let xi: Vec<F> = x.row(s).to_vec();
2460            result[s] = self.decision_value(&xi);
2461        }
2462        Ok(result)
2463    }
2464
2465    /// Indices of the support vectors into the training set, ascending
2466    /// (`SVR.support_`, `sklearn/svm/_base.py:318-410`). SVR has a single
2467    /// "class", so there is no per-class grouping; the SVs are kept in
2468    /// training-row order.
2469    #[must_use]
2470    pub fn support(&self) -> Array1<usize> {
2471        Array1::from_vec(self.sv_indices.clone())
2472    }
2473
2474    /// The support vectors, shape `(n_SV, n_features)`
2475    /// (`SVR.support_vectors_`).
2476    #[must_use]
2477    pub fn support_vectors(&self) -> Array2<F> {
2478        let n_sv = self.support_vectors.len();
2479        let n_features = self.support_vectors.first().map_or(0, Vec::len);
2480        let mut out = Array2::<F>::zeros((n_sv, n_features));
2481        for (r, sv) in self.support_vectors.iter().enumerate() {
2482            for (c, &v) in sv.iter().enumerate() {
2483                out[[r, c]] = v;
2484            }
2485        }
2486        out
2487    }
2488
2489    /// Number of support vectors. For SVR `n_support_` has size 1
2490    /// (`sklearn/svm/_base.py:680-682`).
2491    #[must_use]
2492    pub fn n_support(&self) -> Vec<usize> {
2493        vec![self.support_vectors.len()]
2494    }
2495
2496    /// Dual coefficients `alpha*_i - alpha_i`, shape `(1, n_SV)`
2497    /// (`SVR.dual_coef_`). No sign flip applies to SVR
2498    /// (`sklearn/svm/_base.py:260` restricts the flip to `c_svc`/`nu_svc`).
2499    #[must_use]
2500    pub fn dual_coef(&self) -> Array2<F> {
2501        let n_sv = self.dual_coefs.len();
2502        let mut out = Array2::<F>::zeros((1, n_sv));
2503        for (c, &v) in self.dual_coefs.iter().enumerate() {
2504            out[[0, c]] = v;
2505        }
2506        out
2507    }
2508
2509    /// The intercept, length 1 (`SVR.intercept_`). The SVR decision function is
2510    /// `sum coef*K + bias`, matching libsvm's `f(x) = ... + rho`, so the public
2511    /// intercept equals this crate's stored `bias` (no sign flip).
2512    #[must_use]
2513    pub fn intercept(&self) -> Array1<F> {
2514        Array1::from_vec(vec![self.bias])
2515    }
2516}
2517
2518/// Solve epsilon-SVR using SMO.
2519///
2520/// Reformulates the epsilon-SVR dual as a standard 2n-variable QP and
2521/// solves it with the Fan-Chen-Lin WSS approach, analogous to `smo_binary`.
2522///
2523/// The 2n variables are indexed 0..2n:
2524/// - Index `k` (k < n)  corresponds to alpha\*\_k  with label +1
2525/// - Index `k` (k >= n) corresponds to alpha\_{k-n} with label -1
2526///
2527/// The Q matrix is: `Q_{ij} = s_i * s_j * K(p_i, p_j)` where `s` is the
2528/// sign (+1 or -1) and `p` maps to the original sample index.
2529///
2530/// The linear term is: `q_k = epsilon - y_{p_k} * s_k`.
2531#[allow(clippy::too_many_arguments)]
2532fn smo_svr<F: Float, K: Kernel<F>>(
2533    data: &[Vec<F>],
2534    targets: &[F],
2535    kernel: &K,
2536    c: F,
2537    epsilon: F,
2538    tol: F,
2539    max_iter: usize,
2540    cache_size: usize,
2541) -> Result<(Vec<F>, F), FerroError> {
2542    let n = data.len();
2543    let m = 2 * n; // Total number of dual variables.
2544
2545    // Encoding: variable k in [0, m)
2546    //   k < n  => alpha*_k   (sign = +1, sample index = k)
2547    //   k >= n => alpha_{k-n} (sign = -1, sample index = k - n)
2548    //
2549    // The dual is: min 1/2 * beta^T Q beta + q^T beta
2550    //   s.t. 0 <= beta_k <= C, sum_k s_k * beta_k = 0
2551    // where beta_k = alpha*_k or alpha_{k-n},
2552    //       Q_{ij} = s_i * s_j * K(p_i, p_j),
2553    //       q_k    = epsilon - y_{p_k} * s_k.
2554    //
2555    // This has the same structure as the SVC dual.
2556
2557    let sign = |k: usize| -> F { if k < n { F::one() } else { -F::one() } };
2558    let sample = |k: usize| -> usize { if k < n { k } else { k - n } };
2559
2560    let mut beta = vec![F::zero(); m];
2561    let mut cache = KernelCache::new(cache_size);
2562
2563    // Gradient: grad_k = (Q * beta)_k + q_k.  Initially beta=0 so grad_k = q_k.
2564    // q_k = epsilon - y_{p_k} * s_k
2565    let mut grad: Vec<F> = (0..m)
2566        .map(|k| epsilon - targets[sample(k)] * sign(k))
2567        .collect();
2568
2569    let two = F::one() + F::one();
2570    let eps_num = F::from(1e-12).unwrap_or_else(F::epsilon);
2571
2572    // `max_iter == 0` is the sklearn `max_iter=-1` ("no iteration limit")
2573    // sentinel — run until the KKT gap closes; non-zero caps the count.
2574    let mut iter = 0usize;
2575    loop {
2576        if max_iter != 0 && iter >= max_iter {
2577            break;
2578        }
2579        iter += 1;
2580        // WSS: same as SVC but with the extended variables.
2581        // I_up  = {k : (s_k=+1 and beta_k < C) or (s_k=-1 and beta_k > 0)}
2582        // I_low = {k : (s_k=+1 and beta_k > 0) or (s_k=-1 and beta_k < C)}
2583        // Select i = argmax_{k in I_up}  -s_k * grad_k
2584        // Select j = argmin_{k in I_low} -s_k * grad_k
2585
2586        let mut i_up = None;
2587        let mut max_val = F::neg_infinity();
2588        let mut j_low = None;
2589        let mut min_val = F::infinity();
2590
2591        for k in 0..m {
2592            let sk = sign(k);
2593            let val = -sk * grad[k];
2594
2595            let in_up =
2596                (sk > F::zero() && beta[k] < c - eps_num) || (sk < F::zero() && beta[k] > eps_num);
2597            let in_low =
2598                (sk > F::zero() && beta[k] > eps_num) || (sk < F::zero() && beta[k] < c - eps_num);
2599
2600            if in_up && val > max_val {
2601                max_val = val;
2602                i_up = Some(k);
2603            }
2604            if in_low && val < min_val {
2605                min_val = val;
2606                j_low = Some(k);
2607            }
2608        }
2609
2610        if i_up.is_none() || j_low.is_none() || max_val - min_val < tol {
2611            break;
2612        }
2613
2614        let i = i_up.unwrap();
2615        let j = j_low.unwrap();
2616
2617        if i == j {
2618            break;
2619        }
2620
2621        let si = sign(i);
2622        let sj = sign(j);
2623        let pi = sample(i);
2624        let pj = sample(j);
2625
2626        // Q_{ii} = si*si*K(pi,pi) = K(pi,pi),  similarly for jj and ij
2627        let kii = cache.get_or_compute(pi, pi, kernel, data);
2628        let kjj = cache.get_or_compute(pj, pj, kernel, data);
2629        let kij = cache.get_or_compute(pi, pj, kernel, data);
2630
2631        // eta = Q_{ii} + Q_{jj} - 2*Q_{ij} = K(pi,pi) + K(pj,pj) - 2*si*sj*K(pi,pj)
2632        let eta = kii + kjj - two * si * sj * kij;
2633
2634        if eta <= eps_num {
2635            continue;
2636        }
2637
2638        // Bounds for beta_j
2639        let old_bi = beta[i];
2640        let old_bj = beta[j];
2641
2642        let (lo, hi) = if si == sj {
2643            let sum = old_bi + old_bj;
2644            ((sum - c).max(F::zero()), sum.min(c))
2645        } else {
2646            let diff = old_bj - old_bi;
2647            (diff.max(F::zero()), (c + diff).min(c))
2648        };
2649
2650        if (hi - lo).abs() < eps_num {
2651            continue;
2652        }
2653
2654        // Analytic update: beta_j += s_j * (E_i - E_j) / eta
2655        // where E_k = s_k * grad_k
2656        let mut new_bj = old_bj + sj * (si * grad[i] - sj * grad[j]) / eta;
2657
2658        if new_bj > hi {
2659            new_bj = hi;
2660        }
2661        if new_bj < lo {
2662            new_bj = lo;
2663        }
2664
2665        if (new_bj - old_bj).abs() < eps_num {
2666            continue;
2667        }
2668
2669        let new_bi = old_bi + si * sj * (old_bj - new_bj);
2670
2671        beta[i] = new_bi;
2672        beta[j] = new_bj;
2673
2674        // Update gradient: grad_k += delta_bi * Q_{k,i} + delta_bj * Q_{k,j}
2675        // Q_{k,t} = s_k * s_t * K(p_k, p_t)
2676        let delta_bi = new_bi - old_bi;
2677        let delta_bj = new_bj - old_bj;
2678
2679        for (k, grad_k) in grad.iter_mut().enumerate() {
2680            let sk = sign(k);
2681            let pk = sample(k);
2682            let kki = cache.get_or_compute(pk, pi, kernel, data);
2683            let kkj = cache.get_or_compute(pk, pj, kernel, data);
2684            *grad_k = *grad_k + delta_bi * sk * si * kki + delta_bj * sk * sj * kkj;
2685        }
2686    }
2687
2688    // Recover alpha*_i = beta_i (i < n) and alpha_i = beta_{i+n} (i >= n).
2689    // Coefficient for prediction: coef_i = alpha*_i - alpha_i.
2690    let coefs: Vec<F> = (0..n).map(|i| beta[i] - beta[i + n]).collect();
2691
2692    // Compute bias from KKT conditions on free support vectors.
2693    // For k where 0 < beta_k < C:
2694    //   grad_k = 0 at optimality => (Q*beta)_k + q_k = 0
2695    //   sum_t beta_t * s_k * s_t * K(p_k, p_t) + epsilon - y_{p_k} * s_k = 0
2696    //   s_k * sum_t (beta_t * s_t) * K(p_k, p_t) = y_{p_k} * s_k - epsilon
2697    //   sum_t coef_t_effective * K(p_k, p_t) = y_{p_k} - epsilon / s_k  (nah, let's use f directly)
2698    //
2699    // Prediction: f(x) = sum_i coef_i * K(x_i, x) + b
2700    // For free alpha*_i (0 < alpha*_i < C): y_i - f(x_i) = epsilon  => b = y_i - epsilon - sum coef_j * K(i,j)
2701    // For free alpha_i  (0 < alpha_i  < C): f(x_i) - y_i = epsilon  => b = y_i + epsilon - sum coef_j * K(i,j)
2702
2703    let mut b_sum = F::zero();
2704    let mut b_count = 0usize;
2705
2706    for i in 0..n {
2707        let mut kernel_sum = F::zero();
2708        let has_free = (beta[i] > eps_num && beta[i] < c - eps_num)
2709            || (beta[i + n] > eps_num && beta[i + n] < c - eps_num);
2710
2711        if !has_free {
2712            continue;
2713        }
2714
2715        for (j, &cj) in coefs.iter().enumerate() {
2716            if cj.abs() > eps_num {
2717                kernel_sum = kernel_sum + cj * cache.get_or_compute(i, j, kernel, data);
2718            }
2719        }
2720
2721        if beta[i] > eps_num && beta[i] < c - eps_num {
2722            // alpha*_i is free: y_i - f(x_i) = epsilon => b = y_i - epsilon - kernel_sum
2723            b_sum = b_sum + targets[i] - epsilon - kernel_sum;
2724            b_count += 1;
2725        }
2726        if beta[i + n] > eps_num && beta[i + n] < c - eps_num {
2727            // alpha_i is free: f(x_i) - y_i = epsilon => b = y_i + epsilon - kernel_sum
2728            b_sum = b_sum + targets[i] + epsilon - kernel_sum;
2729            b_count += 1;
2730        }
2731    }
2732
2733    let bias = if b_count > 0 {
2734        b_sum / F::from(b_count).unwrap()
2735    } else {
2736        F::zero()
2737    };
2738
2739    Ok((coefs, bias))
2740}
2741
2742// ---------------------------------------------------------------------------
2743// Solver_NU — the libsvm nu-parameterized solver (nu-SVC / nu-SVR)
2744// ---------------------------------------------------------------------------
2745
2746/// Output of the generic [`solver_nu_core`] solve.
2747struct NuResult<F> {
2748    /// The dual variables `alpha` (length `l`, the solver-internal variables).
2749    alpha: Vec<F>,
2750    /// `rho = (r1 - r2) / 2` (the per-pair bias term, `Solver_NU::calculate_rho`
2751    /// returns this; `sklearn/svm/src/libsvm/svm.cpp:1417`).
2752    rho: F,
2753    /// `r = (r1 + r2) / 2` (`si->r`, the nu-SVC `/r` rescale factor /
2754    /// the nu-SVR `-epsilon`, `svm.cpp:1416`).
2755    r: F,
2756}
2757
2758/// The generic libsvm `Solver_NU` core: solves
2759/// `min 0.5 αᵀQα + pᵀα  s.t.  0≤α_k≤C_k,  yᵀα=0,  eᵀα=const`
2760/// where `Q[k][t] = y_k·y_t·K(sample(k), sample(t))`, with the nu-specific
2761/// second-order working-set selection (separately over the `y=+1` / `y=-1`
2762/// groups, four running maxima `Gmaxp/Gmaxp2/Gmaxn/Gmaxn2`) and the
2763/// two-group `rho`/`r` recovery.
2764///
2765/// A faithful transcription of libsvm's `Solver_NU` + the shared
2766/// `Solver::Solve` update step (`sklearn/svm/src/libsvm/svm.cpp:1166-1418`
2767/// for the nu-specific `select_working_set`/`calculate_rho`, and `:663-940`
2768/// for the gradient init + analytic 2-variable update + objective). Like the
2769/// existing [`smo_binary`]/[`smo_svr`] solvers this is a NATURAL-ORDER,
2770/// no-shrinking, DETERMINISTIC variant: libsvm's shrinking heuristic
2771/// (`Solver_NU::do_shrinking`, `svm.cpp:1318`) is a performance optimization
2772/// that does NOT change the converged optimum (R-DEV-7), so it is omitted; the
2773/// `active_size` always equals `l`. The result (`alpha`, `rho`, `r`) is in
2774/// libsvm's convention so the caller can reconstruct `dual_coef_`/`intercept_`
2775/// that match the live `NuSVC`/`NuSVR` oracle after the public sign flip.
2776///
2777/// Arguments:
2778/// - `data`: the ORIGINAL per-sample feature rows (length `n`), keyed by the
2779///   [`KernelCache`] via `sample(i)` for the kernel evaluation.
2780/// - `m`: number of solver variables (`l` in libsvm; `= n` for nu-SVC,
2781///   `= 2n` for nu-SVR).
2782/// - `sample`: maps a solver variable index `k` to the original sample index
2783///   (into `data`) used for the kernel evaluation `K(sample(i), sample(j))`.
2784/// - `y`: the per-variable sign (`+1` / `-1`).
2785/// - `p`: the linear term (`p[k]`; `0` for nu-SVC, `∓prob.y` for nu-SVR).
2786/// - `c`: the per-variable upper bound `C_k`.
2787/// - `alpha`: the (greedily-initialized) starting dual variables, modified
2788///   in place into the solution.
2789#[allow(
2790    clippy::too_many_arguments,
2791    reason = "a faithful transcription of libsvm's Solver_NU::Solve threads the \
2792              problem (n, m, sample, y, p, c, alpha) + hyperparameters through \
2793              one call; splitting them would obscure the C oracle correspondence"
2794)]
2795#[allow(
2796    clippy::too_many_lines,
2797    reason = "a one-to-one transcription of libsvm's Solver_NU select_working_set \
2798              + the shared Solver analytic 2-variable update + calculate_rho \
2799              (svm.cpp:663-940, 1186-1418); kept inline to preserve the \
2800              line-by-line correspondence to the C oracle"
2801)]
2802fn solver_nu_core<F: Float, K: Kernel<F>>(
2803    data: &[Vec<F>],
2804    m: usize,
2805    sample: &dyn Fn(usize) -> usize,
2806    y: &[F],
2807    p: &[F],
2808    c: &[F],
2809    mut alpha: Vec<F>,
2810    kernel: &K,
2811    tol: F,
2812    max_iter: usize,
2813    cache_size: usize,
2814) -> NuResult<F> {
2815    let zero = F::zero();
2816    let two = F::one() + F::one();
2817    // libsvm's TAU (`svm.cpp:316`): a tiny positive floor for the quadratic
2818    // coefficient when the kernel is not strictly positive-definite.
2819    let tau = F::from(1e-12).unwrap_or_else(F::epsilon);
2820    let eps_bound = F::from(1e-12).unwrap_or_else(F::epsilon);
2821
2822    let mut cache = KernelCache::new(cache_size);
2823    // `QD[i] = K(sample(i), sample(i))` (since `y_i^2 = 1`, `Q_ii = K(i,i)`).
2824    // The cache keys on the ORIGINAL-sample index `sample(i)` into `data`.
2825    let qd: Vec<F> = (0..m)
2826        .map(|i| {
2827            let si = sample(i);
2828            cache.get_or_compute(si, si, kernel, data)
2829        })
2830        .collect();
2831
2832    // `q(i, j) = y_i·y_j·K(sample(i), sample(j))` (libsvm `SVC_Q::get_Q`,
2833    // `svm.cpp:1436-1446`). The cache is keyed by original-sample index.
2834    let q_entry = |i: usize, j: usize, cache: &mut KernelCache<F>| -> F {
2835        y[i] * y[j] * cache.get_or_compute(sample(i), sample(j), kernel, data)
2836    };
2837
2838    // Bound predicates (`Solver::update_alpha_status`, `svm.cpp:588-598`).
2839    let is_upper = |k: usize, a: &[F]| -> bool { a[k] >= c[k] - eps_bound };
2840    let is_lower = |k: usize, a: &[F]| -> bool { a[k] <= eps_bound };
2841
2842    // Gradient `G[k] = (Q·alpha)_k + p[k]` (`svm.cpp:693-715`). Since the
2843    // greedy init has some `alpha_k > 0`, accumulate the full product.
2844    let mut grad: Vec<F> = p.to_vec();
2845    #[allow(
2846        clippy::needless_range_loop,
2847        reason = "i indexes alpha while the inner loop forms Q[i][j]·alpha[i]"
2848    )]
2849    for i in 0..m {
2850        if alpha[i] > eps_bound {
2851            let ai = alpha[i];
2852            for (j, g) in grad.iter_mut().enumerate() {
2853                *g = *g + ai * q_entry(i, j, &mut cache);
2854            }
2855        }
2856    }
2857
2858    let mut iter = 0usize;
2859    loop {
2860        if max_iter != 0 && iter >= max_iter {
2861            break;
2862        }
2863        iter += 1;
2864
2865        // ---- Solver_NU::select_working_set (svm.cpp:1186-1296) ----
2866        let mut gmaxp = F::neg_infinity();
2867        let mut gmaxp2 = F::neg_infinity();
2868        let mut gmaxp_idx: isize = -1;
2869        let mut gmaxn = F::neg_infinity();
2870        let mut gmaxn2 = F::neg_infinity();
2871        let mut gmaxn_idx: isize = -1;
2872
2873        for t in 0..m {
2874            if y[t] > zero {
2875                if !is_upper(t, &alpha) && -grad[t] >= gmaxp {
2876                    gmaxp = -grad[t];
2877                    gmaxp_idx = t as isize;
2878                }
2879            } else if !is_lower(t, &alpha) && grad[t] >= gmaxn {
2880                gmaxn = grad[t];
2881                gmaxn_idx = t as isize;
2882            }
2883        }
2884
2885        let ip = gmaxp_idx;
2886        let in_ = gmaxn_idx;
2887
2888        let mut gmin_idx: isize = -1;
2889        let mut obj_diff_min = F::infinity();
2890
2891        for j in 0..m {
2892            if y[j] > zero {
2893                if !is_lower(j, &alpha) {
2894                    let grad_diff = gmaxp + grad[j];
2895                    if grad[j] >= gmaxp2 {
2896                        gmaxp2 = grad[j];
2897                    }
2898                    if grad_diff > zero && ip != -1 {
2899                        let ipi = ip as usize;
2900                        let quad_coef = qd[ipi] + qd[j] - two * q_entry(ipi, j, &mut cache);
2901                        let obj_diff = if quad_coef > zero {
2902                            -(grad_diff * grad_diff) / quad_coef
2903                        } else {
2904                            -(grad_diff * grad_diff) / tau
2905                        };
2906                        if obj_diff <= obj_diff_min {
2907                            gmin_idx = j as isize;
2908                            obj_diff_min = obj_diff;
2909                        }
2910                    }
2911                }
2912            } else if !is_upper(j, &alpha) {
2913                let grad_diff = gmaxn - grad[j];
2914                if -grad[j] >= gmaxn2 {
2915                    gmaxn2 = -grad[j];
2916                }
2917                if grad_diff > zero && in_ != -1 {
2918                    let ini = in_ as usize;
2919                    let quad_coef = qd[ini] + qd[j] - two * q_entry(ini, j, &mut cache);
2920                    let obj_diff = if quad_coef > zero {
2921                        -(grad_diff * grad_diff) / quad_coef
2922                    } else {
2923                        -(grad_diff * grad_diff) / tau
2924                    };
2925                    if obj_diff <= obj_diff_min {
2926                        gmin_idx = j as isize;
2927                        obj_diff_min = obj_diff;
2928                    }
2929                }
2930            }
2931        }
2932
2933        // Stopping criterion (`svm.cpp:1286`).
2934        if (gmaxp + gmaxp2).max(gmaxn + gmaxn2) < tol || gmin_idx == -1 {
2935            break;
2936        }
2937
2938        let i = if y[gmin_idx as usize] > zero {
2939            gmaxp_idx
2940        } else {
2941            gmaxn_idx
2942        };
2943        if i == -1 {
2944            break;
2945        }
2946        let i = i as usize;
2947        let j = gmin_idx as usize;
2948        if i == j {
2949            break;
2950        }
2951
2952        // ---- Solver::Solve analytic 2-variable update (svm.cpp:756-852) ----
2953        let q_ij = q_entry(i, j, &mut cache);
2954        let c_i = c[i];
2955        let c_j = c[j];
2956        let old_alpha_i = alpha[i];
2957        let old_alpha_j = alpha[j];
2958
2959        if y[i] != y[j] {
2960            // `Q_i[j] = y_i·y_j·K = -K(i,j)` here, so `+2·Q_i[j]` in libsvm.
2961            let mut quad_coef = qd[i] + qd[j] + two * q_ij;
2962            if quad_coef <= zero {
2963                quad_coef = tau;
2964            }
2965            let delta = (-grad[i] - grad[j]) / quad_coef;
2966            let diff = alpha[i] - alpha[j];
2967            alpha[i] = alpha[i] + delta;
2968            alpha[j] = alpha[j] + delta;
2969
2970            if diff > zero {
2971                if alpha[j] < zero {
2972                    alpha[j] = zero;
2973                    alpha[i] = diff;
2974                }
2975            } else if alpha[i] < zero {
2976                alpha[i] = zero;
2977                alpha[j] = -diff;
2978            }
2979            if diff > c_i - c_j {
2980                if alpha[i] > c_i {
2981                    alpha[i] = c_i;
2982                    alpha[j] = c_i - diff;
2983                }
2984            } else if alpha[j] > c_j {
2985                alpha[j] = c_j;
2986                alpha[i] = c_j + diff;
2987            }
2988        } else {
2989            let mut quad_coef = qd[i] + qd[j] - two * q_ij;
2990            if quad_coef <= zero {
2991                quad_coef = tau;
2992            }
2993            let delta = (grad[i] - grad[j]) / quad_coef;
2994            let sum = alpha[i] + alpha[j];
2995            alpha[i] = alpha[i] - delta;
2996            alpha[j] = alpha[j] + delta;
2997
2998            if sum > c_i {
2999                if alpha[i] > c_i {
3000                    alpha[i] = c_i;
3001                    alpha[j] = sum - c_i;
3002                }
3003            } else if alpha[j] < zero {
3004                alpha[j] = zero;
3005                alpha[i] = sum;
3006            }
3007            if sum > c_j {
3008                if alpha[j] > c_j {
3009                    alpha[j] = c_j;
3010                    alpha[i] = sum - c_j;
3011                }
3012            } else if alpha[i] < zero {
3013                alpha[i] = zero;
3014                alpha[j] = sum;
3015            }
3016        }
3017
3018        // ---- Update gradient (svm.cpp:856-862) ----
3019        let delta_alpha_i = alpha[i] - old_alpha_i;
3020        let delta_alpha_j = alpha[j] - old_alpha_j;
3021        #[allow(
3022            clippy::needless_range_loop,
3023            reason = "k indexes grad and is also the kernel column Q[k][i]/Q[k][j]"
3024        )]
3025        for k in 0..m {
3026            let q_ki = q_entry(k, i, &mut cache);
3027            let q_kj = q_entry(k, j, &mut cache);
3028            grad[k] = grad[k] + q_ki * delta_alpha_i + q_kj * delta_alpha_j;
3029        }
3030    }
3031
3032    // ---- Solver_NU::calculate_rho (svm.cpp:1370-1418) ----
3033    let mut nr_free1 = 0usize;
3034    let mut nr_free2 = 0usize;
3035    let mut ub1 = F::infinity();
3036    let mut ub2 = F::infinity();
3037    let mut lb1 = F::neg_infinity();
3038    let mut lb2 = F::neg_infinity();
3039    let mut sum_free1 = zero;
3040    let mut sum_free2 = zero;
3041
3042    for i in 0..m {
3043        let upper = is_upper(i, &alpha);
3044        let lower = is_lower(i, &alpha);
3045        if y[i] > zero {
3046            if upper {
3047                lb1 = lb1.max(grad[i]);
3048            } else if lower {
3049                ub1 = ub1.min(grad[i]);
3050            } else {
3051                nr_free1 += 1;
3052                sum_free1 = sum_free1 + grad[i];
3053            }
3054        } else if upper {
3055            lb2 = lb2.max(grad[i]);
3056        } else if lower {
3057            ub2 = ub2.min(grad[i]);
3058        } else {
3059            nr_free2 += 1;
3060            sum_free2 = sum_free2 + grad[i];
3061        }
3062    }
3063
3064    let r1 = if nr_free1 > 0 {
3065        sum_free1 / F::from(nr_free1).unwrap_or_else(F::one)
3066    } else {
3067        (ub1 + lb1) / two
3068    };
3069    let r2 = if nr_free2 > 0 {
3070        sum_free2 / F::from(nr_free2).unwrap_or_else(F::one)
3071    } else {
3072        (ub2 + lb2) / two
3073    };
3074
3075    NuResult {
3076        alpha,
3077        rho: (r1 - r2) / two,
3078        r: (r1 + r2) / two,
3079    }
3080}
3081
3082/// The recovered nu-SVC binary sub-model: support vectors, their `dual_coef`
3083/// values (`alpha_i·y_i/r`, libsvm's `sv_coef`), original indices, and the
3084/// libsvm-internal bias `-rho/r` (so the decision function is
3085/// `f(x) = Σ sv_coef·K(sv, x) - rho/r`).
3086pub(crate) struct NuSvcModel<F> {
3087    pub sv_data: Vec<Vec<F>>,
3088    pub sv_coefs: Vec<F>,
3089    pub sv_indices: Vec<usize>,
3090    /// libsvm-internal bias term: `-rho/r`. Used directly as the `+1`-side
3091    /// decision bias for the LOWER-index class (libsvm convention).
3092    pub bias_internal: F,
3093}
3094
3095/// Solve the libsvm **nu-SVC** dual for a single binary sub-problem
3096/// (`solve_nu_svc`, `sklearn/svm/src/libsvm/svm.cpp:1646-1708`):
3097/// `min 0.5 αᵀQα  s.t.  yᵀα=0, eᵀα=ν·l, 0≤α_i≤1`, `Q_ij=y_iy_jK`.
3098///
3099/// `data`/`labels` are the per-pair samples and signs (`+1` for the higher-index
3100/// `class_pos`, `-1` for the lower-index `class_neg`, matching this crate's
3101/// [`BinarySvm`] convention). The greedy `alpha` init (`svm.cpp:1667-1682`)
3102/// fills each class up to `min(C_i, nu·l/2 − running_sum)`. After
3103/// [`solver_nu_core`], libsvm rescales `alpha_i ← alpha_i·y_i/r` and
3104/// `rho ← rho/r` (`svm.cpp:1696-1702`); the support-vector coefficient is
3105/// `sv_coef = alpha·y/r` and the decision bias is `−rho/r`.
3106///
3107/// Returns `None` when `r ≈ 0` (degenerate — no usable rescale, e.g. a
3108/// pathological all-bound solution), letting the caller surface a clean error.
3109#[allow(
3110    clippy::too_many_arguments,
3111    reason = "the solver + per-pair samples/labels + hyperparameters thread \
3112              through one call mirroring libsvm's solve_nu_svc"
3113)]
3114pub(crate) fn solve_nu_svc<F: Float, K: Kernel<F>>(
3115    data: &[Vec<F>],
3116    labels: &[F],
3117    kernel: &K,
3118    nu: F,
3119    tol: F,
3120    max_iter: usize,
3121    cache_size: usize,
3122) -> Option<NuSvcModel<F>> {
3123    let l = data.len();
3124    let zero = F::zero();
3125    let one = F::one();
3126    let two = one + one;
3127    // `C[i] = prob->W[i] = 1` (unit instance weights, `svm.cpp:1664`).
3128    let c = vec![one; l];
3129
3130    // Greedy alpha init (`svm.cpp:1667-1682`): `nu_l = Σ nu·C[i] = nu·l`,
3131    // `sum_pos = sum_neg = nu_l/2`, fill each class greedily up to `C[i]`.
3132    let nu_l = nu * F::from(l).unwrap_or_else(F::zero);
3133    let mut sum_pos = nu_l / two;
3134    let mut sum_neg = nu_l / two;
3135    let mut alpha = vec![zero; l];
3136    for i in 0..l {
3137        if labels[i] > zero {
3138            alpha[i] = c[i].min(sum_pos);
3139            sum_pos = sum_pos - alpha[i];
3140        } else {
3141            alpha[i] = c[i].min(sum_neg);
3142            sum_neg = sum_neg - alpha[i];
3143        }
3144    }
3145
3146    let p = vec![zero; l]; // nu-SVC linear term is 0 (`zeros`, svm.cpp:1684).
3147    let sample = |k: usize| k;
3148    let res = solver_nu_core(
3149        data, l, &sample, labels, &p, &c, alpha, kernel, tol, max_iter, cache_size,
3150    );
3151
3152    let r = res.r;
3153    if r.abs() <= F::from(1e-12).unwrap_or_else(F::epsilon) {
3154        return None;
3155    }
3156
3157    // libsvm: `alpha_i *= y_i / r`, `rho /= r` (`svm.cpp:1696-1702`).
3158    // `sv_coef = alpha·y/r`; decision bias = `-rho/r`.
3159    let eps_sv = F::from(1e-8).unwrap_or_else(F::epsilon);
3160    let mut sv_data = Vec::new();
3161    let mut sv_coefs = Vec::new();
3162    let mut sv_indices = Vec::new();
3163    #[allow(
3164        clippy::needless_range_loop,
3165        reason = "i indexes alpha, the sign labels[i], and the sample data[i] together"
3166    )]
3167    for i in 0..l {
3168        let coef = res.alpha[i] * labels[i] / r;
3169        if coef.abs() > eps_sv {
3170            sv_data.push(data[i].clone());
3171            sv_coefs.push(coef);
3172            sv_indices.push(i);
3173        }
3174    }
3175
3176    Some(NuSvcModel {
3177        sv_data,
3178        sv_coefs,
3179        sv_indices,
3180        bias_internal: (res.rho / r).neg(),
3181    })
3182}
3183
3184/// The recovered nu-SVR model: prediction coefficients `α*−α` per sample,
3185/// support indices, and the bias.
3186pub(crate) struct NuSvrModel<F> {
3187    pub sv_data: Vec<Vec<F>>,
3188    pub sv_coefs: Vec<F>,
3189    pub sv_indices: Vec<usize>,
3190    /// Prediction bias: `f(x) = Σ coef·K(sv, x) + bias`. libsvm's decision
3191    /// function is `Σ coef·K − rho`, so `bias = −rho`.
3192    pub bias: F,
3193}
3194
3195/// Solve the libsvm **nu-SVR** dual (`solve_nu_svr`,
3196/// `sklearn/svm/src/libsvm/svm.cpp:1795-1839`): a `2l`-variable
3197/// `(α, α*)` dual with the learned-tube `nu` constraint, both `nu` AND `C`
3198/// used (`epsilon` is replaced by `nu`).
3199///
3200/// Variable layout (libsvm): `k < l` is `α*_k` with sign `+1` and linear term
3201/// `−y_k`; `k ≥ l` is `α_{k−l}` with sign `−1` and linear term `+y_{k−l}`;
3202/// `C_k = W·C` for all (`svm.cpp:1806-1824`). The greedy init fills both halves
3203/// to `min(sum, C)` where `sum = (Σ C·nu)/2` (`svm.cpp:1814-1817`). After
3204/// [`solver_nu_core`] the prediction coefficient is `coef_k = α*_k − α_k`
3205/// (`svm.cpp:1832-1833`) and the bias is `−rho` (libsvm `f = Σ coef·K − rho`).
3206#[allow(
3207    clippy::too_many_arguments,
3208    reason = "the solver + samples/targets + (nu, C) + hyperparameters thread \
3209              through one call mirroring libsvm's solve_nu_svr"
3210)]
3211pub(crate) fn solve_nu_svr<F: Float, K: Kernel<F>>(
3212    data: &[Vec<F>],
3213    targets: &[F],
3214    kernel: &K,
3215    nu: F,
3216    c_param: F,
3217    tol: F,
3218    max_iter: usize,
3219    cache_size: usize,
3220) -> NuSvrModel<F> {
3221    let l = data.len();
3222    let m = 2 * l;
3223    let zero = F::zero();
3224    let one = F::one();
3225    let two = one + one;
3226
3227    // `C[i] = C[i+l] = W·C` (`svm.cpp:1809`); `sum = (Σ C·nu)/2`.
3228    let c = vec![c_param; m];
3229    let mut sum = c_param * nu * F::from(l).unwrap_or_else(F::zero) / two;
3230
3231    let mut alpha = vec![zero; m];
3232    let mut y = vec![zero; m];
3233    let mut p = vec![zero; m];
3234    for i in 0..l {
3235        let a = sum.min(c[i]); // alpha2[i] = alpha2[i+l] = min(sum, C[i])
3236        alpha[i] = a;
3237        alpha[i + l] = a;
3238        sum = sum - a;
3239        p[i] = targets[i].neg(); // linear_term[i]   = -y_i
3240        y[i] = one;
3241        p[i + l] = targets[i]; // linear_term[i+l] = +y_i
3242        y[i + l] = -one;
3243    }
3244
3245    let sample = |k: usize| if k < l { k } else { k - l };
3246    let res = solver_nu_core(
3247        data, m, &sample, &y, &p, &c, alpha, kernel, tol, max_iter, cache_size,
3248    );
3249
3250    // coef_i = alpha2[i] - alpha2[i+l] = α*_i - α_i (`svm.cpp:1832-1833`).
3251    let eps_sv = F::from(1e-8).unwrap_or_else(F::epsilon);
3252    let mut sv_data = Vec::new();
3253    let mut sv_coefs = Vec::new();
3254    let mut sv_indices = Vec::new();
3255    #[allow(
3256        clippy::needless_range_loop,
3257        reason = "i pairs alpha2[i] with alpha2[i+l] and indexes the sample data[i]"
3258    )]
3259    for i in 0..l {
3260        let coef = res.alpha[i] - res.alpha[i + l];
3261        if coef.abs() > eps_sv {
3262            sv_data.push(data[i].clone());
3263            sv_coefs.push(coef);
3264            sv_indices.push(i);
3265        }
3266    }
3267
3268    NuSvrModel {
3269        sv_data,
3270        sv_coefs,
3271        sv_indices,
3272        bias: res.rho.neg(),
3273    }
3274}
3275
3276impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
3277    Fit<Array2<F>, Array1<F>> for SVR<F, K>
3278{
3279    type Fitted = FittedSVR<F, K>;
3280    type Error = FerroError;
3281
3282    /// Fit the SVR model using SMO.
3283    ///
3284    /// # Errors
3285    ///
3286    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
3287    /// sample counts.
3288    /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
3289    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedSVR<F, K>, FerroError> {
3290        let (n_samples, _n_features) = x.dim();
3291
3292        if self.c <= F::zero() {
3293            return Err(FerroError::InvalidParameter {
3294                name: "C".into(),
3295                reason: "must be positive".into(),
3296            });
3297        }
3298
3299        if n_samples == 0 {
3300            return Err(FerroError::InsufficientSamples {
3301                required: 1,
3302                actual: 0,
3303                context: "SVR requires at least one sample".into(),
3304            });
3305        }
3306
3307        // Reject non-finite input (NaN / +/-inf) in X or the float target y
3308        // BEFORE the X/y length (`ShapeMismatch`) check, mirroring sklearn's
3309        // `BaseLibSVM.fit` -> `_validate_data(X, y, ...)`
3310        // (`sklearn/svm/_base.py:190`) which routes to `check_X_y`:
3311        // `check_array(X, force_all_finite=True)` then `check_array(y)` both run
3312        // BEFORE `check_consistent_length(X, y)` (`_base.py:208`), so on an input
3313        // that is BOTH non-finite AND length-mismatched sklearn raises the
3314        // finiteness `ValueError("Input X contains NaN.")` /
3315        // `"Input y contains NaN."` / `"... contains infinity ..."`, NOT a
3316        // consistency error (#2270). SVR's `y` is float regression targets, so
3317        // both X and y are finiteness-checked. `.iter().any(|v| !v.is_finite())`
3318        // catches NaN and +/-inf; on finite input the guard never fires, so the
3319        // fitted SVR attributes are byte-identical and a finite length-mismatch
3320        // still yields `ShapeMismatch`.
3321        if x.iter().any(|v| !v.is_finite()) {
3322            return Err(FerroError::InvalidParameter {
3323                name: "X".into(),
3324                reason: "Input X contains NaN or infinity.".into(),
3325            });
3326        }
3327        if y.iter().any(|v| !v.is_finite()) {
3328            return Err(FerroError::InvalidParameter {
3329                name: "y".into(),
3330                reason: "Input y contains NaN or infinity.".into(),
3331            });
3332        }
3333
3334        if n_samples != y.len() {
3335            return Err(FerroError::ShapeMismatch {
3336                expected: vec![n_samples],
3337                actual: vec![y.len()],
3338                context: "y length must match number of samples in X".into(),
3339            });
3340        }
3341
3342        // Resolve any data-dependent kernel parameters (e.g. a `None` gamma ->
3343        // sklearn's default `gamma='scale'` = 1/(n_features * X.var()),
3344        // `_base.py:236-239`) against the training data BEFORE fitting, and use
3345        // this resolved kernel for both fitting and prediction.
3346        let kernel = self.kernel.resolved_for_fit(x);
3347
3348        let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();
3349        let targets: Vec<F> = y.to_vec();
3350
3351        let (coefs, bias) = smo_svr(
3352            &data,
3353            &targets,
3354            &kernel,
3355            self.c,
3356            self.epsilon,
3357            self.tol,
3358            self.max_iter,
3359            self.cache_size,
3360        )?;
3361
3362        // Extract support vectors (non-zero coefficients).
3363        let eps = F::from(1e-8).unwrap_or_else(F::epsilon);
3364        let mut sv_data = Vec::new();
3365        let mut sv_coefs = Vec::new();
3366        let mut sv_idx = Vec::new();
3367
3368        for (i, &coef) in coefs.iter().enumerate() {
3369            if coef.abs() > eps {
3370                sv_data.push(data[i].clone());
3371                sv_coefs.push(coef);
3372                sv_idx.push(i);
3373            }
3374        }
3375
3376        Ok(FittedSVR {
3377            kernel,
3378            support_vectors: sv_data,
3379            sv_indices: sv_idx,
3380            dual_coefs: sv_coefs,
3381            bias,
3382        })
3383    }
3384}
3385
3386impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
3387    for FittedSVR<F, K>
3388{
3389    type Output = Array1<F>;
3390    type Error = FerroError;
3391
3392    /// Predict target values for the given feature matrix.
3393    ///
3394    /// # Errors
3395    ///
3396    /// Returns `Ok` always for valid input.
3397    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3398        self.decision_function(x)
3399    }
3400}
3401
3402#[cfg(test)]
3403mod tests {
3404    use super::*;
3405    use approx::assert_relative_eq;
3406    use ndarray::array;
3407
3408    #[test]
3409    fn test_linear_kernel() {
3410        let k = LinearKernel;
3411        let x = vec![1.0, 2.0, 3.0];
3412        let y = vec![4.0, 5.0, 6.0];
3413        assert_relative_eq!(k.compute(&x, &y), 32.0, epsilon = 1e-10);
3414    }
3415
3416    #[test]
3417    fn test_rbf_kernel() {
3418        let k = RbfKernel::with_gamma(1.0);
3419        let x = vec![0.0, 0.0];
3420        let y = vec![0.0, 0.0];
3421        assert_relative_eq!(k.compute(&x, &y), 1.0, epsilon = 1e-10);
3422
3423        // Different points should give value < 1
3424        let y2 = vec![1.0, 0.0];
3425        let val: f64 = k.compute(&x, &y2);
3426        assert!(val < 1.0);
3427        assert!(val > 0.0);
3428    }
3429
3430    #[test]
3431    fn test_polynomial_kernel() {
3432        let k = PolynomialKernel {
3433            gamma: Gamma::Value(1.0),
3434            degree: 2,
3435            coef0: 1.0,
3436        };
3437        let x = vec![1.0f64, 0.0];
3438        let y = vec![1.0, 0.0];
3439        // (1.0 * 1.0 + 1.0)^2 = 4.0
3440        assert_relative_eq!(k.compute(&x, &y), 4.0, epsilon = 1e-10);
3441    }
3442
3443    #[test]
3444    fn test_sigmoid_kernel() {
3445        let k = SigmoidKernel {
3446            gamma: Gamma::Value(1.0),
3447            coef0: 0.0,
3448        };
3449        let x = vec![0.0f64];
3450        let y = vec![0.0];
3451        // tanh(0) = 0
3452        assert_relative_eq!(k.compute(&x, &y), 0.0, epsilon = 1e-10);
3453    }
3454
3455    #[test]
3456    fn test_svc_linear_separable() {
3457        // Two well-separated clusters.
3458        let x = Array2::from_shape_vec(
3459            (8, 2),
3460            vec![
3461                1.0, 1.0, 1.5, 1.0, 1.0, 1.5, 1.5, 1.5, // class 0
3462                5.0, 5.0, 5.5, 5.0, 5.0, 5.5, 5.5, 5.5, // class 1
3463            ],
3464        )
3465        .unwrap();
3466        let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
3467
3468        let model = SVC::<f64, LinearKernel>::new(LinearKernel).with_c(10.0);
3469        let fitted = model.fit(&x, &y).unwrap();
3470        let preds = fitted.predict(&x).unwrap();
3471
3472        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3473        assert!(correct >= 6, "Expected at least 6 correct, got {correct}");
3474    }
3475
3476    #[test]
3477    fn test_svc_rbf_xor() {
3478        // XOR problem: not linearly separable, needs RBF kernel.
3479        let x = Array2::from_shape_vec(
3480            (8, 2),
3481            vec![
3482                0.0, 0.0, 0.1, 0.1, // class 0
3483                1.0, 1.0, 1.1, 1.1, // class 0
3484                1.0, 0.0, 1.1, 0.1, // class 1
3485                0.0, 1.0, 0.1, 1.1, // class 1
3486            ],
3487        )
3488        .unwrap();
3489        let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
3490
3491        let kernel = RbfKernel::with_gamma(5.0);
3492        let model = SVC::new(kernel).with_c(100.0).with_max_iter(50000);
3493        let fitted = model.fit(&x, &y).unwrap();
3494        let preds = fitted.predict(&x).unwrap();
3495
3496        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3497        assert!(
3498            correct >= 6,
3499            "Expected at least 6 correct for XOR, got {correct}"
3500        );
3501    }
3502
3503    #[test]
3504    fn test_svc_multiclass() {
3505        // Three linearly separable clusters.
3506        let x = Array2::from_shape_vec(
3507            (9, 2),
3508            vec![
3509                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, // class 0
3510                5.0, 0.0, 5.5, 0.0, 5.0, 0.5, // class 1
3511                0.0, 5.0, 0.5, 5.0, 0.0, 5.5, // class 2
3512            ],
3513        )
3514        .unwrap();
3515        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3516
3517        let model = SVC::new(LinearKernel).with_c(10.0);
3518        let fitted = model.fit(&x, &y).unwrap();
3519        let preds = fitted.predict(&x).unwrap();
3520
3521        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3522        assert!(
3523            correct >= 7,
3524            "Expected at least 7 correct for multiclass, got {correct}"
3525        );
3526    }
3527
3528    #[test]
3529    fn test_svc_decision_function() -> TestResult {
3530        let x = Array2::from_shape_vec(
3531            (6, 2),
3532            vec![
3533                1.0, 1.0, 1.5, 1.0, 1.0, 1.5, // class 0
3534                5.0, 5.0, 5.5, 5.0, 5.0, 5.5, // class 1
3535            ],
3536        )
3537        .map_err(|_| err("shape"))?;
3538        let y = array![0usize, 0, 0, 1, 1, 1];
3539
3540        let model = SVC::new(LinearKernel).with_c(10.0);
3541        let fitted = model.fit(&x, &y)?;
3542
3543        let df = fitted.decision_function(&x)?;
3544        // Binary: 1-D (n,) score (sklearn ravels the single ovo column,
3545        // `_base.py:538-539`); the multiclass borrow is None.
3546        assert!(df.as_multiclass().is_none());
3547        let bin = df.as_binary().ok_or_else(|| err("binary 1-D"))?;
3548        assert_eq!(bin.len(), 6);
3549
3550        // Class 0 samples should have negative decision values,
3551        // class 1 should have positive (positive -> classes_[1]).
3552        for (i, &v) in bin.iter().enumerate().take(3) {
3553            assert!(v < 0.5, "Class 0 sample {i} has decision value {v}");
3554        }
3555        Ok(())
3556    }
3557
3558    // -----------------------------------------------------------------------
3559    // REQ-4 smoke tests: decision_function shape + sign + ovr/ovo transform.
3560    //
3561    // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3):
3562    //
3563    //   import numpy as np; from sklearn.svm import SVC
3564    //   X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3565    //   y=np.array([0,0,0,1,1,1]); m=SVC(kernel='linear',C=1.0).fit(X,y)
3566    //   m.decision_function(X)  # (6,) [-1.2853,-0.9997,-0.9997,0.9995,1.2851,1.2851]
3567    //
3568    //   X3=[[0,0],[.5,0],[0,.5],[5,0],[5.5,0],[5,.5],[0,5],[.5,5],[0,5.5]]
3569    //   y3=[0,0,0,1,1,1,2,2,2]; m3=SVC(kernel='linear',C=1.0).fit(X3,y3)
3570    //   m3.decision_function(X3)            # ovr (9,3) row0 [2.2366,0.8167,-0.1833]
3571    //                                       #         row3 [1.0606,2.2262,-0.2333]
3572    //   SVC(...,decision_function_shape='ovo').fit(X3,y3).decision_function(X3)
3573    //                                       # ovo (9,3) row0 [1.2222,1.2222,0.0]
3574    // -----------------------------------------------------------------------
3575
3576    fn three_class_9x2() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
3577        let x = Array2::from_shape_vec(
3578            (9, 2),
3579            vec![
3580                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
3581                0.0, 5.5,
3582            ],
3583        )
3584        .map_err(|_| err("shape"))?;
3585        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3586        Ok((x, y))
3587    }
3588
3589    #[test]
3590    fn test_svc_decision_function_binary_values() -> TestResult {
3591        let m = binary_fit()?;
3592        let x = Array2::from_shape_vec(
3593            (6, 2),
3594            vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3595        )
3596        .map_err(|_| err("shape"))?;
3597        let df = m.decision_function(&x)?;
3598        let bin = df.as_binary().ok_or_else(|| err("binary"))?;
3599        assert_eq!(bin.len(), 6);
3600        let oracle = [-1.2853, -0.9997, -0.9997, 0.9995, 1.2851, 1.2851];
3601        for (i, &exp) in oracle.iter().enumerate() {
3602            assert!(
3603                (bin[i] - exp).abs() < 1e-2,
3604                "binary df[{i}] = {} vs oracle {exp}",
3605                bin[i]
3606            );
3607        }
3608        Ok(())
3609    }
3610
3611    #[test]
3612    fn test_svc_decision_function_ovr() -> TestResult {
3613        let (x, y) = three_class_9x2()?;
3614        let m = SVC::new(LinearKernel)
3615            .with_c(1.0)
3616            .with_tol(1e-6)
3617            .with_max_iter(200_000)
3618            .fit(&x, &y)?;
3619        let df = m.decision_function(&x)?;
3620        let mc = df.as_multiclass().ok_or_else(|| err("multiclass"))?;
3621        assert_eq!(mc.dim(), (9, 3));
3622        // ovr (default): row0 [2.2366,0.8167,-0.1833], row3 [1.0606,2.2262,-0.2333].
3623        let row0 = [2.2366, 0.8167, -0.1833];
3624        let row3 = [1.0606, 2.2262, -0.2333];
3625        for (c, &v) in row0.iter().enumerate() {
3626            assert!(
3627                (mc[[0, c]] - v).abs() < 1e-2,
3628                "ovr row0[{c}] = {} vs oracle {v}",
3629                mc[[0, c]]
3630            );
3631        }
3632        for (c, &v) in row3.iter().enumerate() {
3633            assert!(
3634                (mc[[3, c]] - v).abs() < 1e-2,
3635                "ovr row3[{c}] = {} vs oracle {v}",
3636                mc[[3, c]]
3637            );
3638        }
3639        Ok(())
3640    }
3641
3642    #[test]
3643    fn test_svc_decision_function_ovo() -> TestResult {
3644        let (x, y) = three_class_9x2()?;
3645        let m = SVC::new(LinearKernel)
3646            .with_c(1.0)
3647            .with_tol(1e-6)
3648            .with_max_iter(200_000)
3649            .with_decision_function_shape(SvmDecisionShape::Ovo)
3650            .fit(&x, &y)?;
3651        let df = m.decision_function(&x)?;
3652        let mc = df.as_multiclass().ok_or_else(|| err("multiclass"))?;
3653        assert_eq!(mc.dim(), (9, 3));
3654        // ovo: row0 [1.2222,1.2222,0.0].
3655        let row0 = [1.2222, 1.2222, 0.0];
3656        for (c, &v) in row0.iter().enumerate() {
3657            assert!(
3658                (mc[[0, c]] - v).abs() < 1e-2,
3659                "ovo row0[{c}] = {} vs oracle {v}",
3660                mc[[0, c]]
3661            );
3662        }
3663        Ok(())
3664    }
3665
3666    #[test]
3667    fn test_svc_invalid_c() {
3668        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3669        let y = array![0usize, 0, 1, 1];
3670
3671        let model = SVC::new(LinearKernel).with_c(0.0);
3672        assert!(model.fit(&x, &y).is_err());
3673    }
3674
3675    #[test]
3676    fn test_svc_single_class_error() {
3677        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3678        let y = array![0usize, 0, 0];
3679
3680        let model = SVC::new(LinearKernel);
3681        assert!(model.fit(&x, &y).is_err());
3682    }
3683
3684    #[test]
3685    fn test_svc_shape_mismatch() {
3686        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3687        let y = array![0usize, 1];
3688
3689        let model = SVC::new(LinearKernel);
3690        assert!(model.fit(&x, &y).is_err());
3691    }
3692
3693    #[test]
3694    fn test_svr_simple() {
3695        // Simple linear regression: y = 2x
3696        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
3697        let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
3698
3699        let model = SVR::new(LinearKernel)
3700            .with_c(100.0)
3701            .with_epsilon(0.1)
3702            .with_max_iter(50000);
3703        let fitted = model.fit(&x, &y).unwrap();
3704        let preds = fitted.predict(&x).unwrap();
3705
3706        // Check predictions are reasonably close.
3707        for (p, &actual) in preds.iter().zip(y.iter()) {
3708            assert!(
3709                (*p - actual).abs() < 2.0,
3710                "SVR prediction {p} too far from actual {actual}"
3711            );
3712        }
3713    }
3714
3715    #[test]
3716    fn test_svr_decision_function() {
3717        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3718        let y = array![2.0, 4.0, 6.0, 8.0];
3719
3720        let model = SVR::new(LinearKernel).with_c(100.0).with_epsilon(0.1);
3721        let fitted = model.fit(&x, &y).unwrap();
3722
3723        let df = fitted.decision_function(&x).unwrap();
3724        let preds = fitted.predict(&x).unwrap();
3725
3726        // Decision function and predict should return the same values.
3727        for i in 0..4 {
3728            assert_relative_eq!(df[i], preds[i], epsilon = 1e-10);
3729        }
3730    }
3731
3732    #[test]
3733    fn test_svr_invalid_c() {
3734        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3735        let y = array![1.0, 2.0, 3.0, 4.0];
3736
3737        let model = SVR::new(LinearKernel).with_c(-1.0);
3738        assert!(model.fit(&x, &y).is_err());
3739    }
3740
3741    #[test]
3742    fn test_svr_shape_mismatch() {
3743        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3744        let y = array![1.0, 2.0];
3745
3746        let model = SVR::new(LinearKernel);
3747        assert!(model.fit(&x, &y).is_err());
3748    }
3749
3750    // -----------------------------------------------------------------------
3751    // REQ-3 smoke tests: libsvm-layout fitted attributes (binary sign flip).
3752    //
3753    // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3), never
3754    // copied from the ferrolearn side:
3755    //
3756    //   python3 -c "import numpy as np; from sklearn.svm import SVC, SVR
3757    //   X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3758    //   y=np.array([0,0,0,1,1,1]); m=SVC(kernel='linear',C=1.0).fit(X,y)
3759    //   print(m.support_.tolist(), m.n_support_.tolist(),
3760    //         m.dual_coef_.tolist(), m.intercept_.tolist(), m.coef_.tolist())"
3761    //   # [1, 2, 3] [2, 1] [[-0.0408,-0.0408,0.0816]] [-1.8565] [[0.2856,0.2856]]
3762    //
3763    //   X3=[[0,0],[.5,0],[0,.5],[5,0],[5.5,0],[5,.5],[0,5],[.5,5],[0,5.5]]
3764    //   y3=[0,0,0,1,1,1,2,2,2]; m3=SVC(kernel='linear',C=1.0).fit(X3,y3)
3765    //   # support_ [1,2,3,5,6,7] n_support_ [2,2,2]
3766    //   # dual_coef_ [[0.0988,0,-0.0988,0,-0.0988,0],[0,0.0988,0,0.0494,0,-0.0494]]
3767    //   # intercept_ [1.2222,1.2222,0.0]
3768    //
3769    //   Xr=[[1],[2],[3],[4],[5],[6]]; yr=[2,4,6,8,10,12]
3770    //   mr=SVR(kernel='linear',C=100,epsilon=0.1).fit(Xr,yr)
3771    //   # support_ [0,5] dual_coef_ [[-0.392,0.392]] intercept_ [0.14] n_support_ [2]
3772    //
3773    // The tests return `Result` and use `?`/`ok_or` (no unwrap/expect/panic).
3774    // -----------------------------------------------------------------------
3775
3776    type TestResult = Result<(), FerroError>;
3777
3778    fn err(msg: &str) -> FerroError {
3779        FerroError::InvalidParameter {
3780            name: "test".into(),
3781            reason: msg.into(),
3782        }
3783    }
3784
3785    fn binary_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
3786        let x = Array2::from_shape_vec(
3787            (6, 2),
3788            vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3789        )
3790        .map_err(|_| err("shape"))?;
3791        let y = array![0usize, 0, 0, 1, 1, 1];
3792        SVC::new(LinearKernel)
3793            .with_c(1.0)
3794            .with_tol(1e-6)
3795            .with_max_iter(200_000)
3796            .fit(&x, &y)
3797    }
3798
3799    #[test]
3800    fn test_svc_binary_support_attrs() -> TestResult {
3801        let m = binary_fit()?;
3802        // support_ [1,2,3], grouped by class (class0:[1,2], class1:[3]).
3803        assert_eq!(m.support().to_vec(), vec![1, 2, 3]);
3804        // n_support_ [2,1].
3805        assert_eq!(m.n_support(), vec![2, 1]);
3806        // support_vectors_ = X[support_].
3807        let svs = m.support_vectors();
3808        assert_eq!(svs.dim(), (3, 2));
3809        let expected = [[2.0, 1.0], [1.0, 2.0], [5.0, 5.0]];
3810        for (r, row) in expected.iter().enumerate() {
3811            for (c, &v) in row.iter().enumerate() {
3812                assert_relative_eq!(svs[[r, c]], v, epsilon = 1e-10);
3813            }
3814        }
3815        Ok(())
3816    }
3817
3818    #[test]
3819    fn test_svc_binary_dual_coef_sign_flip() -> TestResult {
3820        let m = binary_fit()?;
3821        // dual_coef_ shape (1,3) = [[-0.0408,-0.0408,0.0816]] (binary sign flip).
3822        let dc = m.dual_coef();
3823        assert_eq!(dc.dim(), (1, 3));
3824        let oracle = [-0.0408, -0.0408, 0.0816];
3825        for (c, &v) in oracle.iter().enumerate() {
3826            assert!(
3827                (dc[[0, c]] - v).abs() < 1e-2,
3828                "dual_coef_[0,{c}] = {} vs oracle {v}",
3829                dc[[0, c]]
3830            );
3831        }
3832        Ok(())
3833    }
3834
3835    #[test]
3836    fn test_svc_binary_intercept_and_coef() -> TestResult {
3837        let m = binary_fit()?;
3838        // intercept_ [-1.8565], length 1 (binary sign flip).
3839        let ic = m.intercept();
3840        assert_eq!(ic.len(), 1);
3841        assert!(
3842            (ic[0] - (-1.8565)).abs() < 1e-2,
3843            "intercept_ = {} vs oracle -1.8565",
3844            ic[0]
3845        );
3846        // coef_ [[0.2856,0.2856]] shape (1,2) for the linear kernel.
3847        let coef = m.coef().ok_or_else(|| err("linear kernel exposes coef_"))?;
3848        assert_eq!(coef.dim(), (1, 2));
3849        for c in 0..2 {
3850            assert!(
3851                (coef[[0, c]] - 0.2856).abs() < 1e-2,
3852                "coef_[0,{c}] = {} vs oracle 0.2856",
3853                coef[[0, c]]
3854            );
3855        }
3856        Ok(())
3857    }
3858
3859    #[test]
3860    fn test_svc_coef_none_for_nonlinear() -> TestResult {
3861        // coef_ is only available for the linear kernel; RBF -> None
3862        // (sklearn raises AttributeError, _base.py:650-651).
3863        let x = Array2::from_shape_vec(
3864            (6, 2),
3865            vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3866        )
3867        .map_err(|_| err("shape"))?;
3868        let y = array![0usize, 0, 0, 1, 1, 1];
3869        let m = SVC::new(RbfKernel::with_gamma(0.5)).fit(&x, &y)?;
3870        assert!(m.coef().is_none());
3871        Ok(())
3872    }
3873
3874    fn multiclass_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
3875        let x = Array2::from_shape_vec(
3876            (9, 2),
3877            vec![
3878                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
3879                0.0, 5.5,
3880            ],
3881        )
3882        .map_err(|_| err("shape"))?;
3883        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3884        SVC::new(LinearKernel)
3885            .with_c(1.0)
3886            .with_tol(1e-6)
3887            .with_max_iter(200_000)
3888            .fit(&x, &y)
3889    }
3890
3891    #[test]
3892    fn test_svc_multiclass_support_attrs() -> TestResult {
3893        let m = multiclass_fit()?;
3894        // support_ [1,2,3,5,6,7] grouped by class; n_support_ [2,2,2].
3895        assert_eq!(m.support().to_vec(), vec![1, 2, 3, 5, 6, 7]);
3896        assert_eq!(m.n_support(), vec![2, 2, 2]);
3897        // intercept_ [1.2222,1.2222,0.0] (no sign flip for multiclass).
3898        let ic = m.intercept();
3899        assert_eq!(ic.len(), 3);
3900        let oracle_ic = [1.2222, 1.2222, 0.0];
3901        for (i, &v) in oracle_ic.iter().enumerate() {
3902            assert!(
3903                (ic[i] - v).abs() < 1e-2,
3904                "intercept_[{i}] = {} vs oracle {v}",
3905                ic[i]
3906            );
3907        }
3908        Ok(())
3909    }
3910
3911    #[test]
3912    fn test_svc_multiclass_dual_coef_packing() -> TestResult {
3913        let m = multiclass_fit()?;
3914        // dual_coef_ shape (2,6), libsvm packing (cols = SVs [1,2,3,5,6,7]):
3915        //   row0 = [0.0988, 0.0, -0.0988, 0.0, -0.0988, 0.0]
3916        //   row1 = [0.0, 0.0988, 0.0, 0.0494, 0.0, -0.0494]
3917        let dc = m.dual_coef();
3918        assert_eq!(dc.dim(), (2, 6));
3919        let oracle = [
3920            [0.0988, 0.0, -0.0988, 0.0, -0.0988, 0.0],
3921            [0.0, 0.0988, 0.0, 0.0494, 0.0, -0.0494],
3922        ];
3923        for (r, row) in oracle.iter().enumerate() {
3924            for (c, &v) in row.iter().enumerate() {
3925                assert!(
3926                    (dc[[r, c]] - v).abs() < 1e-2,
3927                    "dual_coef_[{r},{c}] = {} vs oracle {v}",
3928                    dc[[r, c]]
3929                );
3930            }
3931        }
3932        Ok(())
3933    }
3934
3935    #[test]
3936    fn test_svr_linear_attrs() -> TestResult {
3937        // SVR(kernel='linear', C=100, epsilon=0.1) on the 6x1 set.
3938        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
3939            .map_err(|_| err("shape"))?;
3940        let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
3941        let m = SVR::new(LinearKernel)
3942            .with_c(100.0)
3943            .with_epsilon(0.1)
3944            .with_tol(1e-6)
3945            .with_max_iter(200_000)
3946            .fit(&x, &y)?;
3947        // support_ [0,5]; n_support_ [2]; dual_coef_ (1,2) [[-0.392,0.392]];
3948        // intercept_ [0.14].
3949        assert_eq!(m.support().to_vec(), vec![0, 5]);
3950        assert_eq!(m.n_support(), vec![2]);
3951        let dc = m.dual_coef();
3952        assert_eq!(dc.dim(), (1, 2));
3953        assert!(
3954            (dc[[0, 0]] - (-0.392)).abs() < 1e-2,
3955            "dual_coef_[0,0] = {} vs oracle -0.392",
3956            dc[[0, 0]]
3957        );
3958        assert!(
3959            (dc[[0, 1]] - 0.392).abs() < 1e-2,
3960            "dual_coef_[0,1] = {} vs oracle 0.392",
3961            dc[[0, 1]]
3962        );
3963        let ic = m.intercept();
3964        assert_eq!(ic.len(), 1);
3965        assert!(
3966            (ic[0] - 0.14).abs() < 1e-2,
3967            "intercept_ = {} vs oracle 0.14",
3968            ic[0]
3969        );
3970        Ok(())
3971    }
3972
3973    // -----------------------------------------------------------------------
3974    // REQ-1 (gamma='auto') + REQ-8 (break_ties) smoke tests.
3975    //
3976    // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3):
3977    //
3978    //   import numpy as np; from sklearn.svm import SVC
3979    //   X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3980    //   y=np.array([0,0,0,1,1,1])
3981    //   m=SVC(kernel='rbf',C=1.0,gamma='auto').fit(X,y)
3982    //   m._gamma                       # 0.5  (= 1/n_features = 1/2)
3983    //   m.decision_function(X)         # [-0.9996,-0.9999,-0.9999,
3984    //                                  #   0.9999, 0.9999, 0.9996]
3985    //
3986    //   break_ties: a symmetric, cleanly-separable 3-class set so ferrolearn's
3987    //   SMO converges to libsvm's optimum (each class has 2 SVs). Near the
3988    //   centroid the three ovr scores are ~1.0 (a near 1-1-1 vote tie), so the
3989    //   libsvm vote breaks toward the LOWEST class index (0) while ovr-argmax
3990    //   breaks by confidence. Oracle (re-derived vs the live oracle):
3991    //   Xb=[[0,0],[.5,0],[0,.5],[10,0],[10.5,0],[10,.5],[5,9],[5.5,9],[5,9.5]]
3992    //   yb=[0,0,0,1,1,1,2,2,2]
3993    //   q1=[[5.19,3.342]]: vote -> 0, break_ties -> 2 (df [0.9942,0.999,1.0068])
3994    //   q2=[[5.19,3.241]]: vote -> 0, break_ties -> 1 (df [0.9999,1.0051,0.9949])
3995    //   SVC(...,decision_function_shape='ovo',break_ties=True) -> ValueError
3996    // -----------------------------------------------------------------------
3997
3998    #[test]
3999    fn test_svc_gamma_auto_decision_function() -> TestResult {
4000        // gamma='auto' on the 6x2 set: _gamma = 1/n_features = 0.5.
4001        let x = Array2::from_shape_vec(
4002            (6, 2),
4003            vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
4004        )
4005        .map_err(|_| err("shape"))?;
4006        let y = array![0usize, 0, 0, 1, 1, 1];
4007
4008        // Confirm the resolved gamma is 1/n_features (sklearn `_base.py:241`).
4009        let resolved = RbfKernel::<f64>::with_gamma_auto().resolved_for_fit(&x);
4010        assert!(
4011            (gamma_value_or_one(resolved.gamma) - 0.5).abs() < 1e-12,
4012            "gamma='auto' resolved to {} vs oracle 0.5",
4013            gamma_value_or_one(resolved.gamma)
4014        );
4015
4016        let m = SVC::new(RbfKernel::<f64>::with_gamma_auto())
4017            .with_c(1.0)
4018            .with_tol(1e-6)
4019            .with_max_iter(200_000)
4020            .fit(&x, &y)?;
4021        let df = m.decision_function(&x)?;
4022        let bin = df.as_binary().ok_or_else(|| err("binary"))?;
4023        assert_eq!(bin.len(), 6);
4024        let oracle = [-0.9996, -0.9999, -0.9999, 0.9999, 0.9999, 0.9996];
4025        for (i, &exp) in oracle.iter().enumerate() {
4026            assert!(
4027                (bin[i] - exp).abs() < 1e-2,
4028                "gamma=auto df[{i}] = {} vs oracle {exp}",
4029                bin[i]
4030            );
4031        }
4032        Ok(())
4033    }
4034
4035    #[test]
4036    fn test_svc_gamma_scale_still_default() -> TestResult {
4037        // gamma='scale' (the default) must STILL resolve to
4038        // 1/(n_features * X.var()); on the 6x2 set X.var()=4.2222 ->
4039        // _gamma = 1/(2*4.2222) = 0.11842 (sklearn `_base.py:238-239`).
4040        let x = Array2::from_shape_vec(
4041            (6, 2),
4042            vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
4043        )
4044        .map_err(|_| err("shape"))?;
4045        let resolved = RbfKernel::<f64>::new().resolved_for_fit(&x);
4046        assert!(
4047            (gamma_value_or_one(resolved.gamma) - 0.118_421).abs() < 1e-4,
4048            "gamma='scale' resolved to {} vs oracle 0.118421",
4049            gamma_value_or_one(resolved.gamma)
4050        );
4051        Ok(())
4052    }
4053
4054    fn break_ties_set() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
4055        let x = Array2::from_shape_vec(
4056            (9, 2),
4057            vec![
4058                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 5.0, 9.0, 5.5, 9.0,
4059                5.0, 9.5,
4060            ],
4061        )
4062        .map_err(|_| err("shape"))?;
4063        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
4064        Ok((x, y))
4065    }
4066
4067    fn break_ties_fit(
4068        break_ties: bool,
4069        shape: SvmDecisionShape,
4070    ) -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4071        let (x, y) = break_ties_set()?;
4072        SVC::new(LinearKernel)
4073            .with_c(1.0)
4074            .with_tol(1e-6)
4075            .with_max_iter(200_000)
4076            .with_break_ties(break_ties)
4077            .with_decision_function_shape(shape)
4078            .fit(&x, &y)
4079    }
4080
4081    #[test]
4082    fn test_svc_break_ties_changes_label() -> TestResult {
4083        // q1=(5.19,3.342): vote -> 0, break_ties -> 2.
4084        let q1 = Array2::from_shape_vec((1, 2), vec![5.19, 3.342]).map_err(|_| err("shape"))?;
4085        // q2=(5.19,3.241): vote -> 0, break_ties -> 1.
4086        let q2 = Array2::from_shape_vec((1, 2), vec![5.19, 3.241]).map_err(|_| err("shape"))?;
4087
4088        let vote = break_ties_fit(false, SvmDecisionShape::Ovr)?;
4089        let bt = break_ties_fit(true, SvmDecisionShape::Ovr)?;
4090
4091        // break_ties=false (default): libsvm vote -> lowest-index class 0.
4092        assert_eq!(vote.predict(&q1)?[0], 0, "vote q1 should be 0");
4093        assert_eq!(vote.predict(&q2)?[0], 0, "vote q2 should be 0");
4094
4095        // break_ties=true + ovr: argmax(decision_function).
4096        assert_eq!(
4097            bt.predict(&q1)?[0],
4098            2,
4099            "break_ties q1 (ovr-argmax) should be 2"
4100        );
4101        assert_eq!(
4102            bt.predict(&q2)?[0],
4103            1,
4104            "break_ties q2 (ovr-argmax) should be 1"
4105        );
4106        Ok(())
4107    }
4108
4109    #[test]
4110    fn test_svc_break_ties_ovo_errors() -> TestResult {
4111        // sklearn raises when break_ties=True and decision_function_shape='ovo'
4112        // (`_base.py:801-804`).
4113        let m = break_ties_fit(true, SvmDecisionShape::Ovo)?;
4114        let q = Array2::from_shape_vec((1, 2), vec![5.19, 3.342]).map_err(|_| err("shape"))?;
4115        assert!(m.predict(&q).is_err());
4116        Ok(())
4117    }
4118
4119    #[test]
4120    fn test_svc_default_params() {
4121        // sklearn defaults: cache_size=200, max_iter=-1 (= 0 sentinel),
4122        // shrinking=True, break_ties=False, decision_function_shape='ovr'.
4123        let m = SVC::<f64, LinearKernel>::new(LinearKernel);
4124        assert_eq!(m.cache_size, 200);
4125        assert_eq!(m.max_iter, 0);
4126        assert!(m.shrinking);
4127        assert!(!m.break_ties);
4128        assert_eq!(m.decision_function_shape, SvmDecisionShape::Ovr);
4129        let r = SVR::<f64, LinearKernel>::new(LinearKernel);
4130        assert_eq!(r.cache_size, 200);
4131        assert_eq!(r.max_iter, 0);
4132        assert!(r.shrinking);
4133    }
4134
4135    /// The overlapping imbalanced binary set used to pin `class_weight`.
4136    fn class_weight_xy() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
4137        let x = Array2::from_shape_vec(
4138            (8, 2),
4139            vec![
4140                0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.5, 0.5, 1.5, 0.5, 2.0, 2.0, 2.5, 2.5,
4141            ],
4142        )
4143        .map_err(|_| err("shape"))?;
4144        let y = array![0usize, 0, 0, 0, 0, 1, 1, 1];
4145        Ok((x, y))
4146    }
4147
4148    fn cw_fit(cw: ClassWeight<f64>) -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4149        let (x, y) = class_weight_xy()?;
4150        SVC::new(LinearKernel)
4151            .with_c(1.0)
4152            .with_tol(1e-7)
4153            .with_max_iter(500_000)
4154            .with_class_weight(cw)
4155            .fit(&x, &y)
4156    }
4157
4158    /// `class_weight` per-class C in the C-SVC SMO (REQ-8, #641).
4159    ///
4160    /// Oracle (live `SVC(kernel='linear', C=1.0, class_weight=...)` on the
4161    /// overlapping imbalanced binary set, R-CHAR-3):
4162    /// ```text
4163    /// X=[[0,0],[1,0],[0,1],[1,1],[0.5,0.5],[1.5,0.5],[2,2],[2.5,2.5]] y=[0,0,0,0,0,1,1,1]
4164    /// None     -> dual_coef_ [[-0.5,-1.0,1.0,0.5]]      intercept_ [-2.0]    support_ [1,3,5,6]
4165    /// balanced -> dual_coef_ [[-0.8,-0.8,1.3333,0.2667]] intercept_ [-1.6667] support_ [1,3,5,6]
4166    /// {0:1,1:5}-> support_ [1,3,4,5] intercept_ [-2.0]
4167    /// ```
4168    #[test]
4169    fn test_svc_class_weight_smoke() -> TestResult {
4170        // class_weight=None.
4171        let none = cw_fit(ClassWeight::None)?;
4172        assert_eq!(none.support().to_vec(), vec![1, 3, 5, 6]);
4173        let dc = none.dual_coef();
4174        for (c, &v) in [-0.5, -1.0, 1.0, 0.5].iter().enumerate() {
4175            assert!(
4176                (dc[[0, c]] - v).abs() < 1e-2,
4177                "None dual_coef_[0,{c}] = {} vs {v}",
4178                dc[[0, c]]
4179            );
4180        }
4181        let none_int = none.intercept()[0];
4182        assert!(
4183            (none_int - (-2.0)).abs() < 1e-2,
4184            "None intercept_ {none_int}"
4185        );
4186
4187        // class_weight='balanced' (weights [0.8, 1.3333]).
4188        let bal = cw_fit(ClassWeight::Balanced)?;
4189        assert_eq!(bal.support().to_vec(), vec![1, 3, 5, 6]);
4190        let dcb = bal.dual_coef();
4191        for (c, &v) in [-0.8, -0.8, 1.3333, 0.2667].iter().enumerate() {
4192            assert!(
4193                (dcb[[0, c]] - v).abs() < 1e-2,
4194                "balanced dual_coef_[0,{c}] = {} vs {v}",
4195                dcb[[0, c]]
4196            );
4197        }
4198        let bal_int = bal.intercept()[0];
4199        assert!(
4200            (bal_int - (-1.6667)).abs() < 1e-2,
4201            "balanced intercept_ {bal_int}"
4202        );
4203
4204        // class_weight={0:1, 1:5}.
4205        let exp = cw_fit(ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]))?;
4206        assert_eq!(exp.support().to_vec(), vec![1, 3, 4, 5]);
4207        let exp_int = exp.intercept()[0];
4208        assert!(
4209            (exp_int - (-2.0)).abs() < 1e-2,
4210            "explicit intercept_ {exp_int}"
4211        );
4212
4213        // None vs balanced MUST give different intercepts — fails if
4214        // class_weight were ignored (R-CHAR-1).
4215        assert!(
4216            (none_int - bal_int).abs() > 1e-2,
4217            "None intercept {none_int} must differ from balanced {bal_int}"
4218        );
4219        Ok(())
4220    }
4221
4222    // -----------------------------------------------------------------------
4223    // REQ-9 smoke tests: probability / predict_proba (Platt scaling).
4224    //
4225    // These pin the DETERMINISTIC contract + STRUCTURAL invariants only, NOT
4226    // exact probA/probB or predict_proba values. sklearn's predict_proba is
4227    // RNG-CV-dependent (probA_ = -0.7749 at random_state=0 vs -1.0541 at
4228    // random_state=1 on the binary set), so exact values are NOT a stable
4229    // oracle (R-CHAR-3: the asserted invariants are sklearn's DOCUMENTED
4230    // contract — `_base.py:829-864` "columns correspond to classes_ in sorted
4231    // order", `predict_proba` rows are a probability distribution — never
4232    // copied from the ferrolearn side).
4233    // -----------------------------------------------------------------------
4234
4235    #[test]
4236    fn test_svc_predict_proba_raises_when_probability_false() -> TestResult {
4237        // probability=false (default): predict_proba/predict_log_proba error,
4238        // mirroring sklearn's raise (`_base.py:820-827`/`856-860`).
4239        let m = binary_fit()?; // default probability=false
4240        let x = Array2::from_shape_vec((1, 2), vec![3.0, 3.0]).map_err(|_| err("shape"))?;
4241        assert!(m.predict_proba(&x).is_err());
4242        assert!(m.predict_log_proba(&x).is_err());
4243        Ok(())
4244    }
4245
4246    fn proba_binary_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4247        let x = Array2::from_shape_vec(
4248            (10, 2),
4249            vec![
4250                1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0, 1.5, 1.5, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0,
4251                6.0, 6.0, 5.5, 5.5,
4252            ],
4253        )
4254        .map_err(|_| err("shape"))?;
4255        let y = array![0usize, 0, 0, 0, 0, 1, 1, 1, 1, 1];
4256        SVC::new(LinearKernel)
4257            .with_c(1.0)
4258            .with_tol(1e-6)
4259            .with_max_iter(200_000)
4260            .with_probability(true)
4261            .fit(&x, &y)
4262    }
4263
4264    #[test]
4265    fn test_svc_predict_proba_binary_rows_sum_to_one() -> TestResult {
4266        let m = proba_binary_fit()?;
4267        let x = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.5, 1.5, 5.0, 5.0, 5.5, 5.5])
4268            .map_err(|_| err("shape"))?;
4269        let p = m.predict_proba(&x)?;
4270        assert_eq!(p.dim(), (4, 2));
4271        for s in 0..4 {
4272            let row_sum = p[[s, 0]] + p[[s, 1]];
4273            assert!((row_sum - 1.0).abs() < 1e-9, "row {s} sums to {row_sum}");
4274            for c in 0..2 {
4275                assert!(
4276                    p[[s, c]] >= 0.0 && p[[s, c]] <= 1.0,
4277                    "p[{s},{c}] = {} out of [0,1]",
4278                    p[[s, c]]
4279                );
4280            }
4281        }
4282        Ok(())
4283    }
4284
4285    #[test]
4286    fn test_svc_predict_proba_binary_monotone_in_decision() -> TestResult {
4287        // STRUCTURAL invariant: P(classes_[1]) is monotone non-decreasing in
4288        // the (binary) decision_function value (higher decision -> higher
4289        // P(class_1)), per the sigmoid `1/(1+exp(A f + B))` contract.
4290        let m = proba_binary_fit()?;
4291        // A grid of query points sweeping from the class-0 to the class-1 side.
4292        let x = Array2::from_shape_vec(
4293            (5, 2),
4294            vec![1.0, 1.0, 2.5, 2.5, 3.5, 3.5, 4.5, 4.5, 6.0, 6.0],
4295        )
4296        .map_err(|_| err("shape"))?;
4297        let p = m.predict_proba(&x)?;
4298        let df = m.decision_function(&x)?;
4299        let bin = df.as_binary().ok_or_else(|| err("binary"))?;
4300
4301        // Sort sample indices by decision value, then P(class_1) must be
4302        // non-decreasing along that order.
4303        let mut order: Vec<usize> = (0..5).collect();
4304        order.sort_by(|&a, &b| {
4305            bin[a]
4306                .partial_cmp(&bin[b])
4307                .unwrap_or(std::cmp::Ordering::Equal)
4308        });
4309        let mut prev = f64::NEG_INFINITY;
4310        for &s in &order {
4311            let p1 = p[[s, 1]];
4312            assert!(
4313                p1 >= prev - 1e-9,
4314                "P(class_1) not monotone in decision: sample {s} df={} p1={p1} prev={prev}",
4315                bin[s]
4316            );
4317            prev = p1;
4318        }
4319        Ok(())
4320    }
4321
4322    #[test]
4323    fn test_svc_predict_log_proba_equals_log_of_proba() -> TestResult {
4324        let m = proba_binary_fit()?;
4325        let x = Array2::from_shape_vec((3, 2), vec![1.0, 1.0, 3.5, 3.5, 6.0, 6.0])
4326            .map_err(|_| err("shape"))?;
4327        let p = m.predict_proba(&x)?;
4328        let lp = m.predict_log_proba(&x)?;
4329        assert_eq!(lp.dim(), p.dim());
4330        for s in 0..3 {
4331            for c in 0..2 {
4332                assert_relative_eq!(lp[[s, c]], p[[s, c]].ln(), epsilon = 1e-12);
4333            }
4334        }
4335        Ok(())
4336    }
4337
4338    fn proba_multiclass_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4339        let x = Array2::from_shape_vec(
4340            (9, 2),
4341            vec![
4342                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
4343                0.0, 5.5,
4344            ],
4345        )
4346        .map_err(|_| err("shape"))?;
4347        let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
4348        SVC::new(LinearKernel)
4349            .with_c(1.0)
4350            .with_tol(1e-6)
4351            .with_max_iter(200_000)
4352            .with_probability(true)
4353            .fit(&x, &y)
4354    }
4355
4356    #[test]
4357    fn test_svc_predict_proba_multiclass_rows_sum_to_one() -> TestResult {
4358        // 3-class: predict_proba is (n, 3), each row a probability
4359        // distribution (Wu-Lin-Weng coupling, `svm.cpp:2941`).
4360        let m = proba_multiclass_fit()?;
4361        let x = Array2::from_shape_vec((3, 2), vec![0.25, 0.25, 5.0, 0.25, 0.25, 5.0])
4362            .map_err(|_| err("shape"))?;
4363        let p = m.predict_proba(&x)?;
4364        assert_eq!(p.dim(), (3, 3));
4365        for s in 0..3 {
4366            let row_sum: f64 = (0..3).map(|c| p[[s, c]]).sum();
4367            assert!((row_sum - 1.0).abs() < 1e-9, "row {s} sums to {row_sum}");
4368            for c in 0..3 {
4369                assert!(
4370                    p[[s, c]] >= 0.0 && p[[s, c]] <= 1.0,
4371                    "p[{s},{c}] = {} out of [0,1]",
4372                    p[[s, c]]
4373                );
4374            }
4375        }
4376        Ok(())
4377    }
4378
4379    #[test]
4380    fn test_sigmoid_predict_overflow_safe() {
4381        // sigmoid_predict matches `1/(1+exp(A f + B))` and is overflow-safe at
4382        // extreme decision values (`svm.cpp:2032-2040`).
4383        let a = -1.0f64;
4384        let b = 0.0;
4385        // f large positive -> fApB = -f large negative -> p -> 1.
4386        let p_pos = sigmoid_predict(1000.0, a, b);
4387        assert!(p_pos.is_finite() && (p_pos - 1.0).abs() < 1e-6);
4388        // f large negative -> p -> 0.
4389        let p_neg = sigmoid_predict(-1000.0, a, b);
4390        assert!(p_neg.is_finite() && p_neg.abs() < 1e-6);
4391        // f = 0 -> 1/(1+exp(0)) = 0.5.
4392        assert_relative_eq!(sigmoid_predict(0.0, a, b), 0.5, epsilon = 1e-12);
4393    }
4394
4395    #[test]
4396    fn test_multiclass_probability_binary_reduces_to_pairwise() {
4397        // For k=2 the Wu-Lin-Weng coupling reduces to [r01, 1-r01].
4398        let mut r = Array2::<f64>::zeros((2, 2));
4399        r[[0, 1]] = 0.7;
4400        r[[1, 0]] = 0.3;
4401        let p = multiclass_probability(2, &r);
4402        assert_relative_eq!(p[0], 0.7, epsilon = 1e-6);
4403        assert_relative_eq!(p[1], 0.3, epsilon = 1e-6);
4404    }
4405
4406    /// `compute_class_weight` matches `sklearn.utils.compute_class_weight`
4407    /// (`_classes.py:122-124` balanced formula) on the imbalanced set.
4408    #[test]
4409    fn test_compute_class_weight_balanced() {
4410        // 8 samples, 2 classes; class0 count=5, class1 count=3.
4411        // balanced[c] = 8 / (2 * count_c): [8/10, 8/6] = [0.8, 1.3333].
4412        let classes = [0usize, 1];
4413        let y = [0usize, 0, 0, 0, 0, 1, 1, 1];
4414        let w = compute_class_weight::<f64>(&ClassWeight::Balanced, &classes, &y);
4415        assert_relative_eq!(w[0], 0.8, epsilon = 1e-9);
4416        assert_relative_eq!(w[1], 8.0 / 6.0, epsilon = 1e-9);
4417        // None -> all 1.0.
4418        let wn = compute_class_weight::<f64>(&ClassWeight::None, &classes, &y);
4419        assert_eq!(wn, vec![1.0, 1.0]);
4420        // Explicit map, unlisted defaults to 1.0.
4421        let we = compute_class_weight::<f64>(&ClassWeight::Explicit(vec![(1, 5.0)]), &classes, &y);
4422        assert_eq!(we, vec![1.0, 5.0]);
4423    }
4424}