Skip to main content

clawft_kernel/sensor_graph/
layers.rs

1//! Pure-Rust linear / GraphSAGE / GLU layers for K-STEMIT forward + SGD.
2//!
3//! No external BLAS / ndarray. Matrices are row-major `Vec<f32>`.
4
5/// Dense affine map `y = Wx + b` with analytical gradients for SGD.
6#[derive(Debug, Clone)]
7pub struct Linear {
8    /// Weight matrix, row-major, shape `[out_dim, in_dim]`.
9    pub w: Vec<f32>,
10    /// Bias, length `out_dim`.
11    pub b: Vec<f32>,
12    pub in_dim: usize,
13    pub out_dim: usize,
14}
15
16impl Linear {
17    /// Zero-initialized layer.
18    pub fn zeros(in_dim: usize, out_dim: usize) -> Self {
19        Self {
20            w: vec![0.0; out_dim * in_dim],
21            b: vec![0.0; out_dim],
22            in_dim,
23            out_dim,
24        }
25    }
26
27    /// Xavier/Glorot uniform-ish init from a simple LCG seed.
28    pub fn xavier(in_dim: usize, out_dim: usize, seed: u64) -> Self {
29        let mut rng = seed;
30        let limit = (6.0f32 / (in_dim + out_dim) as f32).sqrt();
31        let mut w = vec![0.0f32; out_dim * in_dim];
32        for slot in &mut w {
33            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
34            let u = (rng >> 33) as f32 / (u32::MAX as f32);
35            *slot = (u * 2.0 - 1.0) * limit;
36        }
37        Self {
38            w,
39            b: vec![0.0; out_dim],
40            in_dim,
41            out_dim,
42        }
43    }
44
45    /// Forward: `y = Wx + b`.
46    pub fn forward(&self, x: &[f32]) -> Vec<f32> {
47        debug_assert_eq!(x.len(), self.in_dim);
48        let mut y = self.b.clone();
49        for o in 0..self.out_dim {
50            let row = o * self.in_dim;
51            let mut acc = y[o];
52            for i in 0..self.in_dim {
53                acc += self.w[row + i] * x[i];
54            }
55            y[o] = acc;
56        }
57        y
58    }
59
60    /// Backward given `dy` and cached input `x`.
61    ///
62    /// Returns `dx` and accumulates into `dw` / `db` buffers (same layout as `w` / `b`).
63    pub fn backward(
64        &self,
65        x: &[f32],
66        dy: &[f32],
67        dw: &mut [f32],
68        db: &mut [f32],
69    ) -> Vec<f32> {
70        debug_assert_eq!(x.len(), self.in_dim);
71        debug_assert_eq!(dy.len(), self.out_dim);
72        debug_assert_eq!(dw.len(), self.w.len());
73        debug_assert_eq!(db.len(), self.b.len());
74
75        for o in 0..self.out_dim {
76            db[o] += dy[o];
77            let row = o * self.in_dim;
78            for i in 0..self.in_dim {
79                dw[row + i] += dy[o] * x[i];
80            }
81        }
82
83        let mut dx = vec![0.0f32; self.in_dim];
84        for o in 0..self.out_dim {
85            let row = o * self.in_dim;
86            for i in 0..self.in_dim {
87                dx[i] += self.w[row + i] * dy[o];
88            }
89        }
90        dx
91    }
92
93    /// SGD step with optional L2 weight decay on `w`.
94    pub fn sgd_step(&mut self, dw: &[f32], db: &[f32], lr: f32, weight_decay: f32) {
95        for i in 0..self.w.len() {
96            self.w[i] -= lr * (dw[i] + weight_decay * self.w[i]);
97        }
98        for i in 0..self.b.len() {
99            self.b[i] -= lr * db[i];
100        }
101    }
102}
103
104/// Gated linear unit: `y = a ⊙ σ(g)` where `[a;g] = W x + b`.
105#[derive(Debug, Clone)]
106pub struct TemporalGlu {
107    /// Projects flattened temporal window → `2 * hidden`.
108    pub proj: Linear,
109    pub window: usize,
110    pub hidden: usize,
111}
112
113impl TemporalGlu {
114    pub fn new(window: usize, hidden: usize, seed: u64) -> Self {
115        Self {
116            proj: Linear::xavier(window, 2 * hidden, seed),
117            window,
118            hidden,
119        }
120    }
121
122    /// Forward over a length-`window` series (already padded/cropped).
123    pub fn forward(&self, series: &[f32]) -> Vec<f32> {
124        assert_eq!(series.len(), self.window);
125        let z = self.proj.forward(series);
126        glu_activate(&z, self.hidden)
127    }
128
129    /// Backward through GLU + projection.
130    pub fn backward(
131        &self,
132        series: &[f32],
133        dy: &[f32],
134        dproj_w: &mut [f32],
135        dproj_b: &mut [f32],
136    ) -> Vec<f32> {
137        let z = self.proj.forward(series);
138        let dz = glu_backward(&z, dy, self.hidden);
139        self.proj.backward(series, &dz, dproj_w, dproj_b)
140    }
141}
142
143fn sigmoid(x: f32) -> f32 {
144    1.0 / (1.0 + (-x).exp())
145}
146
147fn glu_activate(z: &[f32], hidden: usize) -> Vec<f32> {
148    debug_assert_eq!(z.len(), 2 * hidden);
149    let mut y = vec![0.0f32; hidden];
150    for i in 0..hidden {
151        y[i] = z[i] * sigmoid(z[hidden + i]);
152    }
153    y
154}
155
156fn glu_backward(z: &[f32], dy: &[f32], hidden: usize) -> Vec<f32> {
157    debug_assert_eq!(z.len(), 2 * hidden);
158    debug_assert_eq!(dy.len(), hidden);
159    let mut dz = vec![0.0f32; 2 * hidden];
160    for i in 0..hidden {
161        let a = z[i];
162        let s = sigmoid(z[hidden + i]);
163        // y = a * s
164        dz[i] = dy[i] * s;
165        dz[hidden + i] = dy[i] * a * s * (1.0 - s);
166    }
167    dz
168}
169
170/// One GraphSAGE hop: `h = σ(W_self x + W_neigh AGG(N(x)))`.
171///
172/// `AGG` is inverse-distance weighted mean supplied by the caller.
173/// Activation is ReLU.
174#[derive(Debug, Clone)]
175pub struct GraphSageLayer {
176    pub w_self: Linear,
177    pub w_neigh: Linear,
178    pub in_dim: usize,
179    pub hidden: usize,
180}
181
182impl GraphSageLayer {
183    pub fn new(in_dim: usize, hidden: usize, seed: u64) -> Self {
184        Self {
185            w_self: Linear::xavier(in_dim, hidden, seed),
186            w_neigh: Linear::xavier(in_dim, hidden, seed.wrapping_add(17)),
187            in_dim,
188            hidden,
189        }
190    }
191
192    /// Forward one node given self features and pre-aggregated neighbor features.
193    pub fn forward(&self, x_self: &[f32], x_agg: &[f32]) -> Vec<f32> {
194        let a = self.w_self.forward(x_self);
195        let b = self.w_neigh.forward(x_agg);
196        let mut h = vec![0.0f32; self.hidden];
197        for i in 0..self.hidden {
198            h[i] = relu(a[i] + b[i]);
199        }
200        h
201    }
202
203    /// Backward. Returns `(dx_self, dx_agg)`.
204    pub fn backward(
205        &self,
206        x_self: &[f32],
207        x_agg: &[f32],
208        dy: &[f32],
209        dw_self_w: &mut [f32],
210        dw_self_b: &mut [f32],
211        dw_neigh_w: &mut [f32],
212        dw_neigh_b: &mut [f32],
213    ) -> (Vec<f32>, Vec<f32>) {
214        let a = self.w_self.forward(x_self);
215        let b = self.w_neigh.forward(x_agg);
216        // d(relu)/d(pre)
217        let mut dpre = vec![0.0f32; self.hidden];
218        for i in 0..self.hidden {
219            let pre = a[i] + b[i];
220            dpre[i] = if pre > 0.0 { dy[i] } else { 0.0 };
221        }
222        let dx_self = self
223            .w_self
224            .backward(x_self, &dpre, dw_self_w, dw_self_b);
225        let dx_agg = self
226            .w_neigh
227            .backward(x_agg, &dpre, dw_neigh_w, dw_neigh_b);
228        (dx_self, dx_agg)
229    }
230}
231
232fn relu(x: f32) -> f32 {
233    if x > 0.0 {
234        x
235    } else {
236        0.0
237    }
238}
239
240/// Softmax over logits (numerically stable).
241pub fn softmax(logits: &[f32]) -> Vec<f32> {
242    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
243    let mut exps: Vec<f32> = logits.iter().map(|v| (v - max).exp()).collect();
244    let sum: f32 = exps.iter().sum();
245    if sum > 0.0 {
246        for e in &mut exps {
247            *e /= sum;
248        }
249    }
250    exps
251}
252
253/// Cross-entropy loss for a single sample: `-log p[label]`.
254pub fn cross_entropy(probs: &[f32], label: usize) -> f32 {
255    let p = probs.get(label).copied().unwrap_or(0.0).clamp(1e-8, 1.0);
256    -p.ln()
257}
258
259/// Softmax + CE gradient w.r.t. logits: `p - one_hot(label)`.
260pub fn softmax_ce_grad(probs: &[f32], label: usize) -> Vec<f32> {
261    let mut g = probs.to_vec();
262    if label < g.len() {
263        g[label] -= 1.0;
264    }
265    g
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn linear_forward_identity_bias() {
274        let mut layer = Linear::zeros(2, 2);
275        // identity
276        layer.w = vec![1.0, 0.0, 0.0, 1.0];
277        layer.b = vec![1.0, -1.0];
278        let y = layer.forward(&[3.0, 4.0]);
279        assert!((y[0] - 4.0).abs() < 1e-5);
280        assert!((y[1] - 3.0).abs() < 1e-5);
281    }
282
283    #[test]
284    fn linear_backward_matches_finite_diff() {
285        let layer = Linear::xavier(3, 2, 42);
286        let x = vec![0.5f32, -0.2, 1.0];
287        let dy = vec![1.0f32, -0.5];
288        let mut dw = vec![0.0; layer.w.len()];
289        let mut db = vec![0.0; layer.b.len()];
290        let _dx = layer.backward(&x, &dy, &mut dw, &mut db);
291
292        // Finite-diff check on one weight.
293        let eps = 1e-3f32;
294        let mut layer_p = layer.clone();
295        layer_p.w[0] += eps;
296        let mut layer_m = layer.clone();
297        layer_m.w[0] -= eps;
298        let loss = |l: &Linear| {
299            let y = l.forward(&x);
300            y[0] * dy[0] + y[1] * dy[1]
301        };
302        let fd = (loss(&layer_p) - loss(&layer_m)) / (2.0 * eps);
303        assert!((dw[0] - fd).abs() < 1e-2, "dw[0]={:.4} fd={:.4}", dw[0], fd);
304    }
305
306    #[test]
307    fn glu_forward_gate_zero() {
308        let mut glu = TemporalGlu::new(4, 2, 1);
309        // Force proj to zeros → y = 0
310        glu.proj = Linear::zeros(4, 4);
311        let y = glu.forward(&[1.0, 2.0, 3.0, 4.0]);
312        assert_eq!(y.len(), 2);
313        assert!((y[0]).abs() < 1e-6);
314        assert!((y[1]).abs() < 1e-6);
315    }
316
317    #[test]
318    fn graphsage_relu_positive_path() {
319        let mut sage = GraphSageLayer::new(2, 2, 7);
320        sage.w_self = Linear::zeros(2, 2);
321        sage.w_self.w = vec![1.0, 0.0, 0.0, 1.0];
322        sage.w_neigh = Linear::zeros(2, 2);
323        // self [1,2] + neigh 0 → ReLU keeps positives
324        let h = sage.forward(&[1.0, 2.0], &[0.0, 0.0]);
325        assert!((h[0] - 1.0).abs() < 1e-5);
326        assert!((h[1] - 2.0).abs() < 1e-5);
327    }
328
329    #[test]
330    fn softmax_sums_to_one() {
331        let p = softmax(&[1.0, 2.0, 3.0]);
332        let s: f32 = p.iter().sum();
333        assert!((s - 1.0).abs() < 1e-5);
334        assert!(p[2] > p[1] && p[1] > p[0]);
335    }
336}