ferrolearn-linear 0.5.0

Linear models for the ferrolearn ML framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
//! One-Class SVM for novelty detection.
//!
//! This module provides [`OneClassSVM`], which learns a decision boundary
//! around the training data and classifies new points as inliers (`+1`) or
//! outliers (`-1`).
//!
//! # Algorithm
//!
//! One-Class SVM trains a standard binary SVC where all training data is
//! assigned label `+1` and a synthetic origin point is assigned label `-1`.
//! The decision function then separates the data from the origin in kernel
//! feature space. Points with positive decision values are inliers; negative
//! are outliers.
//!
//! # Examples
//!
//! ```
//! use ferrolearn_linear::one_class_svm::OneClassSVM;
//! use ferrolearn_linear::svm::RbfKernel;
//! use ferrolearn_core::{Fit, Predict};
//! use ndarray::{array, Array2, Array1};
//!
//! let x_train = Array2::from_shape_vec((6, 2), vec![
//!     1.0, 1.0,  1.5, 1.0,  1.0, 1.5,
//!     1.2, 1.3,  1.3, 1.2,  1.1, 1.1,
//! ]).unwrap();
//!
//! let model = OneClassSVM::<f64, RbfKernel<f64>>::new(RbfKernel::with_gamma(1.0));
//! let fitted = model.fit(&x_train, &()).unwrap();
//!
//! // Most training data should be classified as inliers.
//! let preds = fitted.predict(&x_train).unwrap();
//! let inliers: usize = preds.iter().filter(|&&p| p == 1).count();
//! assert!(inliers >= 4);
//! ```
//!
//! ## REQ status
//!
//! Classification (R-DEFER-2): SHIPPED = impl + non-test production consumer +
//! tests + green oracle verification; NOT-STARTED = open blocker `#`.
//! `OneClassSVM`/`FittedOneClassSVM` are boundary estimator types re-exported at
//! the crate root (`pub use one_class_svm::{…}` in `lib.rs`) — under S5/R-DEFER-1
//! the consumer surface exists for the grandfathered public API. See
//! `.design/linear/one_class_svm.md`.
//!
//! | REQ | Status | Evidence |
//! |---|---|---|
//! | REQ-1 (ONE_CLASS nu dual + nu validation) | SHIPPED | `fn fit in one_class_svm.rs` validates `nu ∈ (0,1]` (`InvalidParameter`) and solves the one-class dual `0≤α≤1/(n·ν), Σα=1`, rescaled to libsvm's `Σα=ν·n` convention (`let scale = F::one()/c; rho * scale`, `dual_coefs.push(alpha*scale)`). On a NON-DEGENERATE (unique-optimum) set the SMO recovers libsvm's EXACT decomposition — `support_`/`n_support_`/`dual_coef_`/`intercept_` match the live oracle within 1e-8 (pinned by `divergence_pin5_sv_decomposition_nondegenerate_646 in tests/divergence_one_class_svm.rs`: `support_ [0,1,2,4]`, `dual_coef_ [1,0.569,0.431,1]`, `intercept_ [-1.616]`, verified unique via perturbation). DEGENERACY BOUNDARY (documented, not a gap): on the symmetric toy set the optimal face is degenerate (margin points 1,4,5 satisfy `0.5·x₁=0.25·x₄+0.25·x₅` → identical `w`), so ferrolearn's deterministic WSS reaches a different but equally-optimal vertex (5 SVs vs libsvm's 4) — sanctioned α-decomposition non-uniqueness; the hyperplane/`decision_function`/`predict` are IDENTICAL (pin1/pin3 green). |
//! | REQ-2 (kernels & gamma resolution) | SHIPPED | `fn fit in one_class_svm.rs` resolves the kernel against X at fit time via `let kernel = self.kernel.resolved_for_fit(x);` (mirroring `svm.rs`'s `SVC::fit`), used for ALL kernel evaluations in the SMO solve and stored on `FittedOneClassSVM` so decision_function/predict reuse the same gamma. `Gamma::Scale` (default) resolves to `1/(n_features·X.var())`, `Auto` to `1/n_features`, `Value` verbatim (`crate::svm::Kernel::resolved_for_fit`, `_base.py:236-243`). Pinned: `divergence_pin2_gamma_scale_default_647 in tests/divergence_one_class_svm.rs` — default `RbfKernel` (`Gamma::Scale`) on the 7×2 set gives `_gamma≈0.46578` and df matching the live `OneClassSVM(kernel='rbf',nu=0.5)` oracle `[0.022499,0.022633,0.000122,0.0,0.0,0.000387,-1.44231]` (R-CHAR-3, 1e-2). |
//! | REQ-3 (fitted attributes + offset_) | SHIPPED | The libsvm-layout accessor surface now exists: `FittedOneClassSVM::{support,support_vectors,n_support,dual_coef,intercept,offset,coef} in one_class_svm.rs` — `support_` (SV indices via the new `sv_indices` field), `support_vectors_` shape `(n_SV,n_features)`, `n_support_` `vec![n_SV]` (length 1), `dual_coef_` shape `(1,n_SV)` (libsvm scale, raw α — no `α·y` flip, `Σ=ν·n`), `intercept_=[-rho]`, `offset_=rho=-intercept_` (`_classes.py:1767`), linear-only `coef_=dual_coef_@support_vectors_` (gated on `Kernel::is_linear`, else `None`, `_base.py:650-666`). The hyperplane attributes match the live oracle: `intercept_ [-0.01]`, `offset_ 0.01` (= `-intercept_`), `coef_ [[0.05,0.05]]`. Consumer: the crate-root re-export. Pinned by `test_one_class_svm_fitted_attributes_linear_oracle` (offset_/coef_/intercept_/shapes/the `offset_=-intercept_` identity + `dual_coef_` sum `=ν·n`) + `test_one_class_svm_coef_none_for_rbf` (rbf → `None`) in `one_class_svm.rs` (R-CHAR-3, 1e-2). The `support_`/`dual_coef_`/`n_support_` decomposition matches the oracle on NON-DEGENERATE (unique) optima (`divergence_pin5_sv_decomposition_nondegenerate_646`); on the symmetric toy set the decomposition is a sanctioned non-unique vertex (REQ-1's documented degeneracy boundary — same hyperplane). |
//! | REQ-4 (decision_function / score_samples) | SHIPPED | `pub fn decision_function in one_class_svm.rs` returns `Array1<F>` `(n,)` = `Σ coef·K(sv,x) − rho` in libsvm scale (the #646 rescale: `let scale = F::one()/c; rho * scale`, `dual_coefs.push(alpha*scale)`, `svm.cpp:2834` `sum -= rho`). `pub fn score_samples in one_class_svm.rs` now returns `decision_function(X) + offset_` (`_classes.py:1801`). Pinned: `divergence_pin1_decision_function_scaling_646 in tests/divergence_one_class_svm.rs` — linear `nu=0.5` on the 7×2 set gives df `[-0.01,0.0,-0.01,-0.01,0.0,0.0,0.29]` matching the live oracle; `test_one_class_svm_score_samples_linear_oracle in one_class_svm.rs` pins `score_samples [0,0.01,0,0,0.01,0.01,0.3]` against the live oracle (R-CHAR-3, 1e-2). |
//! | REQ-5 (predict +1/-1) | SHIPPED | `fn predict in one_class_svm.rs` returns `+1` (inlier) / `-1` (outlier); labels match the live oracle `[-1,1,-1,-1,1,1,1]` (pinned by `divergence_pin3_predict_labels_648 in tests/divergence_one_class_svm.rs`, R-CHAR-3). The boundary uses a `|rho|`-relative slack so on-margin points (`decision≈0` modulo float roundoff) take libsvm's observable label (`+1`), reproducing the oracle (R-DEV-3 observable contract); libsvm's exact `(sum>0)?+1:-1` (`svm.cpp:2837-2838`) differs only at a genuine `decision==0` (measure-zero / degenerate edge). |
//! | REQ-6 (constructor params/defaults) | SHIPPED | `OneClassSVM::new` now mirrors sklearn's exact param surface defaults (`_classes.py:1712-1721`, live `inspect.signature`): `nu=0.5`, `tol=1e-3`, `cache_size=200` (was `1024`; accepted for parity, no kernel cache in this module), `max_iter=0` (was `10000`) = sklearn `max_iter=-1` ("no iteration limit"), and a new `pub shrinking: bool` field (default `true`) + `#[must_use] with_shrinking` — accepted for API parity, shrinking-invariant one-class optimum so DOES NOT alter the fit (no shrinking heuristic, R-DEV-7), mirroring `svm.rs`'s `SVC`/`SVR`. `fn fit`'s SMO loop treats `max_iter == 0` as unbounded (run to convergence) via the sentinel guard `if self.max_iter != 0 && iter >= self.max_iter { break; }` (same as `svm.rs`'s `smo_binary`/`smo_svr`); the KKT-gap break (`i_max_grad - j_min_grad < tol`) terminates the default-0 fit. R-DEV-7 design difference (preserved contract, NOT a gap): estimator-level `kernel`(string)/`degree`/`coef0` are the type parameter `K` set by construction, `gamma` resolution is REQ-2; `verbose`/`random_state` are unused (deterministic SMO). Pinned by `test_one_class_svm_default_params` (asserts `nu==0.5`, `tol==1e-3`, `max_iter==0`, `cache_size==200`, `shrinking==true` against the live `OneClassSVM.__init__` signature, R-DEV-2) + `test_one_class_svm_default_max_iter_converges` (default-0 fit converges, no infinite loop) + `test_one_class_svm_builder_pattern` (`with_shrinking`) in `one_class_svm.rs`. The 6 divergence pins use explicit `with_max_iter(1_000_000)` and stay green. |
//! | REQ-7 (ferray substrate) | NOT-STARTED | open prereq blocker #652. `one_class_svm.rs` imports `ndarray::{Array1, Array2, ScalarOperand}`, not `ferray-core` (R-SUBSTRATE). |
//! | REQ-8 (non-finite input rejected) | SHIPPED | `fn fit in one_class_svm.rs` rejects any NaN/+/-inf in X BEFORE the one-class 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.")` / `"… contains infinity …"`. OneClassSVM is unsupervised (`Fit<Array2<F>, ()>`, X only — sklearn's `OneClassSVM.fit(X)` passes a synthetic all-ones `y`), so only X is checked; ferrolearn's `Fit::fit` has no `sample_weight` argument. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the 6 one-class divergence pins stay green). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X all raise `ValueError` (`tests/divergence_svm_nonfinite.rs::ocs_*`). Non-test consumer: the existing `Fit::fit` consumer + the crate-root `pub use one_class_svm::{OneClassSVM, …}` re-export. (#2269) |

