Skip to main content

ipfrs_tensorlogic/
online_learner.rs

1//! Online / incremental learning algorithms for streaming data.
2//!
3//! Implements three production-grade online learning algorithms:
4//!
5//! * **Perceptron** — classic binary classifier; updates weights only on mispredictions.
6//! * **Passive-Aggressive (PA-I)** — margin-based update with a soft constraint
7//!   (`C` parameter) that controls the trade-off between aggressiveness and
8//!   passiveness.
9//! * **SGD with Momentum** — stochastic gradient descent with configurable
10//!   momentum, learning rate, and L2 regularisation.
11//!
12//! All algorithms share a unified [`OnlineLearner`] interface that tracks
13//! running statistics (total updates, accuracy, average loss, weight norm).
14//!
15//! # Examples
16//!
17//! ```rust
18//! use ipfrs_tensorlogic::online_learner::{
19//!     OnlineLearner, OnlineAlgorithm, OlLossFunction, TrainingSample,
20//! };
21//!
22//! let mut learner = OnlineLearner::new(
23//!     OnlineAlgorithm::Perceptron,
24//!     2,
25//!     OlLossFunction::Hinge,
26//! );
27//!
28//! let sample = TrainingSample { features: vec![1.0, 0.5], label: 1.0 };
29//! learner.update(&sample).expect("example: should succeed in docs");
30//! let class = learner.predict_class(&[1.0, 0.5]).expect("example: should succeed in docs");
31//! assert!(class == 1 || class == -1);
32//! ```
33
34use std::fmt;
35use thiserror::Error;
36
37// ---------------------------------------------------------------------------
38// Error type
39// ---------------------------------------------------------------------------
40
41/// Errors that can be raised by [`OnlineLearner`] operations.
42#[derive(Debug, Error, Clone, PartialEq)]
43pub enum LearnerError {
44    /// Feature vector dimensionality does not match the learner.
45    #[error("dimension mismatch: expected {expected}, got {got}")]
46    DimensionMismatch { expected: usize, got: usize },
47
48    /// An empty input (zero-length feature vector or empty sample slice) was
49    /// provided where non-empty input is required.
50    #[error("empty input")]
51    EmptyInput,
52
53    /// A label value was provided that is invalid for the chosen algorithm
54    /// (e.g. a value other than ±1.0 for binary classification).
55    #[error("invalid label: {label} — binary classifiers expect +1.0 or -1.0")]
56    InvalidLabel { label: f64 },
57}
58
59// ---------------------------------------------------------------------------
60// Core enumerations
61// ---------------------------------------------------------------------------
62
63/// Online learning algorithm selection.
64#[derive(Debug, Clone, PartialEq)]
65pub enum OnlineAlgorithm {
66    /// Classic Perceptron binary classifier.
67    ///
68    /// Update rule (on misprediction only):
69    /// ```text
70    /// w[i] += label * x[i]
71    /// bias  += label
72    /// ```
73    Perceptron,
74
75    /// Passive-Aggressive PA-I update.
76    ///
77    /// ```text
78    /// loss = max(0, 1 - label * score)
79    /// tau  = loss / (||x||² + 1 / (2 * C))
80    /// w[i] += tau * label * x[i]
81    /// bias  += tau * label
82    /// ```
83    PassiveAggressive {
84        /// Aggressiveness parameter.  Larger values → more aggressive updates.
85        c: f64,
86    },
87
88    /// Stochastic gradient descent with momentum and L2 regularisation.
89    ///
90    /// ```text
91    /// velocity[i] = momentum * velocity[i] - lr * (grad[i] + l2_reg * w[i])
92    /// w[i]       += velocity[i]
93    /// bias       -= lr * (-label)
94    /// ```
95    SgdMomentum {
96        /// Learning rate (step size).
97        lr: f64,
98        /// Momentum coefficient ∈ [0, 1).
99        momentum: f64,
100        /// L2 weight-decay coefficient.
101        l2_reg: f64,
102    },
103}
104
105/// Loss function used for computing per-sample losses and SGD gradients.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OlLossFunction {
108    /// `max(0, 1 − label · score)`
109    Hinge,
110    /// `max(0, 1 − label · score)²`
111    SquaredHinge,
112    /// `ln(1 + exp(−label · score))` — numerically stable via log-sum-exp.
113    LogLoss,
114}
115
116impl fmt::Display for OlLossFunction {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Hinge => write!(f, "Hinge"),
120            Self::SquaredHinge => write!(f, "SquaredHinge"),
121            Self::LogLoss => write!(f, "LogLoss"),
122        }
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Training sample
128// ---------------------------------------------------------------------------
129
130/// A single labelled training example for online learning.
131///
132/// For binary classification the label **must** be `+1.0` or `−1.0`.
133/// For regression the label may be any finite `f64`.
134#[derive(Debug, Clone, PartialEq)]
135pub struct TrainingSample {
136    /// Input feature vector.
137    pub features: Vec<f64>,
138    /// Target label.  Binary classifiers expect ±1.0.
139    pub label: f64,
140}
141
142impl TrainingSample {
143    /// Construct a new training sample.
144    pub fn new(features: Vec<f64>, label: f64) -> Self {
145        Self { features, label }
146    }
147
148    /// Return `true` if the label is a valid binary classification label (±1.0).
149    pub fn is_valid_binary_label(&self) -> bool {
150        (self.label - 1.0).abs() < f64::EPSILON || (self.label + 1.0).abs() < f64::EPSILON
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Statistics
156// ---------------------------------------------------------------------------
157
158/// Running statistics tracked by [`OnlineLearner`] across all updates and
159/// predictions.
160#[derive(Debug, Clone, PartialEq)]
161pub struct OnlineLearnerStats {
162    /// Total number of `update()` calls performed.
163    pub total_updates: u64,
164    /// Number of `predict_class()` calls that returned the correct label.
165    pub correct_predictions: u64,
166    /// Total number of `predict_class()` calls.
167    pub total_predictions: u64,
168    /// Running average of per-update losses.
169    pub avg_loss: f64,
170    /// L2 norm of the weight vector at the time `stats()` was called.
171    pub weight_norm: f64,
172}
173
174impl Default for OnlineLearnerStats {
175    fn default() -> Self {
176        Self {
177            total_updates: 0,
178            correct_predictions: 0,
179            total_predictions: 0,
180            avg_loss: 0.0,
181            weight_norm: 0.0,
182        }
183    }
184}
185
186// ---------------------------------------------------------------------------
187// Internal running-average accumulator (Welford online algorithm)
188// ---------------------------------------------------------------------------
189
190#[derive(Debug, Clone, Default)]
191struct RunningMean {
192    count: u64,
193    mean: f64,
194}
195
196impl RunningMean {
197    fn update(&mut self, value: f64) {
198        self.count += 1;
199        let delta = value - self.mean;
200        self.mean += delta / self.count as f64;
201    }
202
203    fn value(&self) -> f64 {
204        self.mean
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Main learner struct
210// ---------------------------------------------------------------------------
211
212/// Online / incremental learner supporting Perceptron, Passive-Aggressive, and
213/// SGD-with-Momentum algorithms.
214///
215/// The learner maintains a weight vector `w ∈ ℝᵈ` and a scalar `bias`, updated
216/// sample-by-sample via the selected [`OnlineAlgorithm`].
217#[derive(Debug, Clone)]
218pub struct OnlineLearner {
219    /// The update algorithm in use.
220    pub algorithm: OnlineAlgorithm,
221    /// Current weight vector.
222    pub weights: Vec<f64>,
223    /// Scalar bias term.
224    pub bias: f64,
225    /// Dimensionality (number of features).
226    pub dims: usize,
227    /// Loss function for computing per-sample losses.
228    pub loss_fn: OlLossFunction,
229    /// Velocity buffer for SGD-with-Momentum (zero for other algorithms).
230    pub velocity: Vec<f64>,
231
232    // Internal stats tracking
233    running_loss: RunningMean,
234    total_updates: u64,
235    correct_predictions: u64,
236    total_predictions: u64,
237}
238
239impl OnlineLearner {
240    // -----------------------------------------------------------------------
241    // Construction
242    // -----------------------------------------------------------------------
243
244    /// Create a new [`OnlineLearner`] with zero-initialised weights.
245    ///
246    /// # Arguments
247    ///
248    /// * `algorithm` — update rule to apply on each `update()` call.
249    /// * `dims` — feature dimensionality; all input vectors must have
250    ///   exactly `dims` elements.
251    /// * `loss_fn` — loss function used for reporting and SGD gradient
252    ///   computation.
253    ///
254    /// # Panics
255    ///
256    /// Does not panic; returns a well-formed `OnlineLearner` even for `dims == 0`.
257    pub fn new(algorithm: OnlineAlgorithm, dims: usize, loss_fn: OlLossFunction) -> Self {
258        Self {
259            algorithm,
260            weights: vec![0.0_f64; dims],
261            bias: 0.0,
262            dims,
263            loss_fn,
264            velocity: vec![0.0_f64; dims],
265            running_loss: RunningMean::default(),
266            total_updates: 0,
267            correct_predictions: 0,
268            total_predictions: 0,
269        }
270    }
271
272    // -----------------------------------------------------------------------
273    // Prediction
274    // -----------------------------------------------------------------------
275
276    /// Compute the raw decision score: `dot(weights, features) + bias`.
277    ///
278    /// # Errors
279    ///
280    /// Returns [`LearnerError::EmptyInput`] if `features` is empty when
281    /// `dims > 0`, or [`LearnerError::DimensionMismatch`] if
282    /// `features.len() != dims`.
283    pub fn predict(&self, features: &[f64]) -> Result<f64, LearnerError> {
284        self.check_dims(features)?;
285        Ok(dot(&self.weights, features) + self.bias)
286    }
287
288    /// Return the predicted class (`+1` or `−1`) for `features`.
289    ///
290    /// The class is the sign of [`predict`](Self::predict).  A score of
291    /// exactly zero is classified as `+1`.
292    ///
293    /// This method also updates the internal prediction statistics.
294    ///
295    /// # Errors
296    ///
297    /// Propagates errors from [`predict`](Self::predict).
298    pub fn predict_class(&mut self, features: &[f64]) -> Result<i32, LearnerError> {
299        let score = self.predict(features)?;
300        self.total_predictions += 1;
301        Ok(if score >= 0.0 { 1 } else { -1 })
302    }
303
304    /// A non-mutating variant of [`predict_class`](Self::predict_class) that
305    /// does **not** update internal prediction statistics.
306    ///
307    /// Useful for evaluation loops where you want to call `accuracy()` later
308    /// without double-counting.
309    pub fn classify(&self, features: &[f64]) -> Result<i32, LearnerError> {
310        let score = self.predict(features)?;
311        Ok(if score >= 0.0 { 1 } else { -1 })
312    }
313
314    // -----------------------------------------------------------------------
315    // Loss computation
316    // -----------------------------------------------------------------------
317
318    /// Compute the loss for a given `(score, label)` pair using the learner's
319    /// configured [`OlLossFunction`].
320    ///
321    /// | Loss          | Formula                                        |
322    /// |---------------|------------------------------------------------|
323    /// | Hinge         | `max(0, 1 − label · score)`                    |
324    /// | SquaredHinge  | `max(0, 1 − label · score)²`                   |
325    /// | LogLoss       | `ln(1 + exp(−label · score))` (stable)         |
326    pub fn loss(&self, score: f64, label: f64) -> f64 {
327        compute_loss(self.loss_fn, score, label)
328    }
329
330    // -----------------------------------------------------------------------
331    // Online update
332    // -----------------------------------------------------------------------
333
334    /// Perform a single online update for `sample` and return the pre-update
335    /// loss.
336    ///
337    /// # Errors
338    ///
339    /// * [`LearnerError::EmptyInput`] — `sample.features` is empty but
340    ///   `dims > 0`.
341    /// * [`LearnerError::DimensionMismatch`] — feature length ≠ `dims`.
342    /// * [`LearnerError::InvalidLabel`] — label is not ±1.0 for Perceptron or
343    ///   Passive-Aggressive (binary classifiers).
344    pub fn update(&mut self, sample: &TrainingSample) -> Result<f64, LearnerError> {
345        self.check_dims(&sample.features)?;
346
347        // Binary classifiers require ±1.0 labels.
348        match &self.algorithm {
349            OnlineAlgorithm::Perceptron | OnlineAlgorithm::PassiveAggressive { .. } => {
350                if !is_binary_label(sample.label) {
351                    return Err(LearnerError::InvalidLabel {
352                        label: sample.label,
353                    });
354                }
355            }
356            OnlineAlgorithm::SgdMomentum { .. } => {}
357        }
358
359        let score = dot(&self.weights, &sample.features) + self.bias;
360        let loss = compute_loss(self.loss_fn, score, sample.label);
361
362        // Clone algorithm to avoid borrow issues
363        let algo = self.algorithm.clone();
364        match algo {
365            OnlineAlgorithm::Perceptron => {
366                self.update_perceptron(sample.label, &sample.features, score);
367            }
368            OnlineAlgorithm::PassiveAggressive { c } => {
369                self.update_pa(sample.label, &sample.features, score, c);
370            }
371            OnlineAlgorithm::SgdMomentum {
372                lr,
373                momentum,
374                l2_reg,
375            } => {
376                self.update_sgd(sample.label, &sample.features, score, lr, momentum, l2_reg);
377            }
378        }
379
380        self.running_loss.update(loss);
381        self.total_updates += 1;
382        Ok(loss)
383    }
384
385    /// Perform online updates for a batch of samples, returning the per-sample
386    /// losses in the same order as `samples`.
387    ///
388    /// Equivalent to calling [`update`](Self::update) in sequence.
389    ///
390    /// # Errors
391    ///
392    /// Returns the first error encountered, if any.
393    pub fn batch_update(&mut self, samples: &[TrainingSample]) -> Result<Vec<f64>, LearnerError> {
394        if samples.is_empty() {
395            return Err(LearnerError::EmptyInput);
396        }
397        let mut losses = Vec::with_capacity(samples.len());
398        for sample in samples {
399            losses.push(self.update(sample)?);
400        }
401        Ok(losses)
402    }
403
404    // -----------------------------------------------------------------------
405    // Evaluation
406    // -----------------------------------------------------------------------
407
408    /// Compute the fraction of `samples` correctly classified without updating
409    /// weights.
410    ///
411    /// Classification is performed via [`classify`](Self::classify) so the
412    /// internal `total_predictions` counter is **not** incremented.
413    ///
414    /// # Errors
415    ///
416    /// * [`LearnerError::EmptyInput`] — `samples` is empty.
417    /// * Propagates dimension/label errors from `classify`.
418    pub fn accuracy(&self, samples: &[TrainingSample]) -> Result<f64, LearnerError> {
419        if samples.is_empty() {
420            return Err(LearnerError::EmptyInput);
421        }
422        let mut correct = 0usize;
423        for s in samples {
424            let predicted = self.classify(&s.features)?;
425            let expected = if s.label >= 0.0 { 1_i32 } else { -1_i32 };
426            if predicted == expected {
427                correct += 1;
428            }
429        }
430        Ok(correct as f64 / samples.len() as f64)
431    }
432
433    // -----------------------------------------------------------------------
434    // Maintenance
435    // -----------------------------------------------------------------------
436
437    /// Reset weights, bias, velocity, and all accumulated statistics to zero.
438    pub fn reset(&mut self) {
439        self.weights.fill(0.0);
440        self.bias = 0.0;
441        self.velocity.fill(0.0);
442        self.running_loss = RunningMean::default();
443        self.total_updates = 0;
444        self.correct_predictions = 0;
445        self.total_predictions = 0;
446    }
447
448    /// Compute the L2 norm of the weight vector: `√(Σ wᵢ²)`.
449    pub fn l2_norm(&self) -> f64 {
450        self.weights.iter().map(|w| w * w).sum::<f64>().sqrt()
451    }
452
453    /// Snapshot current training statistics.
454    pub fn stats(&self) -> OnlineLearnerStats {
455        OnlineLearnerStats {
456            total_updates: self.total_updates,
457            correct_predictions: self.correct_predictions,
458            total_predictions: self.total_predictions,
459            avg_loss: self.running_loss.value(),
460            weight_norm: self.l2_norm(),
461        }
462    }
463
464    // -----------------------------------------------------------------------
465    // Private update helpers
466    // -----------------------------------------------------------------------
467
468    fn update_perceptron(&mut self, label: f64, features: &[f64], score: f64) {
469        // Only update on misprediction: label * score ≤ 0
470        if label * score <= 0.0 {
471            for (w, &x) in self.weights.iter_mut().zip(features.iter()) {
472                *w += label * x;
473            }
474            self.bias += label;
475        }
476    }
477
478    fn update_pa(&mut self, label: f64, features: &[f64], score: f64, c: f64) {
479        // Hinge loss (always used for PA update regardless of loss_fn setting)
480        let margin = label * score;
481        let hinge = (1.0 - margin).max(0.0);
482
483        if hinge == 0.0 {
484            // Already in the margin — passive (no update)
485            return;
486        }
487
488        let sq_norm: f64 = features.iter().map(|x| x * x).sum();
489        // PA-I: tau = hinge / (||x||² + 1/(2C))
490        let denom = sq_norm + 1.0 / (2.0 * c);
491        let tau = hinge / denom;
492
493        for (w, &x) in self.weights.iter_mut().zip(features.iter()) {
494            *w += tau * label * x;
495        }
496        self.bias += tau * label;
497    }
498
499    fn update_sgd(
500        &mut self,
501        label: f64,
502        features: &[f64],
503        score: f64,
504        lr: f64,
505        momentum: f64,
506        l2_reg: f64,
507    ) {
508        // Subgradient of hinge loss w.r.t. score:
509        //   if margin < 1  →  -label  (we're inside the margin)
510        //   if margin >= 1 →   0.0    (correct & outside margin — no grad)
511        // For LogLoss, use the logistic gradient: -label * sigmoid(-label*score)
512        let grad_score = match self.loss_fn {
513            OlLossFunction::Hinge | OlLossFunction::SquaredHinge => {
514                let margin = label * score;
515                if margin < 1.0 {
516                    -label
517                } else {
518                    0.0
519                }
520            }
521            OlLossFunction::LogLoss => {
522                // d/d_score ln(1 + exp(-y*s)) = -y * sigma(-y*s)
523                let neg_margin = -(label * score);
524                let sigma = stable_sigmoid(neg_margin);
525                -label * sigma
526            }
527        };
528
529        // Update weight velocity and weights
530        for (i, &xi) in features.iter().enumerate().take(self.dims) {
531            let grad_w = grad_score * xi + l2_reg * self.weights[i];
532            self.velocity[i] = momentum * self.velocity[i] - lr * grad_w;
533            self.weights[i] += self.velocity[i];
534        }
535
536        // Bias does not get L2 regularisation (standard practice)
537        self.bias -= lr * grad_score;
538    }
539
540    // -----------------------------------------------------------------------
541    // Validation helper
542    // -----------------------------------------------------------------------
543
544    fn check_dims(&self, features: &[f64]) -> Result<(), LearnerError> {
545        if self.dims == 0 && features.is_empty() {
546            return Ok(());
547        }
548        if features.is_empty() {
549            return Err(LearnerError::EmptyInput);
550        }
551        if features.len() != self.dims {
552            return Err(LearnerError::DimensionMismatch {
553                expected: self.dims,
554                got: features.len(),
555            });
556        }
557        Ok(())
558    }
559
560    // -----------------------------------------------------------------------
561    // Additional evaluation helpers
562    // -----------------------------------------------------------------------
563
564    /// Compute the average loss over a slice of samples without updating weights.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`LearnerError::EmptyInput`] if `samples` is empty, or
569    /// propagates dimension errors.
570    pub fn average_loss(&self, samples: &[TrainingSample]) -> Result<f64, LearnerError> {
571        if samples.is_empty() {
572            return Err(LearnerError::EmptyInput);
573        }
574        let total: f64 = samples
575            .iter()
576            .map(|s| {
577                let score = dot(&self.weights, &s.features) + self.bias;
578                compute_loss(self.loss_fn, score, s.label)
579            })
580            .sum();
581        Ok(total / samples.len() as f64)
582    }
583
584    /// Compute per-sample losses over `samples` without updating weights.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`LearnerError::EmptyInput`] if `samples` is empty.
589    pub fn evaluate_losses(&self, samples: &[TrainingSample]) -> Result<Vec<f64>, LearnerError> {
590        if samples.is_empty() {
591            return Err(LearnerError::EmptyInput);
592        }
593        samples
594            .iter()
595            .map(|s| {
596                self.check_dims(&s.features)?;
597                let score = dot(&self.weights, &s.features) + self.bias;
598                Ok(compute_loss(self.loss_fn, score, s.label))
599            })
600            .collect()
601    }
602
603    /// Record a correct/incorrect prediction result into the running stats.
604    ///
605    /// This is used internally when `predict_class` is called.  Exposed
606    /// publicly for external evaluation loops that use `classify()` and wish
607    /// to manually feed outcomes back.
608    pub fn record_prediction(&mut self, was_correct: bool) {
609        self.total_predictions += 1;
610        if was_correct {
611            self.correct_predictions += 1;
612        }
613    }
614
615    /// Return a reference to the current weight vector.
616    pub fn weights(&self) -> &[f64] {
617        &self.weights
618    }
619
620    /// Return the current bias value.
621    pub fn bias(&self) -> f64 {
622        self.bias
623    }
624
625    /// Return the number of features this learner was constructed for.
626    pub fn dims(&self) -> usize {
627        self.dims
628    }
629}
630
631// ---------------------------------------------------------------------------
632// Free-function helpers (module-private)
633// ---------------------------------------------------------------------------
634
635/// Dot product of two equal-length slices.
636fn dot(a: &[f64], b: &[f64]) -> f64 {
637    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
638}
639
640/// Numerically stable sigmoid: σ(x) = 1/(1 + exp(-x)).
641///
642/// Uses the standard trick of branching on the sign of x to avoid overflow.
643fn stable_sigmoid(x: f64) -> f64 {
644    if x >= 0.0 {
645        let e = (-x).exp();
646        1.0 / (1.0 + e)
647    } else {
648        let e = x.exp();
649        e / (1.0 + e)
650    }
651}
652
653/// Numerically stable log-sigmoid loss: ln(1 + exp(-margin)).
654///
655/// Uses log-sum-exp trick for numerical stability.
656fn log_loss_stable(margin: f64) -> f64 {
657    // ln(1 + exp(-margin))
658    if margin >= 0.0 {
659        // margin >= 0 → exp(-margin) ≤ 1 → no overflow
660        (-margin).exp().ln_1p()
661    } else {
662        // margin < 0 → -margin > 0 → exp(-margin) can overflow
663        // Use: ln(1 + exp(-margin)) = -margin + ln(1 + exp(margin))
664        -margin + margin.exp().ln_1p()
665    }
666}
667
668/// Compute loss for the given function variant.
669fn compute_loss(loss_fn: OlLossFunction, score: f64, label: f64) -> f64 {
670    let margin = label * score;
671    match loss_fn {
672        OlLossFunction::Hinge => (1.0 - margin).max(0.0),
673        OlLossFunction::SquaredHinge => {
674            let h = (1.0 - margin).max(0.0);
675            h * h
676        }
677        OlLossFunction::LogLoss => log_loss_stable(margin),
678    }
679}
680
681/// Return `true` iff `label` is ±1.0 (up to floating-point precision).
682fn is_binary_label(label: f64) -> bool {
683    (label - 1.0).abs() < 1e-9 || (label + 1.0).abs() < 1e-9
684}
685
686// ---------------------------------------------------------------------------
687// Tests
688// ---------------------------------------------------------------------------
689
690#[cfg(test)]
691mod tests {
692    use super::{
693        compute_loss, dot, is_binary_label, log_loss_stable, stable_sigmoid, LearnerError,
694        OlLossFunction, OnlineAlgorithm, OnlineLearner, TrainingSample,
695    };
696
697    // -----------------------------------------------------------------------
698    // Helper builders
699    // -----------------------------------------------------------------------
700
701    fn perceptron(dims: usize) -> OnlineLearner {
702        OnlineLearner::new(OnlineAlgorithm::Perceptron, dims, OlLossFunction::Hinge)
703    }
704
705    fn pa(dims: usize, c: f64) -> OnlineLearner {
706        OnlineLearner::new(
707            OnlineAlgorithm::PassiveAggressive { c },
708            dims,
709            OlLossFunction::Hinge,
710        )
711    }
712
713    fn sgd(dims: usize, lr: f64, momentum: f64, l2_reg: f64) -> OnlineLearner {
714        OnlineLearner::new(
715            OnlineAlgorithm::SgdMomentum {
716                lr,
717                momentum,
718                l2_reg,
719            },
720            dims,
721            OlLossFunction::Hinge,
722        )
723    }
724
725    fn sample(features: Vec<f64>, label: f64) -> TrainingSample {
726        TrainingSample::new(features, label)
727    }
728
729    // -----------------------------------------------------------------------
730    // Test 1: construction initialises to zero
731    // -----------------------------------------------------------------------
732    #[test]
733    fn test_construction_zero_init() {
734        let learner = perceptron(4);
735        assert_eq!(learner.dims(), 4);
736        assert_eq!(learner.bias(), 0.0);
737        assert!(learner.weights().iter().all(|&w| w == 0.0));
738        assert!(learner.velocity.iter().all(|&v| v == 0.0));
739    }
740
741    // -----------------------------------------------------------------------
742    // Test 2: predict on zero weights returns bias (0)
743    // -----------------------------------------------------------------------
744    #[test]
745    fn test_predict_zero_weights() {
746        let learner = perceptron(3);
747        let score = learner
748            .predict(&[1.0, 2.0, 3.0])
749            .expect("test: should succeed");
750        assert_eq!(score, 0.0);
751    }
752
753    // -----------------------------------------------------------------------
754    // Test 3: dimension mismatch error
755    // -----------------------------------------------------------------------
756    #[test]
757    fn test_dimension_mismatch() {
758        let learner = perceptron(3);
759        let err = learner.predict(&[1.0, 2.0]).unwrap_err();
760        assert!(matches!(
761            err,
762            LearnerError::DimensionMismatch {
763                expected: 3,
764                got: 2
765            }
766        ));
767    }
768
769    // -----------------------------------------------------------------------
770    // Test 4: empty input error
771    // -----------------------------------------------------------------------
772    #[test]
773    fn test_empty_input() {
774        let learner = perceptron(3);
775        let err = learner.predict(&[]).unwrap_err();
776        assert_eq!(err, LearnerError::EmptyInput);
777    }
778
779    // -----------------------------------------------------------------------
780    // Test 5: invalid label for perceptron
781    // -----------------------------------------------------------------------
782    #[test]
783    fn test_invalid_label_perceptron() {
784        let mut learner = perceptron(2);
785        let s = sample(vec![1.0, 0.0], 0.5);
786        let err = learner.update(&s).unwrap_err();
787        assert!(matches!(err, LearnerError::InvalidLabel { .. }));
788    }
789
790    // -----------------------------------------------------------------------
791    // Test 6: invalid label for PA
792    // -----------------------------------------------------------------------
793    #[test]
794    fn test_invalid_label_pa() {
795        let mut learner = pa(2, 1.0);
796        let s = sample(vec![1.0, 0.0], 0.0);
797        let err = learner.update(&s).unwrap_err();
798        assert!(matches!(err, LearnerError::InvalidLabel { .. }));
799    }
800
801    // -----------------------------------------------------------------------
802    // Test 7: SGD accepts non-binary labels
803    // -----------------------------------------------------------------------
804    #[test]
805    fn test_sgd_non_binary_label() {
806        let mut learner = sgd(2, 0.1, 0.9, 0.0);
807        let s = sample(vec![1.0, 0.5], 2.5);
808        // Should not error
809        learner.update(&s).expect("test: TD update should succeed");
810    }
811
812    // -----------------------------------------------------------------------
813    // Test 8: Perceptron updates on misprediction
814    // -----------------------------------------------------------------------
815    #[test]
816    fn test_perceptron_updates_on_misprediction() {
817        let mut learner = perceptron(2);
818        // Zero weights → score=0 → label*score=0 ≤ 0 → misprediction for label=1
819        let s = sample(vec![1.0, 1.0], 1.0);
820        learner.update(&s).expect("test: TD update should succeed");
821        // Weights should be updated: w += label * x → [1, 1]
822        assert_eq!(learner.weights()[0], 1.0);
823        assert_eq!(learner.weights()[1], 1.0);
824        assert_eq!(learner.bias(), 1.0);
825    }
826
827    // -----------------------------------------------------------------------
828    // Test 9: Perceptron no update when correctly classified
829    // -----------------------------------------------------------------------
830    #[test]
831    fn test_perceptron_no_update_correct() {
832        let mut learner = perceptron(2);
833        // Give it correct weights first
834        learner.weights[0] = 2.0;
835        learner.bias = 1.0;
836        // score = 2.0 * 1.0 + 1.0 = 3.0 → label*score = 3 > 0 → correct
837        let s = sample(vec![1.0, 0.0], 1.0);
838        learner.update(&s).expect("test: TD update should succeed");
839        assert_eq!(learner.weights()[0], 2.0); // unchanged
840        assert_eq!(learner.bias(), 1.0); // unchanged
841    }
842
843    // -----------------------------------------------------------------------
844    // Test 10: Perceptron converges on linearly separable data
845    // -----------------------------------------------------------------------
846    #[test]
847    fn test_perceptron_convergence() {
848        let mut learner = perceptron(2);
849        let positives: Vec<_> = (0..5)
850            .map(|i| sample(vec![i as f64 + 1.0, 0.5], 1.0))
851            .collect();
852        let negatives: Vec<_> = (0..5)
853            .map(|i| sample(vec![-(i as f64 + 1.0), -0.5], -1.0))
854            .collect();
855
856        let mut all: Vec<TrainingSample> = Vec::new();
857        all.extend(positives);
858        all.extend(negatives);
859
860        for _ in 0..20 {
861            for s in &all {
862                let _ = learner.update(s);
863            }
864        }
865        let acc = learner.accuracy(&all).expect("test: should succeed");
866        assert!(acc > 0.9, "Expected accuracy > 0.9, got {acc}");
867    }
868
869    // -----------------------------------------------------------------------
870    // Test 11: PA-I update reduces loss on positive example
871    // -----------------------------------------------------------------------
872    #[test]
873    fn test_pa_update_reduces_loss() {
874        let mut learner = pa(2, 1.0);
875        let s = sample(vec![1.0, 0.0], 1.0);
876        let pre_loss = learner.update(&s).expect("test: TD update should succeed");
877        let post_score = learner.predict(&s.features).expect("test: should succeed");
878        let post_loss = compute_loss(OlLossFunction::Hinge, post_score, 1.0);
879        // Loss should decrease or stay zero
880        assert!(post_loss <= pre_loss + 1e-10);
881    }
882
883    // -----------------------------------------------------------------------
884    // Test 12: PA-I passive on already correct prediction
885    // -----------------------------------------------------------------------
886    #[test]
887    fn test_pa_passive_when_correct() {
888        let mut learner = pa(2, 1.0);
889        // Set large weights so sample is correctly classified with large margin
890        learner.weights[0] = 10.0;
891        let s = sample(vec![1.0, 0.0], 1.0); // score = 10 → margin = 10 > 1
892        let w_before = learner.weights()[0];
893        learner.update(&s).expect("test: TD update should succeed");
894        assert_eq!(learner.weights()[0], w_before); // no update
895    }
896
897    // -----------------------------------------------------------------------
898    // Test 13: PA-I tau computation is correct
899    // -----------------------------------------------------------------------
900    #[test]
901    fn test_pa_tau_formula() {
902        let mut learner = pa(1, 1.0);
903        // x = [1.0], label = 1.0, initial score = 0
904        // loss = max(0, 1 - 1*0) = 1
905        // ||x||^2 = 1
906        // tau = 1 / (1 + 1/(2*1)) = 1 / 1.5 = 2/3
907        let s = sample(vec![1.0], 1.0);
908        learner.update(&s).expect("test: TD update should succeed");
909        let expected = 2.0 / 3.0;
910        assert!((learner.weights()[0] - expected).abs() < 1e-10);
911    }
912
913    // -----------------------------------------------------------------------
914    // Test 14: SGD momentum velocity accumulates
915    // -----------------------------------------------------------------------
916    #[test]
917    fn test_sgd_velocity_accumulates() {
918        let mut learner = sgd(2, 0.1, 0.9, 0.0);
919        let s = sample(vec![1.0, 1.0], 1.0);
920        learner.update(&s).expect("test: TD update should succeed");
921        // velocity should be non-zero after first update
922        let v_sum: f64 = learner.velocity.iter().sum();
923        assert_ne!(v_sum, 0.0);
924    }
925
926    // -----------------------------------------------------------------------
927    // Test 15: SGD L2 regularisation shrinks weights
928    // -----------------------------------------------------------------------
929    #[test]
930    fn test_sgd_l2_shrinks_weights() {
931        let mut learner = OnlineLearner::new(
932            OnlineAlgorithm::SgdMomentum {
933                lr: 0.01,
934                momentum: 0.0,
935                l2_reg: 0.1,
936            },
937            2,
938            OlLossFunction::Hinge,
939        );
940        // Give it some weights
941        learner.weights[0] = 5.0;
942        learner.weights[1] = 5.0;
943
944        // Correctly classified sample (no gradient from loss, only L2)
945        // score = 5.0 * 0.0 = 0 → loss grad = -label = -1 (in margin)
946        // But let's use large weights so the score will be large enough
947        learner.weights[0] = 5.0;
948        learner.weights[1] = 0.0;
949        // score = 5.0*1.0 + 0.0*0.0 = 5.0, margin = 5 > 1 → grad_score = 0
950        // Only L2 acts: grad_w = l2_reg * w[0] = 0.1 * 5 = 0.5
951        // velocity = 0 - 0.01 * 0.5 = -0.005
952        // w[0] = 5.0 - 0.005 = 4.995
953        let s = sample(vec![1.0, 0.0], 1.0);
954        learner.update(&s).expect("test: TD update should succeed");
955        assert!(learner.weights()[0] < 5.0);
956    }
957
958    // -----------------------------------------------------------------------
959    // Test 16: predict_class returns +1 or -1
960    // -----------------------------------------------------------------------
961    #[test]
962    fn test_predict_class_values() {
963        let mut learner = perceptron(2);
964        learner.weights[0] = 1.0;
965        let c1 = learner
966            .predict_class(&[1.0, 0.0])
967            .expect("test: should succeed");
968        let c2 = learner
969            .predict_class(&[-1.0, 0.0])
970            .expect("test: should succeed");
971        assert_eq!(c1, 1);
972        assert_eq!(c2, -1);
973    }
974
975    // -----------------------------------------------------------------------
976    // Test 17: predict_class updates total_predictions
977    // -----------------------------------------------------------------------
978    #[test]
979    fn test_predict_class_updates_stats() {
980        let mut learner = perceptron(2);
981        learner
982            .predict_class(&[1.0, 0.0])
983            .expect("test: should succeed");
984        learner
985            .predict_class(&[1.0, 0.0])
986            .expect("test: should succeed");
987        assert_eq!(learner.stats().total_predictions, 2);
988    }
989
990    // -----------------------------------------------------------------------
991    // Test 18: batch_update returns per-sample losses
992    // -----------------------------------------------------------------------
993    #[test]
994    fn test_batch_update_returns_losses() {
995        let mut learner = perceptron(2);
996        let samples = vec![sample(vec![1.0, 0.0], 1.0), sample(vec![0.0, 1.0], -1.0)];
997        let losses = learner
998            .batch_update(&samples)
999            .expect("test: should succeed");
1000        assert_eq!(losses.len(), 2);
1001        assert!(losses.iter().all(|&l| l >= 0.0));
1002    }
1003
1004    // -----------------------------------------------------------------------
1005    // Test 19: batch_update on empty slice returns EmptyInput
1006    // -----------------------------------------------------------------------
1007    #[test]
1008    fn test_batch_update_empty() {
1009        let mut learner = perceptron(2);
1010        let err = learner.batch_update(&[]).unwrap_err();
1011        assert_eq!(err, LearnerError::EmptyInput);
1012    }
1013
1014    // -----------------------------------------------------------------------
1015    // Test 20: accuracy on perfectly learned data is 1.0
1016    // -----------------------------------------------------------------------
1017    #[test]
1018    fn test_accuracy_perfect() {
1019        let mut learner = perceptron(1);
1020        let samples = vec![sample(vec![3.0], 1.0), sample(vec![-3.0], -1.0)];
1021        // Train multiple epochs
1022        for _ in 0..10 {
1023            for s in &samples {
1024                let _ = learner.update(s);
1025            }
1026        }
1027        let acc = learner.accuracy(&samples).expect("test: should succeed");
1028        assert_eq!(acc, 1.0);
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // Test 21: accuracy on empty returns EmptyInput
1033    // -----------------------------------------------------------------------
1034    #[test]
1035    fn test_accuracy_empty() {
1036        let learner = perceptron(2);
1037        let err = learner.accuracy(&[]).unwrap_err();
1038        assert_eq!(err, LearnerError::EmptyInput);
1039    }
1040
1041    // -----------------------------------------------------------------------
1042    // Test 22: reset zeroes everything
1043    // -----------------------------------------------------------------------
1044    #[test]
1045    fn test_reset() {
1046        let mut learner = perceptron(3);
1047        let s = sample(vec![1.0, 1.0, 1.0], 1.0);
1048        learner.update(&s).expect("test: TD update should succeed");
1049        learner.reset();
1050        assert!(learner.weights().iter().all(|&w| w == 0.0));
1051        assert_eq!(learner.bias(), 0.0);
1052        assert_eq!(learner.stats().total_updates, 0);
1053        assert_eq!(learner.stats().avg_loss, 0.0);
1054    }
1055
1056    // -----------------------------------------------------------------------
1057    // Test 23: l2_norm of zero vector is 0
1058    // -----------------------------------------------------------------------
1059    #[test]
1060    fn test_l2_norm_zero() {
1061        let learner = perceptron(4);
1062        assert_eq!(learner.l2_norm(), 0.0);
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // Test 24: l2_norm computation is correct
1067    // -----------------------------------------------------------------------
1068    #[test]
1069    fn test_l2_norm_value() {
1070        let mut learner = perceptron(2);
1071        learner.weights[0] = 3.0;
1072        learner.weights[1] = 4.0;
1073        assert!((learner.l2_norm() - 5.0).abs() < 1e-10);
1074    }
1075
1076    // -----------------------------------------------------------------------
1077    // Test 25: stats() reports correct total_updates
1078    // -----------------------------------------------------------------------
1079    #[test]
1080    fn test_stats_total_updates() {
1081        let mut learner = perceptron(2);
1082        for _ in 0..5 {
1083            learner
1084                .update(&sample(vec![1.0, 0.0], 1.0))
1085                .expect("test: should succeed");
1086        }
1087        assert_eq!(learner.stats().total_updates, 5);
1088    }
1089
1090    // -----------------------------------------------------------------------
1091    // Test 26: avg_loss increases on hard examples
1092    // -----------------------------------------------------------------------
1093    #[test]
1094    fn test_stats_avg_loss_non_negative() {
1095        let mut learner = perceptron(2);
1096        let samples = vec![sample(vec![1.0, 0.0], 1.0), sample(vec![-1.0, 0.0], -1.0)];
1097        let _ = learner
1098            .batch_update(&samples)
1099            .expect("test: should succeed");
1100        assert!(learner.stats().avg_loss >= 0.0);
1101    }
1102
1103    // -----------------------------------------------------------------------
1104    // Test 27: Hinge loss computation
1105    // -----------------------------------------------------------------------
1106    #[test]
1107    fn test_hinge_loss() {
1108        // margin = 1 → loss = 0
1109        assert_eq!(compute_loss(OlLossFunction::Hinge, 1.0, 1.0), 0.0);
1110        // margin = 0.5 → loss = 0.5
1111        assert!((compute_loss(OlLossFunction::Hinge, 0.5, 1.0) - 0.5).abs() < 1e-10);
1112        // margin = -1 → loss = 2
1113        assert!((compute_loss(OlLossFunction::Hinge, -1.0, 1.0) - 2.0).abs() < 1e-10);
1114    }
1115
1116    // -----------------------------------------------------------------------
1117    // Test 28: SquaredHinge loss computation
1118    // -----------------------------------------------------------------------
1119    #[test]
1120    fn test_squared_hinge_loss() {
1121        // margin = 1 → loss = 0
1122        assert_eq!(compute_loss(OlLossFunction::SquaredHinge, 1.0, 1.0), 0.0);
1123        // margin = 0.5 → hinge = 0.5, loss = 0.25
1124        assert!((compute_loss(OlLossFunction::SquaredHinge, 0.5, 1.0) - 0.25).abs() < 1e-10);
1125        // margin = -1 → hinge = 2, loss = 4
1126        assert!((compute_loss(OlLossFunction::SquaredHinge, -1.0, 1.0) - 4.0).abs() < 1e-10);
1127    }
1128
1129    // -----------------------------------------------------------------------
1130    // Test 29: LogLoss computation and numerical stability
1131    // -----------------------------------------------------------------------
1132    #[test]
1133    fn test_log_loss_stability() {
1134        // At score=0, margin=0 → ln(2) ≈ 0.693
1135        let l = compute_loss(OlLossFunction::LogLoss, 0.0, 1.0);
1136        assert!((l - std::f64::consts::LN_2).abs() < 1e-10);
1137
1138        // Large positive margin → very small loss
1139        let l_large = compute_loss(OlLossFunction::LogLoss, 100.0, 1.0);
1140        assert!(l_large < 1e-10);
1141
1142        // Large negative margin → approximately equal to |margin|
1143        let l_neg = compute_loss(OlLossFunction::LogLoss, -100.0, 1.0);
1144        assert!((l_neg - 100.0).abs() < 1.0);
1145
1146        // Always non-negative
1147        for s in [-10.0_f64, -1.0, 0.0, 1.0, 10.0] {
1148            for y in [-1.0_f64, 1.0] {
1149                assert!(compute_loss(OlLossFunction::LogLoss, s, y) >= 0.0);
1150            }
1151        }
1152    }
1153
1154    // -----------------------------------------------------------------------
1155    // Test 30: stable_sigmoid is in (0,1) and symmetric
1156    // -----------------------------------------------------------------------
1157    #[test]
1158    fn test_stable_sigmoid() {
1159        assert!((stable_sigmoid(0.0) - 0.5).abs() < 1e-10);
1160        assert!(stable_sigmoid(100.0) > 0.999);
1161        assert!(stable_sigmoid(-100.0) < 0.001);
1162        // Symmetry: sigma(x) = 1 - sigma(-x)
1163        for x in [-5.0_f64, -1.0, 0.0, 1.0, 5.0] {
1164            assert!((stable_sigmoid(x) + stable_sigmoid(-x) - 1.0).abs() < 1e-12);
1165        }
1166    }
1167
1168    // -----------------------------------------------------------------------
1169    // Test 31: log_loss_stable equals ln(2) at margin=0
1170    // -----------------------------------------------------------------------
1171    #[test]
1172    fn test_log_loss_stable_fn() {
1173        let at_zero = log_loss_stable(0.0);
1174        assert!((at_zero - std::f64::consts::LN_2).abs() < 1e-12);
1175        // Positive margin → decreasing loss
1176        assert!(log_loss_stable(1.0) < log_loss_stable(0.0));
1177        assert!(log_loss_stable(5.0) < log_loss_stable(1.0));
1178    }
1179
1180    // -----------------------------------------------------------------------
1181    // Test 32: dot product correctness
1182    // -----------------------------------------------------------------------
1183    #[test]
1184    fn test_dot() {
1185        assert_eq!(dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]), 32.0);
1186        assert_eq!(dot(&[], &[]), 0.0);
1187    }
1188
1189    // -----------------------------------------------------------------------
1190    // Test 33: is_binary_label helper
1191    // -----------------------------------------------------------------------
1192    #[test]
1193    fn test_is_binary_label() {
1194        assert!(is_binary_label(1.0));
1195        assert!(is_binary_label(-1.0));
1196        assert!(!is_binary_label(0.0));
1197        assert!(!is_binary_label(2.0));
1198        assert!(!is_binary_label(0.5));
1199    }
1200
1201    // -----------------------------------------------------------------------
1202    // Test 34: TrainingSample::is_valid_binary_label
1203    // -----------------------------------------------------------------------
1204    #[test]
1205    fn test_training_sample_valid_binary_label() {
1206        let pos = sample(vec![1.0], 1.0);
1207        let neg = sample(vec![1.0], -1.0);
1208        let bad = sample(vec![1.0], 0.0);
1209        assert!(pos.is_valid_binary_label());
1210        assert!(neg.is_valid_binary_label());
1211        assert!(!bad.is_valid_binary_label());
1212    }
1213
1214    // -----------------------------------------------------------------------
1215    // Test 35: classify does not modify prediction stats
1216    // -----------------------------------------------------------------------
1217    #[test]
1218    fn test_classify_no_stats_change() {
1219        let learner = perceptron(2);
1220        learner.classify(&[1.0, 0.0]).expect("test: should succeed");
1221        assert_eq!(learner.stats().total_predictions, 0);
1222    }
1223
1224    // -----------------------------------------------------------------------
1225    // Test 36: evaluate_losses returns correct count
1226    // -----------------------------------------------------------------------
1227    #[test]
1228    fn test_evaluate_losses() {
1229        let learner = perceptron(2);
1230        let samples = vec![
1231            sample(vec![1.0, 0.0], 1.0),
1232            sample(vec![0.0, 1.0], -1.0),
1233            sample(vec![1.0, 1.0], 1.0),
1234        ];
1235        let losses = learner
1236            .evaluate_losses(&samples)
1237            .expect("test: should succeed");
1238        assert_eq!(losses.len(), 3);
1239        assert!(losses.iter().all(|&l| l >= 0.0));
1240    }
1241
1242    // -----------------------------------------------------------------------
1243    // Test 37: average_loss empty returns error
1244    // -----------------------------------------------------------------------
1245    #[test]
1246    fn test_average_loss_empty() {
1247        let learner = perceptron(2);
1248        let err = learner.average_loss(&[]).unwrap_err();
1249        assert_eq!(err, LearnerError::EmptyInput);
1250    }
1251
1252    // -----------------------------------------------------------------------
1253    // Test 38: SGD with LogLoss converges on simple data
1254    // -----------------------------------------------------------------------
1255    #[test]
1256    fn test_sgd_logloss_convergence() {
1257        let mut learner = OnlineLearner::new(
1258            OnlineAlgorithm::SgdMomentum {
1259                lr: 0.1,
1260                momentum: 0.9,
1261                l2_reg: 0.001,
1262            },
1263            1,
1264            OlLossFunction::LogLoss,
1265        );
1266        // Trivially separable 1-D data
1267        let pos = sample(vec![3.0], 1.0);
1268        let neg = sample(vec![-3.0], -1.0);
1269
1270        for _ in 0..200 {
1271            let _ = learner.update(&pos);
1272            let _ = learner.update(&neg);
1273        }
1274        assert_eq!(learner.classify(&[3.0]).expect("test: should succeed"), 1);
1275        assert_eq!(learner.classify(&[-3.0]).expect("test: should succeed"), -1);
1276    }
1277
1278    // -----------------------------------------------------------------------
1279    // Test 39: PA-I with different C values
1280    // -----------------------------------------------------------------------
1281    #[test]
1282    fn test_pa_c_parameter_effect() {
1283        // Larger C → more aggressive update → larger weight change per step
1284        let mut learner_low_c = pa(1, 0.1);
1285        let mut learner_high_c = pa(1, 100.0);
1286
1287        let s = sample(vec![1.0], 1.0);
1288        learner_low_c
1289            .update(&s)
1290            .expect("test: TD update should succeed");
1291        learner_high_c
1292            .update(&s)
1293            .expect("test: TD update should succeed");
1294
1295        // High C should produce a larger (or equal) weight update
1296        assert!(learner_high_c.weights()[0] >= learner_low_c.weights()[0]);
1297    }
1298
1299    // -----------------------------------------------------------------------
1300    // Test 40: OnlineLearnerStats weight_norm matches l2_norm
1301    // -----------------------------------------------------------------------
1302    #[test]
1303    fn test_stats_weight_norm() {
1304        let mut learner = perceptron(3);
1305        learner
1306            .update(&sample(vec![3.0, 0.0, 4.0], 1.0))
1307            .expect("test: should succeed");
1308        let stats = learner.stats();
1309        assert!((stats.weight_norm - learner.l2_norm()).abs() < 1e-10);
1310    }
1311
1312    // -----------------------------------------------------------------------
1313    // Test 41: record_prediction increments counts
1314    // -----------------------------------------------------------------------
1315    #[test]
1316    fn test_record_prediction() {
1317        let mut learner = perceptron(2);
1318        learner.record_prediction(true);
1319        learner.record_prediction(false);
1320        learner.record_prediction(true);
1321        let s = learner.stats();
1322        assert_eq!(s.total_predictions, 3);
1323        assert_eq!(s.correct_predictions, 2);
1324    }
1325
1326    // -----------------------------------------------------------------------
1327    // Test 42: LearnerError display messages
1328    // -----------------------------------------------------------------------
1329    #[test]
1330    fn test_error_display() {
1331        let e1 = LearnerError::DimensionMismatch {
1332            expected: 3,
1333            got: 2,
1334        };
1335        let e2 = LearnerError::EmptyInput;
1336        let e3 = LearnerError::InvalidLabel { label: 0.5 };
1337        assert!(e1.to_string().contains("3"));
1338        assert!(e2.to_string().contains("empty"));
1339        assert!(e3.to_string().contains("0.5"));
1340    }
1341
1342    // -----------------------------------------------------------------------
1343    // Test 43: OlLossFunction display
1344    // -----------------------------------------------------------------------
1345    #[test]
1346    fn test_loss_function_display() {
1347        assert_eq!(OlLossFunction::Hinge.to_string(), "Hinge");
1348        assert_eq!(OlLossFunction::SquaredHinge.to_string(), "SquaredHinge");
1349        assert_eq!(OlLossFunction::LogLoss.to_string(), "LogLoss");
1350    }
1351
1352    // -----------------------------------------------------------------------
1353    // Test 44: perceptron correctly handles negative class features
1354    // -----------------------------------------------------------------------
1355    #[test]
1356    fn test_perceptron_negative_class() {
1357        let mut learner = perceptron(2);
1358        let s = sample(vec![-1.0, -1.0], -1.0);
1359        // Initial score = 0, label*score = 0 ≤ 0 → update
1360        learner.update(&s).expect("test: TD update should succeed");
1361        // w += label * x = -1 * [-1, -1] = [1, 1] wait that's wrong
1362        // w += (-1) * (-1, -1) = (1, 1)
1363        assert_eq!(learner.weights()[0], 1.0);
1364        assert_eq!(learner.bias(), -1.0);
1365    }
1366
1367    // -----------------------------------------------------------------------
1368    // Test 45: multiple reset cycles
1369    // -----------------------------------------------------------------------
1370    #[test]
1371    fn test_multiple_reset_cycles() {
1372        let mut learner = perceptron(3);
1373        for _ in 0..3 {
1374            for _ in 0..5 {
1375                let _ = learner.update(&sample(vec![1.0, 0.0, 0.5], 1.0));
1376            }
1377            learner.reset();
1378            assert_eq!(learner.stats().total_updates, 0);
1379            assert!(learner.weights().iter().all(|&w| w == 0.0));
1380        }
1381    }
1382
1383    // -----------------------------------------------------------------------
1384    // Test 46: SGD with zero momentum behaves like vanilla SGD
1385    // -----------------------------------------------------------------------
1386    #[test]
1387    fn test_sgd_zero_momentum() {
1388        let mut learner = OnlineLearner::new(
1389            OnlineAlgorithm::SgdMomentum {
1390                lr: 0.5,
1391                momentum: 0.0,
1392                l2_reg: 0.0,
1393            },
1394            1,
1395            OlLossFunction::Hinge,
1396        );
1397        // x=[1], label=1, score=0, margin=0 < 1 → grad_score = -1
1398        // grad_w = -1 * 1 + 0 * 0 = -1
1399        // velocity = 0*0 - 0.5*(-1) = 0.5
1400        // w = 0 + 0.5 = 0.5
1401        let s = sample(vec![1.0], 1.0);
1402        learner.update(&s).expect("test: TD update should succeed");
1403        assert!((learner.weights()[0] - 0.5).abs() < 1e-10);
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // Test 47: SquaredHinge SGD gradient at margin boundary
1408    // -----------------------------------------------------------------------
1409    #[test]
1410    fn test_squared_hinge_sgd_boundary() {
1411        let mut learner = OnlineLearner::new(
1412            OnlineAlgorithm::SgdMomentum {
1413                lr: 0.1,
1414                momentum: 0.0,
1415                l2_reg: 0.0,
1416            },
1417            1,
1418            OlLossFunction::SquaredHinge,
1419        );
1420        // margin=1 → grad_score = 0 (outside margin)
1421        learner.weights[0] = 1.0;
1422        // score = 1.0, margin = 1 → grad_score = 0
1423        let w_before = learner.weights()[0];
1424        let s = sample(vec![1.0], 1.0);
1425        learner.update(&s).expect("test: TD update should succeed");
1426        assert_eq!(learner.weights()[0], w_before); // no update
1427    }
1428
1429    // -----------------------------------------------------------------------
1430    // Test 48: evaluate_losses empty returns EmptyInput
1431    // -----------------------------------------------------------------------
1432    #[test]
1433    fn test_evaluate_losses_empty() {
1434        let learner = perceptron(2);
1435        let err = learner.evaluate_losses(&[]).unwrap_err();
1436        assert_eq!(err, LearnerError::EmptyInput);
1437    }
1438
1439    // -----------------------------------------------------------------------
1440    // Test 49: batch_update increments total_updates correctly
1441    // -----------------------------------------------------------------------
1442    #[test]
1443    fn test_batch_update_stats_total_updates() {
1444        let mut learner = perceptron(2);
1445        let samples = vec![
1446            sample(vec![1.0, 0.0], 1.0),
1447            sample(vec![0.0, 1.0], -1.0),
1448            sample(vec![1.0, 1.0], 1.0),
1449        ];
1450        learner
1451            .batch_update(&samples)
1452            .expect("test: should succeed");
1453        assert_eq!(learner.stats().total_updates, 3);
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // Test 50: PA converges on 2-D linearly separable data
1458    // -----------------------------------------------------------------------
1459    #[test]
1460    fn test_pa_convergence_2d() {
1461        let mut learner = pa(2, 1.0);
1462        let samples: Vec<TrainingSample> = vec![
1463            sample(vec![2.0, 1.0], 1.0),
1464            sample(vec![1.0, 2.0], 1.0),
1465            sample(vec![-2.0, -1.0], -1.0),
1466            sample(vec![-1.0, -2.0], -1.0),
1467        ];
1468        for _ in 0..30 {
1469            for s in &samples {
1470                let _ = learner.update(s);
1471            }
1472        }
1473        let acc = learner.accuracy(&samples).expect("test: should succeed");
1474        assert_eq!(acc, 1.0);
1475    }
1476}