Skip to main content

geographdb_core/algorithms/
mlp.rs

1//! Pure-Rust MLP primitives for small geometric training loops.
2//!
3//! No external BLAS or autograd framework — just row-major matrix multiply,
4//! stable softmax, cross-entropy, and a tiny two-layer MLP with manual
5//! back-propagation. Intended for CPU demos and experiments that need a
6//! trainable geometric head without pulling in a full ML framework.
7
8use crate::algorithms::natural_grad::softmax;
9use ndarray::{Array1, Array2, ArrayView2, Axis};
10
11/// Row-major matrix multiplication.
12///
13/// `a` is `m × k`, `b` is `k × n`, and the returned vector is `m × n`.
14pub fn matmul(a: &[f32], a_rows: usize, a_cols: usize, b: &[f32], b_cols: usize) -> Vec<f32> {
15    assert_eq!(a.len(), a_rows * a_cols, "matmul: a dimensions mismatch");
16    assert_eq!(b.len(), a_cols * b_cols, "matmul: b dimensions mismatch");
17
18    let mut c = vec![0.0f32; a_rows * b_cols];
19    matmul_into(a, a_rows, a_cols, b, b_cols, &mut c);
20    c
21}
22
23/// Row-major matrix multiplication writing into a pre-allocated `c` buffer.
24///
25/// `c` must have length `a_rows * b_cols`.
26pub fn matmul_into(
27    a: &[f32],
28    a_rows: usize,
29    a_cols: usize,
30    b: &[f32],
31    b_cols: usize,
32    c: &mut [f32],
33) {
34    assert_eq!(
35        a.len(),
36        a_rows * a_cols,
37        "matmul_into: a dimensions mismatch"
38    );
39    assert_eq!(
40        b.len(),
41        a_cols * b_cols,
42        "matmul_into: b dimensions mismatch"
43    );
44    assert_eq!(
45        c.len(),
46        a_rows * b_cols,
47        "matmul_into: c dimensions mismatch"
48    );
49
50    // matrixmultiply::sgemm gives near-BLAS performance for f32.
51    // Strides are row-major: row stride = width, col stride = 1.
52    unsafe {
53        matrixmultiply::sgemm(
54            a_rows,
55            a_cols,
56            b_cols,
57            1.0,
58            a.as_ptr(),
59            a_cols as isize,
60            1,
61            b.as_ptr(),
62            b_cols as isize,
63            1,
64            0.0,
65            c.as_mut_ptr(),
66            b_cols as isize,
67            1,
68        );
69    }
70}
71
72/// Cross-entropy loss from a probability distribution and an integer target class.
73///
74/// Returns `-ln(probs[target])` with a small floor to avoid `log(0)`.
75pub fn cross_entropy_loss(probs: &[f32], target: usize) -> f32 {
76    assert!(!probs.is_empty(), "cross_entropy_loss: empty distribution");
77    assert!(
78        target < probs.len(),
79        "cross_entropy_loss: target out of bounds"
80    );
81    -probs[target].max(1e-30).ln()
82}
83
84/// Stable cross-entropy directly from logits and an integer target class.
85///
86/// Computes `log(sum(exp(logits))) - logits[target]` in a numerically stable way
87/// so large-magnitude logits do not overflow.
88pub fn cross_entropy_from_logits(logits: &[f32], target: usize) -> f32 {
89    assert!(
90        !logits.is_empty(),
91        "cross_entropy_from_logits: empty logits"
92    );
93    assert!(
94        target < logits.len(),
95        "cross_entropy_from_logits: target out of bounds"
96    );
97    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
98    let log_sum_exp = logits.iter().map(|&l| (l - max).exp()).sum::<f32>().ln() + max;
99    -logits[target] + log_sum_exp
100}
101
102/// Gradient of the cross-entropy loss with respect to logits.
103///
104/// Returns `softmax(logits) - one_hot(target)`.
105pub fn cross_entropy_logits_grad(logits: &[f32], target: usize) -> Vec<f32> {
106    let mut probs = softmax(logits);
107    probs[target] -= 1.0;
108    probs
109}
110
111/// Build a one-hot column vector of length `classes` with a 1 at `class`.
112pub fn one_hot(class: usize, classes: usize) -> Vec<f32> {
113    assert!(class < classes, "one_hot: class out of bounds");
114    let mut v = vec![0.0f32; classes];
115    v[class] = 1.0;
116    v
117}
118
119/// Rectified linear unit.
120pub fn relu(x: f32) -> f32 {
121    x.max(0.0)
122}
123
124/// Derivative of ReLU.
125pub fn relu_grad(x: f32) -> f32 {
126    if x > 0.0 {
127        1.0
128    } else {
129        0.0
130    }
131}
132
133/// Tiny deterministic xorshift RNG used for weight initialisation.
134struct XorShift32 {
135    state: u32,
136}
137
138impl XorShift32 {
139    fn new(seed: u32) -> Self {
140        Self {
141            state: seed.wrapping_add(0x9e37_79b9),
142        }
143    }
144
145    fn next(&mut self) -> u32 {
146        let mut x = self.state;
147        x ^= x << 13;
148        x ^= x >> 17;
149        x ^= x << 5;
150        self.state = x;
151        x
152    }
153
154    /// Uniform random float in `(-scale, scale)`.
155    fn uniform_f32(&mut self, scale: f32) -> f32 {
156        let u = self.next();
157        let normalised = (u as f32 / u32::MAX as f32) * 2.0 - 1.0;
158        normalised * scale
159    }
160}
161
162/// Forward-pass cache for a single input.
163pub struct MlpForward {
164    /// Pre-activation of the hidden layer (`z1`).
165    pub z1: Vec<f32>,
166    /// Activated hidden layer (`h`).
167    pub hidden: Vec<f32>,
168    /// Output logits before softmax.
169    pub logits: Vec<f32>,
170}
171
172/// Forward-pass cache for a batch of inputs.
173pub struct MlpForwardBatch {
174    /// Pre-activation of the hidden layer, shape `[batch, hidden_dim]`.
175    pub z1: Array2<f32>,
176    /// Activated hidden layer, shape `[batch, hidden_dim]`.
177    pub hidden: Array2<f32>,
178    /// Output logits before softmax, shape `[batch, output_dim]`.
179    pub logits: Array2<f32>,
180}
181
182/// Gradients for a single training example.
183#[derive(Debug, Clone)]
184pub struct MlpGradients {
185    /// `input_dim × hidden_dim`
186    pub dw1: Vec<f32>,
187    /// `hidden_dim`
188    pub db1: Vec<f32>,
189    /// `hidden_dim × output_dim`
190    pub dw2: Vec<f32>,
191    /// `output_dim`
192    pub db2: Vec<f32>,
193}
194
195/// A small two-layer MLP classifier: input → linear → ReLU → linear → logits.
196///
197/// Weights are stored row-major:
198/// - `w1` has shape `[input_dim, hidden_dim]`
199/// - `w2` has shape `[hidden_dim, output_dim]`
200#[derive(Debug, Clone)]
201pub struct MlpClassifier {
202    input_dim: usize,
203    hidden_dim: usize,
204    output_dim: usize,
205    /// Input-to-hidden weights, shape `[input_dim, hidden_dim]`.
206    pub w1: Vec<f32>,
207    /// Hidden bias, length `hidden_dim`.
208    pub b1: Vec<f32>,
209    /// Hidden-to-output weights, shape `[hidden_dim, output_dim]`.
210    pub w2: Vec<f32>,
211    /// Output bias, length `output_dim`.
212    pub b2: Vec<f32>,
213}
214
215impl MlpClassifier {
216    /// Create a fresh classifier with Xavier-like random weights.
217    pub fn new(input_dim: usize, hidden_dim: usize, output_dim: usize, seed: u32) -> Self {
218        let mut rng = XorShift32::new(seed);
219
220        let scale1 = (2.0 / input_dim as f32).sqrt() * 0.1;
221        let mut w1 = vec![0.0f32; input_dim * hidden_dim];
222        for v in w1.iter_mut() {
223            *v = rng.uniform_f32(scale1);
224        }
225        let mut b1 = vec![0.0f32; hidden_dim];
226        for v in b1.iter_mut() {
227            *v = rng.uniform_f32(0.01);
228        }
229
230        let scale2 = (2.0 / hidden_dim as f32).sqrt() * 0.1;
231        let mut w2 = vec![0.0f32; hidden_dim * output_dim];
232        for v in w2.iter_mut() {
233            *v = rng.uniform_f32(scale2);
234        }
235        let mut b2 = vec![0.0f32; output_dim];
236        for v in b2.iter_mut() {
237            *v = rng.uniform_f32(0.01);
238        }
239
240        Self {
241            input_dim,
242            hidden_dim,
243            output_dim,
244            w1,
245            b1,
246            w2,
247            b2,
248        }
249    }
250
251    /// Forward pass for a single input vector.
252    pub fn forward(&self, x: &[f32]) -> MlpForward {
253        assert_eq!(x.len(), self.input_dim, "forward: input dimension mismatch");
254
255        let z1 = matmul(x, 1, self.input_dim, &self.w1, self.hidden_dim)
256            .iter()
257            .zip(self.b1.iter())
258            .map(|(&z, &b)| z + b)
259            .collect::<Vec<_>>();
260        let hidden = z1.iter().map(|&z| relu(z)).collect::<Vec<_>>();
261        let logits = matmul(&hidden, 1, self.hidden_dim, &self.w2, self.output_dim)
262            .iter()
263            .zip(self.b2.iter())
264            .map(|(&z, &b)| z + b)
265            .collect::<Vec<_>>();
266
267        MlpForward { z1, hidden, logits }
268    }
269
270    /// Hidden state (post-ReLU) for a single input vector, without the
271    /// hidden-to-output projection.  Useful for FFN-head diagnostics and
272    /// confidence-gated routing where logits may not be needed.
273    pub fn forward_hidden(&self, x: &[f32]) -> Vec<f32> {
274        assert_eq!(
275            x.len(),
276            self.input_dim,
277            "forward_hidden: input dimension mismatch"
278        );
279
280        matmul(x, 1, self.input_dim, &self.w1, self.hidden_dim)
281            .iter()
282            .zip(self.b1.iter())
283            .map(|(&z, &b)| relu(z + b))
284            .collect()
285    }
286
287    /// Predicted class (argmax over logits).
288    pub fn predict(&self, x: &[f32]) -> usize {
289        let fwd = self.forward(x);
290        fwd.logits
291            .iter()
292            .enumerate()
293            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
294            .map(|(i, _)| i)
295            .unwrap_or(0)
296    }
297
298    /// Class probabilities for a single input vector.
299    pub fn probabilities(&self, x: &[f32]) -> Vec<f32> {
300        let fwd = self.forward(x);
301        softmax(&fwd.logits)
302    }
303
304    /// Cross-entropy loss for a single input/target pair.
305    pub fn loss(&self, x: &[f32], target: usize) -> f32 {
306        let fwd = self.forward(x);
307        cross_entropy_from_logits(&fwd.logits, target)
308    }
309
310    /// Back-propagation for a single input/target pair.
311    pub fn backward(&self, x: &[f32], target: usize) -> MlpGradients {
312        assert_eq!(
313            x.len(),
314            self.input_dim,
315            "backward: input dimension mismatch"
316        );
317        assert!(
318            target < self.output_dim,
319            "backward: target class out of bounds"
320        );
321
322        let fwd = self.forward(x);
323        let dlogits = cross_entropy_logits_grad(&fwd.logits, target);
324
325        // Gradients for W2 and b2.
326        let mut dw2 = vec![0.0f32; self.hidden_dim * self.output_dim];
327        for (i, &h_i) in fwd.hidden.iter().enumerate() {
328            for (k, &dlogit_k) in dlogits.iter().enumerate() {
329                dw2[i * self.output_dim + k] = h_i * dlogit_k;
330            }
331        }
332        let db2 = dlogits.clone();
333
334        // Back-propagate through W2.
335        let mut dh = vec![0.0f32; self.hidden_dim];
336        for (i, dh_i) in dh.iter_mut().enumerate() {
337            let mut acc = 0.0f32;
338            for (k, &dlogit_k) in dlogits.iter().enumerate() {
339                acc += dlogit_k * self.w2[i * self.output_dim + k];
340            }
341            *dh_i = acc;
342        }
343
344        // ReLU derivative.
345        let dz1: Vec<f32> = dh
346            .iter()
347            .zip(fwd.z1.iter())
348            .map(|(&dh_i, &z1_i)| dh_i * relu_grad(z1_i))
349            .collect();
350
351        // Gradients for W1 and b1.
352        let mut dw1 = vec![0.0f32; self.input_dim * self.hidden_dim];
353        for j in 0..self.input_dim {
354            for i in 0..self.hidden_dim {
355                dw1[j * self.hidden_dim + i] = x[j] * dz1[i];
356            }
357        }
358        let db1 = dz1;
359
360        MlpGradients { dw1, db1, dw2, db2 }
361    }
362
363    /// Apply a single SGD step using the supplied gradients.
364    pub fn apply_sgd(&mut self, grad: &MlpGradients, lr: f32) {
365        for i in 0..self.w1.len() {
366            self.w1[i] -= lr * grad.dw1[i];
367        }
368        for i in 0..self.b1.len() {
369            self.b1[i] -= lr * grad.db1[i];
370        }
371        for i in 0..self.w2.len() {
372            self.w2[i] -= lr * grad.dw2[i];
373        }
374        for i in 0..self.b2.len() {
375            self.b2[i] -= lr * grad.db2[i];
376        }
377    }
378
379    /// Forward pass for a batch of inputs.
380    ///
381    /// `x` is a row-major flat buffer of shape `[batch_size, input_dim]`.
382    pub fn forward_batch(&self, x: &[f32], batch_size: usize) -> MlpForwardBatch {
383        assert_eq!(
384            x.len(),
385            batch_size * self.input_dim,
386            "forward_batch: input dimension mismatch"
387        );
388
389        let x_arr = Array2::from_shape_vec((batch_size, self.input_dim), x.to_vec())
390            .expect("forward_batch: invalid input shape");
391        let w1_view = ArrayView2::from_shape((self.input_dim, self.hidden_dim), &self.w1).unwrap();
392        let b1_view = ArrayView2::from_shape((1, self.hidden_dim), &self.b1).unwrap();
393        let z1 = x_arr.dot(&w1_view) + &b1_view;
394
395        let hidden = z1.mapv(|v| relu(v));
396
397        let w2_view = ArrayView2::from_shape((self.hidden_dim, self.output_dim), &self.w2).unwrap();
398        let b2_view = ArrayView2::from_shape((1, self.output_dim), &self.b2).unwrap();
399        let logits = hidden.dot(&w2_view) + &b2_view;
400
401        MlpForwardBatch { z1, hidden, logits }
402    }
403
404    /// Cross-entropy loss averaged over a batch.
405    pub fn loss_batch(&self, x: &[f32], targets: &[usize], batch_size: usize) -> f32 {
406        assert_eq!(
407            targets.len(),
408            batch_size,
409            "loss_batch: target count mismatch"
410        );
411        let fwd = self.forward_batch(x, batch_size);
412        let mut total = 0.0f32;
413        for (logits_row, &target) in fwd.logits.axis_iter(Axis(0)).zip(targets.iter()) {
414            total += cross_entropy_from_logits(logits_row.as_slice().unwrap(), target);
415        }
416        total / batch_size as f32
417    }
418
419    /// Back-propagation for a batch of input/target pairs.
420    ///
421    /// Returns gradients **averaged over the batch**, the input gradient
422    /// `dx` as a row-major `[batch_size, input_dim]` buffer, and the average
423    /// cross-entropy loss. A single SGD/Adam step with the returned gradients
424    /// is equivalent to one step per mini-batch (not one step per example).
425    pub fn backward_batch(
426        &self,
427        x: &[f32],
428        targets: &[usize],
429        batch_size: usize,
430    ) -> (MlpGradients, Vec<f32>, f32) {
431        assert_eq!(
432            x.len(),
433            batch_size * self.input_dim,
434            "backward_batch: input dimension mismatch"
435        );
436        assert_eq!(
437            targets.len(),
438            batch_size,
439            "backward_batch: target count mismatch"
440        );
441        for &target in targets {
442            assert!(
443                target < self.output_dim,
444                "backward_batch: target class out of bounds"
445            );
446        }
447
448        let fwd = self.forward_batch(x, batch_size);
449        let batch_f = batch_size as f32;
450
451        // dlogits = softmax(logits) - one_hot(target), averaged implicitly later.
452        let mut dlogits = Array2::zeros((batch_size, self.output_dim));
453        let mut total_loss = 0.0f32;
454        for ((mut row, logits_row), &target) in dlogits
455            .axis_iter_mut(Axis(0))
456            .zip(fwd.logits.axis_iter(Axis(0)))
457            .zip(targets.iter())
458        {
459            let probs = softmax(logits_row.as_slice().unwrap());
460            total_loss += -probs[target].max(1e-30).ln();
461            let mut shifted = Array1::from_vec(probs);
462            shifted[target] -= 1.0;
463            row.assign(&shifted);
464        }
465        let avg_loss = total_loss / batch_f;
466
467        // Gradients for W2 and b2 (averaged over the batch).
468        let dw2 = fwd.hidden.t().dot(&dlogits) / batch_f;
469        let db2 = dlogits.mean_axis(Axis(0)).unwrap();
470
471        // Back-propagate through W2.
472        let w2_view = ArrayView2::from_shape((self.hidden_dim, self.output_dim), &self.w2).unwrap();
473        let dh = dlogits.dot(&w2_view.t());
474
475        // ReLU derivative.
476        let dz1 = dh * fwd.z1.mapv(|v| relu_grad(v));
477
478        // Input gradient (not averaged; per-example so callers can update
479        // external embedding tables with the same batch semantics as the MLP).
480        let w1_view = ArrayView2::from_shape((self.input_dim, self.hidden_dim), &self.w1).unwrap();
481        let dx = dz1.dot(&w1_view.t());
482
483        // Gradients for W1 and b1 (averaged over the batch).
484        let x_arr = Array2::from_shape_vec((batch_size, self.input_dim), x.to_vec())
485            .expect("backward_batch: invalid input shape");
486        let dw1 = x_arr.t().dot(&dz1) / batch_f;
487        let db1 = dz1.mean_axis(Axis(0)).unwrap();
488
489        (
490            MlpGradients {
491                dw1: dw1.into_raw_vec(),
492                db1: db1.into_raw_vec(),
493                dw2: dw2.into_raw_vec(),
494                db2: db2.into_raw_vec(),
495            },
496            dx.into_raw_vec(),
497            avg_loss,
498        )
499    }
500
501    /// Flatten all learnable parameters into one vector, ordered
502    /// `[w1, b1, w2, b2]`.
503    pub fn flatten_params(&self) -> Vec<f32> {
504        let mut params =
505            Vec::with_capacity(self.w1.len() + self.b1.len() + self.w2.len() + self.b2.len());
506        params.extend_from_slice(&self.w1);
507        params.extend_from_slice(&self.b1);
508        params.extend_from_slice(&self.w2);
509        params.extend_from_slice(&self.b2);
510        params
511    }
512
513    /// Flatten the gradients for a single example in the same order as
514    /// [`Self::flatten_params`].
515    pub fn flatten_grad(&self, grad: &MlpGradients) -> Vec<f32> {
516        let mut flat =
517            Vec::with_capacity(grad.dw1.len() + grad.db1.len() + grad.dw2.len() + grad.db2.len());
518        flat.extend_from_slice(&grad.dw1);
519        flat.extend_from_slice(&grad.db1);
520        flat.extend_from_slice(&grad.dw2);
521        flat.extend_from_slice(&grad.db2);
522        flat
523    }
524
525    /// Restore parameters from a flat vector produced by [`Self::flatten_params`].
526    pub fn load_flat_params(&mut self, params: &[f32]) {
527        let w1_len = self.w1.len();
528        let b1_len = self.b1.len();
529        let w2_len = self.w2.len();
530        let b2_len = self.b2.len();
531        let expected = w1_len + b1_len + w2_len + b2_len;
532        assert_eq!(params.len(), expected, "load_flat_params: size mismatch");
533
534        let mut off = 0;
535        self.w1.copy_from_slice(&params[off..off + w1_len]);
536        off += w1_len;
537        self.b1.copy_from_slice(&params[off..off + b1_len]);
538        off += b1_len;
539        self.w2.copy_from_slice(&params[off..off + w2_len]);
540        off += w2_len;
541        self.b2.copy_from_slice(&params[off..off + b2_len]);
542    }
543}
544
545/// Gradients for an embedding + MLP classifier.
546#[derive(Debug, Clone)]
547pub struct EmbeddingMlpGradients {
548    /// `vocab_size × embed_dim`
549    pub dembedding: Vec<f32>,
550    /// MLP weight/bias gradients.
551    pub mlp: MlpGradients,
552}
553
554/// A two-layer MLP classifier with a learnable token-embedding lookup.
555///
556/// This avoids materialising one-hot vectors for large vocabularies.  The
557/// input is built from `n_context_tokens` token IDs whose embeddings are
558/// concatenated, followed by an optional `continuous_dim` vector of continuous
559/// features (e.g. geometric positions).
560#[derive(Debug, Clone)]
561pub struct EmbeddingMlpClassifier {
562    vocab_size: usize,
563    embed_dim: usize,
564    n_context_tokens: usize,
565    continuous_dim: usize,
566    /// Optional RoPE theta.  When `Some(theta)`, context-token embeddings are
567    /// rotated by their sequence distance using standard RoPE frequencies.
568    rope_theta: Option<f32>,
569    /// Token embedding table, row-major `[vocab_size, embed_dim]`.
570    pub embedding: Vec<f32>,
571    /// Underlying MLP head.  Its input dimension is
572    /// `n_context_tokens * embed_dim + continuous_dim`.
573    pub mlp: MlpClassifier,
574}
575
576/// Apply standard RoPE rotation to a single embedding vector in place.
577///
578/// `position` is the 1-based sequence distance from the target (e.g. 1 for the
579/// previous token, 2 for the token before that).  `embed_dim` must be even.
580fn apply_rope_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
581    assert_eq!(
582        vec.len(),
583        embed_dim,
584        "apply_rope_in_place: dimension mismatch"
585    );
586    assert!(
587        embed_dim % 2 == 0,
588        "apply_rope_in_place: embed_dim must be even"
589    );
590
591    let ln_theta = theta.ln();
592    let pos_f = position as f32;
593    let embed_dim_f = embed_dim as f32;
594
595    for pair in 0..embed_dim / 2 {
596        let d0 = pair * 2;
597        let d1 = d0 + 1;
598        let freq = (-2.0f32 * pair as f32 * ln_theta / embed_dim_f).exp();
599        let angle = pos_f * freq;
600        let (cos_a, sin_a) = (angle.cos(), angle.sin());
601        let v0 = vec[d0];
602        let v1 = vec[d1];
603        vec[d0] = v0 * cos_a - v1 * sin_a;
604        vec[d1] = v0 * sin_a + v1 * cos_a;
605    }
606}
607
608/// Apply the inverse RoPE rotation to a single embedding vector in place.
609fn apply_rope_inv_in_place(vec: &mut [f32], embed_dim: usize, position: usize, theta: f32) {
610    assert_eq!(
611        vec.len(),
612        embed_dim,
613        "apply_rope_inv_in_place: dimension mismatch"
614    );
615    assert!(
616        embed_dim % 2 == 0,
617        "apply_rope_inv_in_place: embed_dim must be even"
618    );
619
620    let ln_theta = theta.ln();
621    let pos_f = position as f32;
622    let embed_dim_f = embed_dim as f32;
623
624    for pair in 0..embed_dim / 2 {
625        let d0 = pair * 2;
626        let d1 = d0 + 1;
627        let freq = (-2.0f32 * pair as f32 * ln_theta / embed_dim_f).exp();
628        let angle = pos_f * freq;
629        let (cos_a, sin_a) = (angle.cos(), angle.sin());
630        let v0 = vec[d0];
631        let v1 = vec[d1];
632        vec[d0] = v0 * cos_a + v1 * sin_a;
633        vec[d1] = -v0 * sin_a + v1 * cos_a;
634    }
635}
636
637impl EmbeddingMlpClassifier {
638    /// Create a fresh classifier with Xavier-like random weights.
639    pub fn new(
640        vocab_size: usize,
641        embed_dim: usize,
642        n_context_tokens: usize,
643        continuous_dim: usize,
644        hidden_dim: usize,
645        output_dim: usize,
646        seed: u32,
647        rope_theta: Option<f32>,
648    ) -> Self {
649        if let Some(theta) = rope_theta {
650            assert!(
651                theta > 0.0,
652                "EmbeddingMlpClassifier: rope_theta must be positive"
653            );
654            assert!(
655                embed_dim % 2 == 0,
656                "EmbeddingMlpClassifier: RoPE requires even embed_dim"
657            );
658        }
659
660        let input_dim = n_context_tokens * embed_dim + continuous_dim;
661        let mut rng = XorShift32::new(seed);
662
663        let scale = (2.0 / embed_dim as f32).sqrt() * 0.1;
664        let mut embedding = vec![0.0f32; vocab_size * embed_dim];
665        for v in embedding.iter_mut() {
666            *v = rng.uniform_f32(scale);
667        }
668
669        let mlp = MlpClassifier::new(input_dim, hidden_dim, output_dim, seed.wrapping_add(1));
670
671        Self {
672            vocab_size,
673            embed_dim,
674            n_context_tokens,
675            continuous_dim,
676            rope_theta,
677            embedding,
678            mlp,
679        }
680    }
681
682    /// Total flat input dimension after embedding lookup and concatenation.
683    pub fn input_dim(&self) -> usize {
684        self.n_context_tokens * self.embed_dim + self.continuous_dim
685    }
686
687    fn fill_input(&self, token_ids: &[u32], continuous: Option<&[f32]>, input: &mut [f32]) {
688        assert_eq!(
689            token_ids.len(),
690            self.n_context_tokens,
691            "fill_input: token count mismatch"
692        );
693        if let Some(cont) = continuous {
694            assert_eq!(
695                cont.len(),
696                self.continuous_dim,
697                "fill_input: continuous dimension mismatch"
698            );
699        }
700
701        let mut off = 0;
702        for (t, &tid) in token_ids.iter().enumerate() {
703            let idx = (tid as usize).min(self.vocab_size - 1);
704            let src = &self.embedding[idx * self.embed_dim..(idx + 1) * self.embed_dim];
705            input[off..off + self.embed_dim].copy_from_slice(src);
706
707            // RoPE: tokens are ordered [oldest, ..., newest]; newest is at
708            // position 1 (distance 1 from target).
709            if let Some(theta) = self.rope_theta {
710                let position = self.n_context_tokens - t;
711                apply_rope_in_place(
712                    &mut input[off..off + self.embed_dim],
713                    self.embed_dim,
714                    position,
715                    theta,
716                );
717            }
718
719            off += self.embed_dim;
720        }
721        if let Some(cont) = continuous {
722            input[off..off + self.continuous_dim].copy_from_slice(cont);
723        }
724    }
725
726    fn fill_batch_input(
727        &self,
728        token_ids: &[u32],
729        continuous: Option<&[f32]>,
730        batch_size: usize,
731        input: &mut [f32],
732    ) {
733        let input_dim = self.input_dim();
734        assert_eq!(
735            token_ids.len(),
736            batch_size * self.n_context_tokens,
737            "fill_batch_input: token count mismatch"
738        );
739        assert_eq!(
740            input.len(),
741            batch_size * input_dim,
742            "fill_batch_input: input buffer size mismatch"
743        );
744
745        for b in 0..batch_size {
746            let tok_start = b * self.n_context_tokens;
747            let cont = continuous.map(|c| {
748                let start = b * self.continuous_dim;
749                &c[start..start + self.continuous_dim]
750            });
751            self.fill_input(
752                &token_ids[tok_start..tok_start + self.n_context_tokens],
753                cont,
754                &mut input[b * input_dim..(b + 1) * input_dim],
755            );
756        }
757    }
758
759    /// Forward pass for a single example.
760    pub fn forward(&self, token_ids: &[u32], continuous: Option<&[f32]>) -> MlpForward {
761        let mut input = vec![0.0f32; self.input_dim()];
762        self.fill_input(token_ids, continuous, &mut input);
763        self.mlp.forward(&input)
764    }
765
766    /// Predicted class for a single example.
767    pub fn predict(&self, token_ids: &[u32], continuous: Option<&[f32]>) -> usize {
768        let fwd = self.forward(token_ids, continuous);
769        fwd.logits
770            .iter()
771            .enumerate()
772            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
773            .map(|(i, _)| i)
774            .unwrap_or(0)
775    }
776
777    /// Cross-entropy loss for a single example.
778    pub fn loss(&self, token_ids: &[u32], continuous: Option<&[f32]>, target: usize) -> f32 {
779        let fwd = self.forward(token_ids, continuous);
780        cross_entropy_from_logits(&fwd.logits, target)
781    }
782
783    /// Forward pass for a batch.
784    pub fn forward_batch(
785        &self,
786        token_ids: &[u32],
787        continuous: Option<&[f32]>,
788        batch_size: usize,
789    ) -> MlpForwardBatch {
790        let mut input = vec![0.0f32; batch_size * self.input_dim()];
791        self.fill_batch_input(token_ids, continuous, batch_size, &mut input);
792        self.mlp.forward_batch(&input, batch_size)
793    }
794
795    /// Back-propagation for a batch.
796    ///
797    /// Returns gradients averaged over the batch and the average cross-entropy
798    /// loss.  Embedding gradients are accumulated and averaged; the returned
799    /// `dembedding` has the same shape as [`Self::embedding`].
800    pub fn backward_batch(
801        &self,
802        token_ids: &[u32],
803        continuous: Option<&[f32]>,
804        targets: &[usize],
805        batch_size: usize,
806    ) -> (EmbeddingMlpGradients, f32) {
807        assert_eq!(
808            token_ids.len(),
809            batch_size * self.n_context_tokens,
810            "backward_batch: token count mismatch"
811        );
812        assert_eq!(
813            targets.len(),
814            batch_size,
815            "backward_batch: target count mismatch"
816        );
817        if let Some(cont) = continuous {
818            assert_eq!(
819                cont.len(),
820                batch_size * self.continuous_dim,
821                "backward_batch: continuous size mismatch"
822            );
823        }
824
825        let mut input = vec![0.0f32; batch_size * self.input_dim()];
826        self.fill_batch_input(token_ids, continuous, batch_size, &mut input);
827
828        let (mlp_grad, dx, loss) = self.mlp.backward_batch(&input, targets, batch_size);
829
830        // Accumulate embedding gradients from per-example dx.
831        // If RoPE was applied in the forward pass, apply its inverse to dx
832        // before accumulating into the static embedding table.
833        let mut dembedding = vec![0.0f32; self.embedding.len()];
834        let input_dim = self.input_dim();
835        for b in 0..batch_size {
836            let dx_row = &dx[b * input_dim..(b + 1) * input_dim];
837            let tok_start = b * self.n_context_tokens;
838            for (t, &tid) in token_ids[tok_start..tok_start + self.n_context_tokens]
839                .iter()
840                .enumerate()
841            {
842                let idx = (tid as usize).min(self.vocab_size - 1);
843                let mut dx_emb = dx_row[t * self.embed_dim..(t + 1) * self.embed_dim].to_vec();
844                if let Some(theta) = self.rope_theta {
845                    let position = self.n_context_tokens - t;
846                    apply_rope_inv_in_place(&mut dx_emb, self.embed_dim, position, theta);
847                }
848                let dst = &mut dembedding[idx * self.embed_dim..(idx + 1) * self.embed_dim];
849                for (d, g) in dst.iter_mut().zip(dx_emb.iter()) {
850                    *d += *g;
851                }
852            }
853        }
854
855        let inv_b = 1.0 / batch_size as f32;
856        for v in dembedding.iter_mut() {
857            *v *= inv_b;
858        }
859
860        (
861            EmbeddingMlpGradients {
862                dembedding,
863                mlp: mlp_grad,
864            },
865            loss,
866        )
867    }
868
869    /// Apply a single SGD step using the supplied gradients.
870    pub fn apply_sgd(&mut self, grad: &EmbeddingMlpGradients, lr: f32) {
871        for i in 0..self.embedding.len() {
872            self.embedding[i] -= lr * grad.dembedding[i];
873        }
874        self.mlp.apply_sgd(&grad.mlp, lr);
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    #[test]
883    fn matmul_identity() {
884        let a = vec![1.0f32, 2.0, 3.0, 4.0];
885        // 2x2 identity
886        let i = vec![1.0f32, 0.0, 0.0, 1.0];
887        let c = matmul(&a, 2, 2, &i, 2);
888        assert_eq!(c, a);
889    }
890
891    #[test]
892    fn matmul_small() {
893        // [1 2]   [5 6]   [19 22]
894        // [3 4] * [7 8] = [43 50]
895        let a = vec![1.0f32, 2.0, 3.0, 4.0];
896        let b = vec![5.0f32, 6.0, 7.0, 8.0];
897        let c = matmul(&a, 2, 2, &b, 2);
898        assert_eq!(c, vec![19.0, 22.0, 43.0, 50.0]);
899    }
900
901    #[test]
902    fn cross_entropy_decreases_with_target_probability() {
903        let p = vec![0.1f32, 0.8, 0.1];
904        assert!(cross_entropy_loss(&p, 1) < cross_entropy_loss(&p, 0));
905    }
906
907    #[test]
908    fn mlp_learns_xor() {
909        // XOR is not linearly separable; a tiny hidden MLP can learn it.
910        let examples: Vec<(Vec<f32>, usize)> = vec![
911            (vec![0.0, 0.0], 0),
912            (vec![0.0, 1.0], 1),
913            (vec![1.0, 0.0], 1),
914            (vec![1.0, 1.0], 0),
915        ];
916
917        let mut model = MlpClassifier::new(2, 8, 2, 7);
918        let lr = 0.2;
919
920        for _ in 0..2000 {
921            for (x, y) in &examples {
922                let grad = model.backward(x, *y);
923                model.apply_sgd(&grad, lr);
924            }
925        }
926
927        for (x, y) in &examples {
928            assert_eq!(
929                model.predict(x),
930                *y,
931                "failed XOR input {:?}, logits {:?}",
932                x,
933                model.forward(x).logits
934            );
935        }
936    }
937
938    #[test]
939    fn flatten_and_load_round_trip() {
940        let model = MlpClassifier::new(3, 4, 2, 13);
941        let params = model.flatten_params();
942        let mut restored = MlpClassifier::new(3, 4, 2, 99);
943        restored.load_flat_params(&params);
944        assert_eq!(model.w1, restored.w1);
945        assert_eq!(model.b1, restored.b1);
946        assert_eq!(model.w2, restored.w2);
947        assert_eq!(model.b2, restored.b2);
948    }
949
950    #[test]
951    fn backward_batch_matches_per_example_average() {
952        let input_dim = 4;
953        let hidden_dim = 5;
954        let output_dim = 3;
955        let model = MlpClassifier::new(input_dim, hidden_dim, output_dim, 21);
956        let batch_size = 3;
957
958        let inputs: Vec<f32> = (0..input_dim * batch_size)
959            .map(|i| (i as f32) * 0.13 - 0.4)
960            .collect();
961        let targets = vec![0, 2, 1];
962
963        let (grad_batch, _dx, loss_batch) = model.backward_batch(&inputs, &targets, batch_size);
964
965        let mut dw1 = vec![0.0f32; grad_batch.dw1.len()];
966        let mut db1 = vec![0.0f32; grad_batch.db1.len()];
967        let mut dw2 = vec![0.0f32; grad_batch.dw2.len()];
968        let mut db2 = vec![0.0f32; grad_batch.db2.len()];
969        let mut loss_sum = 0.0f32;
970
971        for b in 0..batch_size {
972            let x = &inputs[b * input_dim..(b + 1) * input_dim];
973            let g = model.backward(x, targets[b]);
974            for (a, v) in dw1.iter_mut().zip(g.dw1.iter()) {
975                *a += v;
976            }
977            for (a, v) in db1.iter_mut().zip(g.db1.iter()) {
978                *a += v;
979            }
980            for (a, v) in dw2.iter_mut().zip(g.dw2.iter()) {
981                *a += v;
982            }
983            for (a, v) in db2.iter_mut().zip(g.db2.iter()) {
984                *a += v;
985            }
986            loss_sum += model.loss(x, targets[b]);
987        }
988
989        let inv = 1.0 / batch_size as f32;
990        for v in dw1.iter_mut() {
991            *v *= inv;
992        }
993        for v in db1.iter_mut() {
994            *v *= inv;
995        }
996        for v in dw2.iter_mut() {
997            *v *= inv;
998        }
999        for v in db2.iter_mut() {
1000            *v *= inv;
1001        }
1002
1003        let tol = 1e-5;
1004        for (a, b) in grad_batch.dw1.iter().zip(dw1.iter()) {
1005            assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
1006        }
1007        for (a, b) in grad_batch.db1.iter().zip(db1.iter()) {
1008            assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
1009        }
1010        for (a, b) in grad_batch.dw2.iter().zip(dw2.iter()) {
1011            assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
1012        }
1013        for (a, b) in grad_batch.db2.iter().zip(db2.iter()) {
1014            assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
1015        }
1016        assert!((loss_batch - loss_sum * inv).abs() < tol);
1017    }
1018
1019    #[test]
1020    fn embedding_mlp_learns_xor_with_continuous() {
1021        // Represent XOR inputs as two token IDs from a 2-token vocab, with no
1022        // continuous features.  The embedding layer must learn to distinguish
1023        // the two "bit" values.
1024        let examples: Vec<(u32, u32, usize)> = vec![(0, 0, 0), (0, 1, 1), (1, 0, 1), (1, 1, 0)];
1025
1026        let mut model = EmbeddingMlpClassifier::new(2, 8, 2, 0, 16, 2, 7, None);
1027        let lr = 0.2;
1028
1029        for _ in 0..8000 {
1030            for &(t1, t2, y) in &examples {
1031                let grad = model.backward_batch(&[t1, t2], None, &[y], 1);
1032                model.apply_sgd(&grad.0, lr);
1033            }
1034        }
1035
1036        for &(t1, t2, y) in &examples {
1037            assert_eq!(
1038                model.predict(&[t1, t2], None),
1039                y,
1040                "failed XOR input ({}, {})",
1041                t1,
1042                t2
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn embedding_mlp_batch_matches_single_example() {
1049        let vocab_size = 5;
1050        let embed_dim = 3;
1051        let n_context = 2;
1052        let continuous_dim = 4;
1053        let model = EmbeddingMlpClassifier::new(
1054            vocab_size,
1055            embed_dim,
1056            n_context,
1057            continuous_dim,
1058            6,
1059            3,
1060            11,
1061            None,
1062        );
1063
1064        let token_ids = vec![0u32, 2, 4, 1];
1065        let continuous: Vec<f32> = (0..(2 * continuous_dim) as i32)
1066            .map(|i| i as f32 * 0.1 - 0.5)
1067            .collect();
1068        let targets = vec![0, 2];
1069
1070        let (batch_grad, batch_loss) =
1071            model.backward_batch(&token_ids, Some(&continuous), &targets, 2);
1072
1073        // Compare against two single-example backward passes.
1074        let mut dembedding = vec![0.0f32; vocab_size * embed_dim];
1075        let mut dw1 = vec![0.0f32; model.mlp.w1.len()];
1076        let mut db1 = vec![0.0f32; model.mlp.b1.len()];
1077        let mut dw2 = vec![0.0f32; model.mlp.w2.len()];
1078        let mut db2 = vec![0.0f32; model.mlp.b2.len()];
1079        let mut loss_sum = 0.0f32;
1080
1081        for b in 0..2 {
1082            let t = &token_ids[b * n_context..(b + 1) * n_context];
1083            let c = &continuous[b * continuous_dim..(b + 1) * continuous_dim];
1084            let (g, loss) = model.backward_batch(t, Some(c), &[targets[b]], 1);
1085            loss_sum += loss;
1086
1087            for (a, v) in dembedding.iter_mut().zip(g.dembedding.iter()) {
1088                *a += v;
1089            }
1090            for (a, v) in dw1.iter_mut().zip(g.mlp.dw1.iter()) {
1091                *a += v;
1092            }
1093            for (a, v) in db1.iter_mut().zip(g.mlp.db1.iter()) {
1094                *a += v;
1095            }
1096            for (a, v) in dw2.iter_mut().zip(g.mlp.dw2.iter()) {
1097                *a += v;
1098            }
1099            for (a, v) in db2.iter_mut().zip(g.mlp.db2.iter()) {
1100                *a += v;
1101            }
1102        }
1103
1104        let inv = 1.0 / 2.0;
1105        for v in dembedding.iter_mut() {
1106            *v *= inv;
1107        }
1108        for v in dw1.iter_mut() {
1109            *v *= inv;
1110        }
1111        for v in db1.iter_mut() {
1112            *v *= inv;
1113        }
1114        for v in dw2.iter_mut() {
1115            *v *= inv;
1116        }
1117        for v in db2.iter_mut() {
1118            *v *= inv;
1119        }
1120
1121        let tol = 1e-5;
1122        for (a, b) in batch_grad.dembedding.iter().zip(dembedding.iter()) {
1123            assert!((a - b).abs() < tol, "dembedding mismatch: {} vs {}", a, b);
1124        }
1125        for (a, b) in batch_grad.mlp.dw1.iter().zip(dw1.iter()) {
1126            assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
1127        }
1128        for (a, b) in batch_grad.mlp.db1.iter().zip(db1.iter()) {
1129            assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
1130        }
1131        for (a, b) in batch_grad.mlp.dw2.iter().zip(dw2.iter()) {
1132            assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
1133        }
1134        for (a, b) in batch_grad.mlp.db2.iter().zip(db2.iter()) {
1135            assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
1136        }
1137        assert!((batch_loss - loss_sum * inv).abs() < tol);
1138    }
1139
1140    #[test]
1141    fn rope_rotates_differently_by_position() {
1142        let mut v1 = vec![1.0f32, 0.0, 0.0, 1.0];
1143        let mut v2 = v1.clone();
1144        apply_rope_in_place(&mut v1, 4, 1, 10000.0);
1145        apply_rope_in_place(&mut v2, 4, 2, 10000.0);
1146        // Same vector rotated by different positions should differ.
1147        assert!(v1.iter().zip(v2.iter()).any(|(a, b)| (a - b).abs() > 1e-6));
1148    }
1149
1150    #[test]
1151    fn rope_inverse_recovers_original() {
1152        let original = vec![0.3f32, -0.7, 1.2, 0.4];
1153        let mut rotated = original.clone();
1154        apply_rope_in_place(&mut rotated, 4, 3, 10000.0);
1155        let mut recovered = rotated.clone();
1156        apply_rope_inv_in_place(&mut recovered, 4, 3, 10000.0);
1157
1158        let tol = 1e-5;
1159        for (a, b) in original.iter().zip(recovered.iter()) {
1160            assert!((a - b).abs() < tol, "RoPE inverse mismatch: {} vs {}", a, b);
1161        }
1162    }
1163
1164    #[test]
1165    fn embedding_mlp_batch_with_rope_matches_per_example() {
1166        let vocab_size = 5;
1167        let embed_dim = 4;
1168        let n_context = 2;
1169        let continuous_dim = 0;
1170        let model = EmbeddingMlpClassifier::new(
1171            vocab_size,
1172            embed_dim,
1173            n_context,
1174            continuous_dim,
1175            6,
1176            3,
1177            11,
1178            Some(10000.0),
1179        );
1180
1181        let token_ids = vec![0u32, 2, 4, 1];
1182        let targets = vec![0, 2];
1183
1184        let (batch_grad, batch_loss) = model.backward_batch(&token_ids, None, &targets, 2);
1185
1186        // Compare against two single-example backward passes.
1187        let mut dembedding = vec![0.0f32; vocab_size * embed_dim];
1188        let mut dw1 = vec![0.0f32; model.mlp.w1.len()];
1189        let mut db1 = vec![0.0f32; model.mlp.b1.len()];
1190        let mut dw2 = vec![0.0f32; model.mlp.w2.len()];
1191        let mut db2 = vec![0.0f32; model.mlp.b2.len()];
1192        let mut loss_sum = 0.0f32;
1193
1194        for b in 0..2 {
1195            let t = &token_ids[b * n_context..(b + 1) * n_context];
1196            let (g, loss) = model.backward_batch(t, None, &[targets[b]], 1);
1197            loss_sum += loss;
1198
1199            for (a, v) in dembedding.iter_mut().zip(g.dembedding.iter()) {
1200                *a += v;
1201            }
1202            for (a, v) in dw1.iter_mut().zip(g.mlp.dw1.iter()) {
1203                *a += v;
1204            }
1205            for (a, v) in db1.iter_mut().zip(g.mlp.db1.iter()) {
1206                *a += v;
1207            }
1208            for (a, v) in dw2.iter_mut().zip(g.mlp.dw2.iter()) {
1209                *a += v;
1210            }
1211            for (a, v) in db2.iter_mut().zip(g.mlp.db2.iter()) {
1212                *a += v;
1213            }
1214        }
1215
1216        let inv = 1.0 / 2.0;
1217        for v in dembedding.iter_mut() {
1218            *v *= inv;
1219        }
1220        for v in dw1.iter_mut() {
1221            *v *= inv;
1222        }
1223        for v in db1.iter_mut() {
1224            *v *= inv;
1225        }
1226        for v in dw2.iter_mut() {
1227            *v *= inv;
1228        }
1229        for v in db2.iter_mut() {
1230            *v *= inv;
1231        }
1232
1233        let tol = 1e-5;
1234        for (a, b) in batch_grad.dembedding.iter().zip(dembedding.iter()) {
1235            assert!((a - b).abs() < tol, "dembedding mismatch: {} vs {}", a, b);
1236        }
1237        for (a, b) in batch_grad.mlp.dw1.iter().zip(dw1.iter()) {
1238            assert!((a - b).abs() < tol, "dw1 mismatch: {} vs {}", a, b);
1239        }
1240        for (a, b) in batch_grad.mlp.db1.iter().zip(db1.iter()) {
1241            assert!((a - b).abs() < tol, "db1 mismatch: {} vs {}", a, b);
1242        }
1243        for (a, b) in batch_grad.mlp.dw2.iter().zip(dw2.iter()) {
1244            assert!((a - b).abs() < tol, "dw2 mismatch: {} vs {}", a, b);
1245        }
1246        for (a, b) in batch_grad.mlp.db2.iter().zip(db2.iter()) {
1247            assert!((a - b).abs() < tol, "db2 mismatch: {} vs {}", a, b);
1248        }
1249        assert!((batch_loss - loss_sum * inv).abs() < tol);
1250    }
1251}