use ferrolearn_core::error::FerroError;
use ferrolearn_core::traits::{Fit, Predict};
use ndarray::{Array1, Array2, ScalarOperand};
use num_traits::Float;

use crate::svm::Kernel;

// ---------------------------------------------------------------------------
// OneClassSVM
// ---------------------------------------------------------------------------

/// One-Class SVM for novelty detection.
///
/// Learns a decision boundary around the training data. New points are
/// classified as inliers (`+1`) or outliers (`-1`).
///
/// # Type Parameters
///
/// - `F`: The floating-point type (`f32` or `f64`).
/// - `K`: The kernel type (e.g., [`RbfKernel`](super::svm::RbfKernel)).
#[derive(Debug, Clone)]
pub struct OneClassSVM<F, K> {
    /// The nu parameter: upper bound on the fraction of outliers.
    /// Must be in `(0, 1]`. Default: `0.5`.
    pub nu: F,
    /// The kernel function.
    pub kernel: K,
    /// Convergence tolerance.
    pub tol: F,
    /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
    /// sentinel ("no iteration limit"; libsvm runs to convergence) — the SMO
    /// loop then runs until the KKT gap closes; a non-zero value caps the
    /// iteration count (`sklearn/svm/_classes.py:1721`, `max_iter` default `-1`).
    pub max_iter: usize,
    /// Size of the kernel evaluation cache (`sklearn` `cache_size`, default
    /// `200`). Accepted for API parity; this module has no kernel cache.
    pub cache_size: usize,
    /// Whether to use libsvm's shrinking heuristic (`sklearn` `shrinking`,
    /// `_classes.py:1718`, default `true`).
    ///
    /// Accepted for API parity. The one-class optimum is shrinking-invariant
    /// and ferrolearn's SMO implements no shrinking heuristic, so this flag
    /// DOES NOT alter the fitted `α`/`dual_coef_`/`intercept_` (R-DEV-7).
    pub shrinking: bool,
}

impl<F: Float, K: Kernel<F>> OneClassSVM<F, K> {
    /// Create a new `OneClassSVM` with the given kernel and default hyperparameters.
    ///
    /// Defaults: `nu = 0.5`, `tol = 1e-3`, `max_iter = 0` (= sklearn `-1`, no
    /// iteration limit — runs to convergence), `cache_size = 200`,
    /// `shrinking = true` (`sklearn/svm/_classes.py:1712-1721`).
    #[must_use]
    pub fn new(kernel: K) -> Self {
        Self {
            nu: F::from(0.5).unwrap_or_else(F::zero),
            kernel,
            tol: F::from(1e-3).unwrap_or_else(F::zero),
            max_iter: 0,
            cache_size: 200,
            shrinking: true,
        }
    }

    /// Set the nu parameter.
    #[must_use]
    pub fn with_nu(mut self, nu: F) -> Self {
        self.nu = nu;
        self
    }

    /// Set the convergence tolerance.
    #[must_use]
    pub fn with_tol(mut self, tol: F) -> Self {
        self.tol = tol;
        self
    }

    /// Set the maximum number of SMO iterations.
    #[must_use]
    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
        self.max_iter = max_iter;
        self
    }

    /// Set the kernel cache size.
    #[must_use]
    pub fn with_cache_size(mut self, cache_size: usize) -> Self {
        self.cache_size = cache_size;
        self
    }

    /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
    ///
    /// Accepted for API parity; the one-class optimum is shrinking-invariant
    /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7), so this does
    /// not change the fit.
    #[must_use]
    pub fn with_shrinking(mut self, shrinking: bool) -> Self {
        self.shrinking = shrinking;
        self
    }
}

/// Fitted One-Class SVM.
///
/// Stores the support vectors and decision boundary. Points are classified
/// as inliers (+1) or outliers (-1) based on the sign of the decision
/// function.
#[derive(Debug, Clone)]
pub struct FittedOneClassSVM<F, K> {
    /// The kernel used for predictions.
    kernel: K,
    /// Support vectors (stored as rows).
    support_vectors: Vec<Vec<F>>,
    /// Original training-row index of each support vector, ascending. Mirrors
    /// libsvm's `support_` accounting (`sklearn/svm/_base.py:318-410`) so the
    /// public `support_`/`support_vectors_` attributes can index back into X.
    sv_indices: Vec<usize>,
    /// Dual coefficients for each support vector.
    dual_coefs: Vec<F>,
    /// Bias (rho) term. Decision function: sign(f(x) - rho).
    rho: F,
}

impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Fit<Array2<F>, ()>
    for OneClassSVM<F, K>
{
    type Fitted = FittedOneClassSVM<F, K>;
    type Error = FerroError;

    /// Fit the One-Class SVM.
    ///
    /// # Errors
    ///
    /// - [`FerroError::InvalidParameter`] if `nu` is not in `(0, 1]`.
    /// - [`FerroError::InsufficientSamples`] if no training data is provided.
    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedOneClassSVM<F, K>, FerroError> {
        if self.nu <= F::zero() || self.nu > F::one() {
            return Err(FerroError::InvalidParameter {
                name: "nu".into(),
                reason: "must be in (0, 1]".into(),
            });
        }

        let n_samples = x.nrows();
        let n_features = x.ncols();

        if n_samples == 0 {
            return Err(FerroError::InsufficientSamples {
                required: 1,
                actual: 0,
                context: "OneClassSVM requires at least one sample".into(),
            });
        }

        // Reject non-finite input (NaN / +/-inf) in X BEFORE the one-class SMO
        // solve, mirroring sklearn's `BaseLibSVM.fit` -> `_validate_data(X, y, …)`
        // (`sklearn/svm/_base.py:190-197`, default `force_all_finite=True`) ->
        // `ValueError("Input X contains NaN.")` / `"… contains infinity …"`.
        // OneClassSVM is unsupervised (`Fit<Array2<F>, ()>`, X only — sklearn's
        // `OneClassSVM.fit(X)` passes a synthetic all-ones `y`), so only X is
        // finiteness-checked; ferrolearn's `Fit::fit` has no `sample_weight`
        // argument. `.iter().any(|v| !v.is_finite())` catches both NaN and
        // +/-inf; on finite input the guard never fires (the fitted
        // `support_`/`dual_coef_`/`intercept_`/`offset_` are byte-identical).
        if x.iter().any(|v| !v.is_finite()) {
            return Err(FerroError::InvalidParameter {
                name: "X".into(),
                reason: "Input X contains NaN or infinity.".into(),
            });
        }

        // Resolve the kernel against X at fit time (gamma='scale'/'auto'/float),
        // exactly as `svm.rs`'s `SVC::fit` does (`self.kernel.resolved_for_fit(x)`).
        // sklearn resolves `gamma='scale'` to `1/(n_features·X.var())` and
        // `'auto'` to `1/n_features` against the training X (`_base.py:236-243`);
        // without this a default `RbfKernel` (`Gamma::Scale`) silently fits with
        // `gamma=1.0`. The resolved kernel is used for ALL kernel evaluations
        // below and stored on `FittedOneClassSVM` so decision_function/predict
        // reuse the same resolved gamma.
        let kernel = self.kernel.resolved_for_fit(x);

        // Solve the one-class SVM dual:
        // max sum_i alpha_i - 0.5 * sum_{i,j} alpha_i * alpha_j * K(x_i, x_j)
        // s.t. 0 <= alpha_i <= 1/(n * nu), sum alpha_i = 1
        //
        // We use a simplified approach: initialize alphas uniformly, then
        // iterate with SMO-style updates.

        let c = F::one() / (F::from(n_samples).unwrap() * self.nu);
        let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();

        // Initialize alphas uniformly: alpha_i = 1/n
        let init_alpha = F::one() / F::from(n_samples).unwrap();
        let mut alphas = vec![init_alpha.min(c); n_samples];

        // Ensure sum(alphas) = 1 after capping at c.
        let alpha_sum: F = alphas.iter().copied().fold(F::zero(), |a, b| a + b);
        if alpha_sum < F::one() {
            // Distribute remaining mass.
            let remaining = F::one() - alpha_sum;
            let per_sample = remaining / F::from(n_samples).unwrap();
            for alpha in &mut alphas {
                *alpha = (*alpha + per_sample).min(c);
            }
        }

        // Compute initial gradient: grad_i = sum_j alpha_j * K(x_i, x_j)
        let eps = F::from(1e-12).unwrap_or_else(F::epsilon);
        let two = F::one() + F::one();

        let mut grad = vec![F::zero(); n_samples];
        for i in 0..n_samples {
            for j in 0..n_samples {
                grad[i] = grad[i] + alphas[j] * kernel.compute(&data[i], &data[j]);
            }
        }

        // SMO iterations. `max_iter == 0` is the sklearn `max_iter=-1` ("no
        // iteration limit", libsvm runs to convergence) sentinel — the loop
        // then runs until the KKT gap closes (the `i_max_grad - j_min_grad <
        // tol` break below). A non-zero `max_iter` caps the iteration count,
        // mirroring `svm.rs`'s `smo_binary`/`smo_svr` loop guard.
        let mut iter = 0usize;
        loop {
            if self.max_iter != 0 && iter >= self.max_iter {
                break;
            }
            iter += 1;
            // Select working set: i with largest gradient (and alpha_i > 0),
            // j with smallest gradient (and alpha_j < c).
            let mut i_best = None;
            let mut i_max_grad = F::neg_infinity();
            let mut j_best = None;
            let mut j_min_grad = F::infinity();

            for k in 0..n_samples {
                if alphas[k] > eps && grad[k] > i_max_grad {
                    i_max_grad = grad[k];
                    i_best = Some(k);
                }
                if alphas[k] < c - eps && grad[k] < j_min_grad {
                    j_min_grad = grad[k];
                    j_best = Some(k);
                }
            }

            if i_best.is_none() || j_best.is_none() || i_max_grad - j_min_grad < self.tol {
                break;
            }

            let i = i_best.unwrap();
            let j = j_best.unwrap();

            if i == j {
                break;
            }

            let kii = kernel.compute(&data[i], &data[i]);
            let kjj = kernel.compute(&data[j], &data[j]);
            let kij = kernel.compute(&data[i], &data[j]);
            let eta = kii + kjj - two * kij;

            if eta <= eps {
                continue;
            }

            // Update: move mass from i to j.
            let delta = (grad[i] - grad[j]) / eta;
            let delta = delta.min(alphas[i]).min(c - alphas[j]);

            if delta.abs() < eps {
                continue;
            }

            alphas[i] = alphas[i] - delta;
            alphas[j] = alphas[j] + delta;

            // Update gradients.
            for k in 0..n_samples {
                let kki = kernel.compute(&data[k], &data[i]);
                let kkj = kernel.compute(&data[k], &data[j]);
                grad[k] = grad[k] - delta * kki + delta * kkj;
            }
        }

        // Compute rho from the KKT conditions.
        // For free SVs (0 < alpha_i < c): rho = grad_i = sum_j alpha_j * K(i, j).
        let mut rho_sum = F::zero();
        let mut rho_count = 0usize;

        for i in 0..n_samples {
            if alphas[i] > eps && alphas[i] < c - eps {
                rho_sum = rho_sum + grad[i];
                rho_count += 1;
            }
        }

        let rho = if rho_count > 0 {
            rho_sum / F::from(rho_count).unwrap()
        } else {
            // Fallback: use the midpoint of the gradient range among all SVs.
            let sv_grads: Vec<F> = (0..n_samples)
                .filter(|&i| alphas[i] > eps)
                .map(|i| grad[i])
                .collect();

            if sv_grads.is_empty() {
                F::zero()
            } else {
                let min_g = sv_grads.iter().fold(F::infinity(), |a, &b| a.min(b));
                let max_g = sv_grads.iter().fold(F::neg_infinity(), |a, &b| a.max(b));
                (min_g + max_g) / two
            }
        };

        // Rescale from the normalized one-class dual (0<=a<=1/(n*nu), Sum a = 1)
        // to libsvm's un-normalized convention (0<=a<=1, Sum a = nu*n). The two
        // optima are the SAME point scaled by `nu*n`, so the reported
        // coefficients and `rho` must be multiplied by `nu*n` for the decision
        // function `Sum a_i K - rho` to match the sklearn/libsvm oracle
        // (libsvm `solve_one_class` svm.cpp:1722-1736, decision svm.cpp:2834).
        // `c == 1/(n*nu)`, so the scale factor `nu*n` is exactly `1/c`.
        let scale = F::one() / c;
        let rho = rho * scale;

        // Extract support vectors. `alphas` is in training-row order, so the
        // recorded `sv_indices` are already ascending — matching libsvm's
        // `support_` ordering (`svm.cpp` keeps SVs in input order for one-class).
        let mut support_vectors = Vec::new();
        let mut sv_indices = Vec::new();
        let mut dual_coefs = Vec::new();

        for (i, &alpha) in alphas.iter().enumerate() {
            if alpha > eps {
                support_vectors.push(data[i].clone());
                sv_indices.push(i);
                dual_coefs.push(alpha * scale);
            }
        }

        // If no support vectors found, use all data as fallback: distribute the
        // total mass `nu*n` (== scale) uniformly across all n samples. With
        // `c == 1/(n*nu)`, the per-sample weight `scale/n == scale*c*nu`.
        if support_vectors.is_empty() {
            let weight = scale * c * self.nu;
            for (i, row) in data.iter().enumerate() {
                support_vectors.push(row.clone());
                sv_indices.push(i);
                dual_coefs.push(weight);
            }
        }

        let _ = n_features; // used for validation context

        Ok(FittedOneClassSVM {
            kernel,
            support_vectors,
            sv_indices,
            dual_coefs,
            rho,
        })
    }
}

impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
    FittedOneClassSVM<F, K>
{
    /// Compute the decision function value for a single sample.
    ///
    /// Returns `f(x) - rho`, where `f(x) = sum_i alpha_i * K(sv_i, x)`.
    fn decision_value(&self, x: &[F]) -> F {
        let mut val = F::zero();
        for (sv, &coef) in self.support_vectors.iter().zip(self.dual_coefs.iter()) {
            val = val + coef * self.kernel.compute(sv, x);
        }
        val - self.rho
    }

    /// Compute the raw decision function values for each sample.
    ///
    /// Returns an array of shape `(n_samples,)`. Positive values indicate
    /// inliers, negative values indicate outliers.
    ///
    /// # Errors
    ///
    /// Returns `Ok` always for valid input.
    pub fn decision_function(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
        let n_samples = x.nrows();
        let mut result = Array1::<F>::zeros(n_samples);
        for s in 0..n_samples {
            let xi: Vec<F> = x.row(s).to_vec();
            result[s] = self.decision_value(&xi);
        }
        Ok(result)
    }

    /// Indices of the support vectors into the training set, ascending
    /// (`OneClassSVM.support_`, `sklearn/svm/_base.py:318-410`). One-class has a
    /// single "class", so the SVs are kept in training-row order.
    #[must_use]
    pub fn support(&self) -> Array1<usize> {
        Array1::from_vec(self.sv_indices.clone())
    }

    /// The support vectors, shape `(n_SV, n_features)`
    /// (`OneClassSVM.support_vectors_`). Equals `X[support_]`.
    #[must_use]
    pub fn support_vectors(&self) -> Array2<F> {
        let n_sv = self.support_vectors.len();
        let n_features = self.support_vectors.first().map_or(0, Vec::len);
        let mut out = Array2::<F>::zeros((n_sv, n_features));
        for (r, sv) in self.support_vectors.iter().enumerate() {
            for (c, &v) in sv.iter().enumerate() {
                out[[r, c]] = v;
            }
        }
        out
    }

    /// Number of support vectors. For one-class `n_support_` has size 1 — a
    /// single count of all SVs (`sklearn/svm/_classes.py:1664`,
    /// `sklearn/svm/_base.py:680-682`).
    #[must_use]
    pub fn n_support(&self) -> Vec<usize> {
        vec![self.support_vectors.len()]
    }

    /// Dual coefficients `alpha`, shape `(1, n_SV)` (`OneClassSVM.dual_coef_`).
    /// For one-class these are the raw `alpha` (NOT `alpha*y`), already rescaled
    /// in `fn fit` to libsvm's `Sum alpha = nu*n` convention
    /// (`sklearn/svm/_classes.py:1639`). No sign flip applies to one-class
    /// (`sklearn/svm/_base.py:258-262` restricts the flip to `c_svc`/`nu_svc`).
    #[must_use]
    pub fn dual_coef(&self) -> Array2<F> {
        let n_sv = self.dual_coefs.len();
        let mut out = Array2::<F>::zeros((1, n_sv));
        for (c, &v) in self.dual_coefs.iter().enumerate() {
            out[[0, c]] = v;
        }
        out
    }

    /// The intercept, length 1 (`OneClassSVM.intercept_`) = `-rho`. The
    /// one-class decision function is `Sum alpha*K - rho`, so the public
    /// intercept is `-rho` (libsvm `svm.cpp:2834` `sum -= rho`,
    /// `sklearn/svm/_base.py` `_intercept_`).
    #[must_use]
    pub fn intercept(&self) -> Array1<F> {
        Array1::from_vec(vec![-self.rho])
    }

    /// The offset, a scalar (`OneClassSVM.offset_`) = `rho` = `-intercept_`
    /// (`sklearn/svm/_classes.py:1767`: `self.offset_ = -self._intercept_`).
    /// Used to shift the decision function back to the raw score:
    /// `decision_function = score_samples - offset_`.
    #[must_use]
    pub fn offset(&self) -> F {
        self.rho
    }

    /// Primal weight vector `coef_ = dual_coef_ @ support_vectors_`, shape
    /// `(1, n_features)` — available ONLY for the linear kernel
    /// (`sklearn/svm/_base.py:650-666`). Returns `None` for any other kernel
    /// (sklearn raises `AttributeError`).
    #[must_use]
    pub fn coef(&self) -> Option<Array2<F>> {
        if !self.kernel.is_linear() {
            return None;
        }
        let dual = self.dual_coef(); // (1, n_SV)
        let svs = self.support_vectors(); // (n_SV, n_features)
        Some(dual.dot(&svs))
    }

    /// Raw (unshifted) scoring function of the samples,
    /// `score_samples(X) = decision_function(X) + offset_`
    /// (`sklearn/svm/_classes.py:1801`). Equals the unshifted `Sum alpha*K`.
    ///
    /// # Errors
    ///
    /// Propagates any error from [`Self::decision_function`].
    pub fn score_samples(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
        let dec = self.decision_function(x)?;
        let off = self.offset();
        Ok(dec.mapv(|v| v + off))
    }
}

impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
    for FittedOneClassSVM<F, K>
{
    type Output = Array1<isize>;
    type Error = FerroError;

    /// Predict inlier (+1) or outlier (-1) for each sample.
    ///
    /// # Errors
    ///
    /// Returns `Ok` always for valid input.
    fn predict(&self, x: &Array2<F>) -> Result<Array1<isize>, FerroError> {
        let n_samples = x.nrows();
        let mut predictions = Array1::<isize>::zeros(n_samples);

        // libsvm ONE_CLASS uses `(sum > 0) ? +1 : -1` (svm.cpp:2837-2838). A
        // point exactly on the decision boundary (a free support vector, whose
        // decision value is mathematically 0) is reported `+1` by libsvm: its
        // converged `rho` lands fractionally below the on-boundary kernel sum
        // (e.g. 0.00999999977 vs 0.01), so `sum` is a small positive. ferrolearn
        // recovers `rho` exactly, leaving boundary points at machine-epsilon
        // noise of either sign. To reproduce libsvm's observable labels
        // (R-DEV-3), treat decision values within solver-precision of 0 as the
        // inlier side: a relative slack off `|rho|` separates true outliers
        // (whose `|sum|` is order `|rho|`) from on-boundary roundoff.
        let boundary = self.rho.abs() * F::from(1e-9).unwrap_or_else(F::epsilon);
        for s in 0..n_samples {
            let xi: Vec<F> = x.row(s).to_vec();
            let val = self.decision_value(&xi);
            predictions[s] = if val > -boundary { 1 } else { -1 };
        }

        Ok(predictions)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::svm::{LinearKernel, RbfKernel};
    use ndarray::Array2;

    fn make_cluster_data() -> Array2<f64> {
        Array2::from_shape_vec(
            (8, 2),
            vec![
                1.0, 1.0, 1.1, 1.0, 1.0, 1.1, 1.1, 1.1, 0.9, 0.9, 1.0, 0.9, 0.9, 1.0, 1.05, 1.05,
            ],
        )
        .unwrap()
    }

    #[test]
    fn test_one_class_svm_fit() {
        let x = make_cluster_data();
        let model = OneClassSVM::<f64, RbfKernel<f64>>::new(RbfKernel::with_gamma(10.0));
        let result = model.fit(&x, &());
        assert!(result.is_ok());
    }

    #[test]
    fn test_one_class_svm_inliers() {
        let x = make_cluster_data();
        let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
        let fitted = model.fit(&x, &()).unwrap();
        let preds = fitted.predict(&x).unwrap();

        // Most training points should be classified as inliers.
        let inliers: usize = preds.iter().filter(|&&p| p == 1).count();
        assert!(inliers >= 6, "Expected at least 6 inliers, got {inliers}");
    }

    #[test]
    fn test_one_class_svm_outlier_detection() {
        let x_train = Array2::from_shape_vec(
            (8, 2),
            vec![
                0.0, 0.0, 0.1, 0.0, 0.0, 0.1, 0.1, 0.1, -0.1, 0.0, 0.0, -0.1, 0.05, 0.05, -0.05,
                -0.05,
            ],
        )
        .unwrap();

        let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
        let fitted = model.fit(&x_train, &()).unwrap();

        // A far-away point should be an outlier.
        let x_outlier = Array2::from_shape_vec((1, 2), vec![100.0, 100.0]).unwrap();
        let preds = fitted.predict(&x_outlier).unwrap();
        assert_eq!(preds[0], -1, "Far-away point should be an outlier");
    }

    #[test]
    fn test_one_class_svm_decision_function() {
        let x = make_cluster_data();
        let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
        let fitted = model.fit(&x, &()).unwrap();

        let df = fitted.decision_function(&x).unwrap();
        assert_eq!(df.len(), 8);

        // Most decision values should be non-negative for training data.
        let positive: usize = df.iter().filter(|&&v| v >= 0.0).count();
        assert!(
            positive >= 6,
            "Expected at least 6 positive df, got {positive}"
        );
    }

    #[test]
    fn test_one_class_svm_invalid_nu() {
        let x = Array2::from_shape_vec((4, 2), vec![1.0; 8]).unwrap();

        let model = OneClassSVM::new(RbfKernel::<f64>::new()).with_nu(0.0);
        assert!(model.fit(&x, &()).is_err());

        let model2 = OneClassSVM::new(RbfKernel::<f64>::new()).with_nu(1.5);
        assert!(model2.fit(&x, &()).is_err());
    }

    #[test]
    fn test_one_class_svm_empty_data() {
        let x = Array2::<f64>::zeros((0, 2));
        let model = OneClassSVM::new(RbfKernel::<f64>::new());
        assert!(model.fit(&x, &()).is_err());
    }

    #[test]
    fn test_one_class_svm_builder_pattern() {
        let model = OneClassSVM::<f64, LinearKernel>::new(LinearKernel)
            .with_nu(0.3)
            .with_tol(1e-4)
            .with_max_iter(5000)
            .with_cache_size(2048)
            .with_shrinking(false);

        assert!((model.nu - 0.3).abs() < 1e-10);
        assert!((model.tol - 1e-4).abs() < 1e-10);
        assert_eq!(model.max_iter, 5000);
        assert_eq!(model.cache_size, 2048);
        assert!(!model.shrinking);
    }

    /// REQ-6 (R-DEV-2): `OneClassSVM::new` exposes sklearn's exact param
    /// surface defaults. Expected values from the live oracle:
    ///   python3 -c "from sklearn.svm import OneClassSVM; import inspect; \
    ///   print({k:v.default for k,v in \
    ///   inspect.signature(OneClassSVM.__init__).parameters.items() if k!='self'})"
    ///   # kernel='rbf' degree=3 gamma='scale' coef0=0.0 tol=1e-3 nu=0.5
    ///   #   shrinking=True cache_size=200 max_iter=-1
    /// (`max_iter=-1` maps to ferrolearn's `0` sentinel = no iteration limit).
    #[test]
    fn test_one_class_svm_default_params() {
        let model = OneClassSVM::<f64, LinearKernel>::new(LinearKernel);
        assert!((model.nu - 0.5).abs() < 1e-12, "nu default 0.5");
        assert!((model.tol - 1e-3).abs() < 1e-12, "tol default 1e-3");
        assert_eq!(model.max_iter, 0, "max_iter default 0 (= sklearn -1)");
        assert_eq!(model.cache_size, 200, "cache_size default 200");
        assert!(model.shrinking, "shrinking default true");
    }

    /// A default-`max_iter` (`0` = unbounded) fit must run to convergence, NOT
    /// run zero iterations or spin forever: the `i_max_grad - j_min_grad < tol`
    /// break terminates the loop. Verifies the sentinel loop guard.
    #[test]
    fn test_one_class_svm_default_max_iter_converges() {
        let x = oracle_7x2();
        let model = OneClassSVM::new(LinearKernel).with_nu(0.5);
        assert_eq!(model.max_iter, 0, "uses the default unbounded sentinel");
        let fit = model.fit(&x, &());
        assert!(
            fit.is_ok(),
            "default max_iter=0 fit converges, no infinite loop"
        );
        let Ok(fitted) = fit else { return };
        // Converged to a usable boundary: it produces a label per sample.
        let preds = fitted.predict(&x);
        assert!(preds.is_ok(), "predict succeeds on converged fit");
        let Ok(preds) = preds else { return };
        assert_eq!(preds.len(), 7);
    }

    #[test]
    fn test_one_class_svm_linear_kernel() {
        let x = make_cluster_data();
        let model = OneClassSVM::new(LinearKernel).with_nu(0.5);
        let fitted = model.fit(&x, &()).unwrap();
        let preds = fitted.predict(&x).unwrap();
        assert_eq!(preds.len(), 8);
    }

    #[test]
    fn test_one_class_svm_single_sample() {
        let x = Array2::from_shape_vec((1, 2), vec![1.0, 1.0]).unwrap();
        let model = OneClassSVM::new(RbfKernel::with_gamma(1.0)).with_nu(0.5);
        let result = model.fit(&x, &());
        assert!(result.is_ok());
    }

    /// The 7x2 contract set; built with `arr2` (no `Result`) so the smoke
    /// tests stay free of the forbidden `.unwrap()` token even under the
    /// Edit-path gate (which does not exempt `#[cfg(test)]`).
    fn oracle_7x2() -> Array2<f64> {
        ndarray::arr2(&[
            [0.0, 0.0],
            [0.1, 0.1],
            [-0.1, 0.1],
            [0.1, -0.1],
            [0.0, 0.2],
            [0.2, 0.0],
            [3.0, 3.0],
        ])
    }

    // Expected values come from the live sklearn 1.5.2 oracle (R-CHAR-3),
    // NOT copied from ferrolearn:
    //
    //   python3 -c "import numpy as np; from sklearn.svm import OneClassSVM; \
    //     X=np.array([[0,0],[0.1,0.1],[-0.1,0.1],[0.1,-0.1],[0,0.2],[0.2,0],[3,3]],dtype=float); \
    //     m=OneClassSVM(kernel='linear',nu=0.5).fit(X); \
    //     print(m.support_.tolist(), m.n_support_.tolist()); \
    //     print(np.round(m.dual_coef_,6).tolist()); \
    //     print(np.round(m.intercept_,6).tolist(), np.round(m.offset_,6).tolist()); \
    //     print(np.round(m.coef_,6).tolist()); \
    //     print(np.round(m.score_samples(X),6).tolist())"
    //
    //   support_ [0,1,2,3]  n_support_ [4]
    //   dual_coef_ [[1.0,0.5,1.0,1.0]]  (sum 3.5 = nu*n = 0.5*7)
    //   intercept_ [-0.01]  offset_ [0.01]  coef_ [[0.05,0.05]]
    //   score_samples [0.0,0.01,0.0,0.0,0.01,0.01,0.3]

    #[test]
    fn test_one_class_svm_fitted_attributes_linear_oracle() {
        let fit = OneClassSVM::new(LinearKernel)
            .with_nu(0.5)
            .fit(&oracle_7x2(), &());
        assert!(fit.is_ok(), "linear one-class fit should succeed");
        let Ok(fitted) = fit else { return };

        // The hyperplane-level attributes match the live oracle exactly
        // (coef_/intercept_/offset_ and decision_function/score_samples — see
        // the score_samples test). NOTE: the SV-decomposition attributes
        // (support_/dual_coef_/n_support_) DIVERGE — ferrolearn's SMO converges
        // to a different vertex of the same optimal face: it reports 5 SVs
        // {0,2,3,4,5} with dual_coef_ [[1,1,1,0.25,0.25]] vs the live oracle's
        // 4 SVs {0,1,2,3} with [[1,0.5,1,1]]. Both sum to nu*n=3.5 and yield
        // the SAME hyperplane (coef_=[[0.05,0.05]], intercept_=[-0.01]), so the
        // decision function matches. The SV-set divergence is a solver-optimum
        // divergence (REQ-1), filed as a new blocker for the critic to pin
        // rigorously and a fixer to resolve in the SMO working-set selection;
        // these accessors faithfully report whatever the solver converged to.

        // support_/support_vectors_ shapes are coherent with each other.
        let support = fitted.support();
        let svs = fitted.support_vectors();
        assert_eq!(
            svs.nrows(),
            support.len(),
            "support_vectors_ rows == |support_|"
        );
        assert_eq!(svs.ncols(), 2, "support_vectors_ n_features == 2");
        // support_ is ascending and indexes valid training rows.
        for w in support.windows(2).into_iter() {
            assert!(w[0] < w[1], "support_ strictly ascending");
        }
        for &i in support.iter() {
            assert!(i < 7, "support_ index in range");
        }

        // n_support_ has length 1 (one-class single "class") and equals |SV|.
        let n_support = fitted.n_support();
        assert_eq!(n_support.len(), 1, "n_support_ length 1 for one-class");
        assert_eq!(n_support[0], support.len(), "n_support_[0] == |support_|");

        // dual_coef_ shape (1, n_SV); its sum is the libsvm-scale total nu*n=3.5
        // (the rescale identity, scale-invariant of the SV decomposition).
        let dual = fitted.dual_coef();
        assert_eq!(dual.dim(), (1, support.len()), "dual_coef_ shape (1, n_SV)");
        let dual_sum: f64 = dual.iter().sum();
        assert!((dual_sum - 3.5).abs() < 1e-2, "dual_coef_ sum = nu*n = 3.5");

        // intercept_ = [-0.01], offset_ = 0.01 = -intercept_ (matches oracle).
        let intercept = fitted.intercept();
        assert_eq!(intercept.len(), 1, "intercept_ length 1");
        assert!(
            (intercept[0] - (-0.01)).abs() < 1e-2,
            "intercept_ vs oracle [-0.01]"
        );
        let offset = fitted.offset();
        assert!((offset - 0.01).abs() < 1e-2, "offset_ vs oracle 0.01");
        assert!(
            (offset - (-intercept[0])).abs() < 1e-12,
            "offset_ = -intercept_"
        );

        // coef_ = dual_coef_ @ support_vectors_ = [[0.05, 0.05]] (matches oracle:
        // the primal hyperplane is identical despite the different SV set).
        let coef = fitted.coef();
        assert!(coef.is_some(), "linear kernel exposes coef_");
        if let Some(coef) = coef {
            assert_eq!(coef.dim(), (1, 2), "coef_ shape (1, n_features)");
            assert!(
                (coef[[0, 0]] - 0.05).abs() < 1e-2,
                "coef_[0][0] vs oracle 0.05"
            );
            assert!(
                (coef[[0, 1]] - 0.05).abs() < 1e-2,
                "coef_[0][1] vs oracle 0.05"
            );
        }
    }

    #[test]
    fn test_one_class_svm_score_samples_linear_oracle() {
        let fit = OneClassSVM::new(LinearKernel)
            .with_nu(0.5)
            .fit(&oracle_7x2(), &());
        assert!(fit.is_ok(), "linear one-class fit should succeed");
        let Ok(fitted) = fit else { return };

        // score_samples = decision_function + offset_ = [0,0.01,0,0,0.01,0.01,0.3].
        let scores_res = fitted.score_samples(&oracle_7x2());
        assert!(scores_res.is_ok(), "score_samples should succeed");
        let df_res = fitted.decision_function(&oracle_7x2());
        assert!(df_res.is_ok(), "decision_function should succeed");
        let (Ok(scores), Ok(df)) = (scores_res, df_res) else {
            return;
        };
        let expected = [0.0, 0.01, 0.0, 0.0, 0.01, 0.01, 0.3];
        assert_eq!(scores.len(), expected.len());
        for (i, &v) in expected.iter().enumerate() {
            assert!(
                (scores[i] - v).abs() < 1e-2,
                "score_samples[{i}] = {} vs oracle {v}",
                scores[i]
            );
        }

        // Cross-check the identity score_samples = decision_function + offset_.
        for i in 0..scores.len() {
            assert!((scores[i] - (df[i] + fitted.offset())).abs() < 1e-12);
        }
    }

    #[test]
    fn test_one_class_svm_coef_none_for_rbf() {
        // coef_ is linear-only; non-linear kernels return None (sklearn raises
        // AttributeError, `sklearn/svm/_base.py:650-651`).
        let fit = OneClassSVM::new(RbfKernel::with_gamma(1.0))
            .with_nu(0.5)
            .fit(&oracle_7x2(), &());
        assert!(fit.is_ok(), "rbf one-class fit should succeed");
        let Ok(fitted) = fit else { return };
        assert!(fitted.coef().is_none(), "rbf kernel has no coef_");
    }
}