Skip to main content

clawft_kernel/sensor_graph/
model.rs

1//! K-STEMIT dual-branch model: temporal GLU + spatial GraphSAGE + physics + head.
2
3use super::graph::{SensorGraph, PHYSICS_DIM};
4use super::layers::{
5    cross_entropy, softmax, softmax_ce_grad, GraphSageLayer, Linear, TemporalGlu,
6};
7
8const DEFAULT_SEED: u64 = 0x00c5_7e71;
9
10/// Hyperparameters for [`KstemitModel`].
11#[derive(Debug, Clone)]
12pub struct KstemitConfig {
13    /// Node feature dimension (includes physics priors).
14    pub feature_dim: usize,
15    /// Temporal window length.
16    pub temporal_window: usize,
17    /// Shared hidden size for both branches.
18    pub hidden: usize,
19    /// Number of classification labels.
20    pub num_classes: usize,
21    /// Scale on the physics residual branch (γ).
22    pub physics_scale: f32,
23    /// RNG seed for weight init.
24    pub seed: u64,
25}
26
27impl Default for KstemitConfig {
28    fn default() -> Self {
29        Self {
30            feature_dim: PHYSICS_DIM,
31            temporal_window: 16,
32            hidden: 8,
33            num_classes: 3,
34            physics_scale: 0.25,
35            seed: DEFAULT_SEED,
36        }
37    }
38}
39
40/// Outputs of a single-node forward pass.
41#[derive(Debug, Clone)]
42pub struct ForwardOutput {
43    /// Spatial branch embedding.
44    pub h_spatial: Vec<f32>,
45    /// Temporal branch embedding.
46    pub h_temporal: Vec<f32>,
47    /// Physics prior embedding.
48    pub h_physics: Vec<f32>,
49    /// Fused hidden: `α·h_s + (1-α)·h_t + γ·h_p`.
50    pub h_fused: Vec<f32>,
51    /// Classification logits.
52    pub logits: Vec<f32>,
53    /// Softmax probabilities.
54    pub probs: Vec<f32>,
55    /// Adaptive fusion weight α = σ(α_logit).
56    pub alpha: f32,
57}
58
59/// K-STEMIT-style spatio-temporal GNN for sonobuoy arrays.
60///
61/// ```text
62/// temporal window ──► TemporalGlu ──► h_t ──┐
63///                                           │ α
64/// node feats + AGG ─► GraphSAGE ───► h_s ───┼──► fuse ──► Linear head ──► logits
65///                                           │
66/// physics priors ───► Linear ──────► h_p ───┘ (γ residual)
67/// ```
68#[derive(Debug, Clone)]
69pub struct KstemitModel {
70    pub config: KstemitConfig,
71    pub temporal: TemporalGlu,
72    pub spatial: GraphSageLayer,
73    pub physics_proj: Linear,
74    /// Unconstrained logit; α = sigmoid(alpha_logit).
75    pub alpha_logit: f32,
76    pub head: Linear,
77}
78
79impl KstemitModel {
80    /// Construct a freshly initialized model.
81    pub fn new(config: KstemitConfig) -> Self {
82        assert!(config.feature_dim > 0);
83        assert!(config.temporal_window > 0);
84        assert!(config.hidden > 0);
85        assert!(config.num_classes > 0);
86        let s = config.seed;
87        Self {
88            temporal: TemporalGlu::new(config.temporal_window, config.hidden, s),
89            spatial: GraphSageLayer::new(config.feature_dim, config.hidden, s.wrapping_add(1)),
90            physics_proj: Linear::xavier(
91                PHYSICS_DIM.min(config.feature_dim).max(1),
92                config.hidden,
93                s.wrapping_add(2),
94            ),
95            alpha_logit: 0.0, // α ≈ 0.5 at init
96            head: Linear::xavier(config.hidden, config.num_classes, s.wrapping_add(3)),
97            config,
98        }
99    }
100
101    /// Current fusion weight α ∈ (0, 1).
102    pub fn alpha(&self) -> f32 {
103        sigmoid(self.alpha_logit)
104    }
105
106    /// Forward for one node index on a [`SensorGraph`].
107    pub fn forward_node(&self, graph: &SensorGraph, node_idx: usize) -> ForwardOutput {
108        let (x_self, x_agg, series, physics) = self.inputs_for(graph, node_idx);
109        self.forward_tensors(&x_self, &x_agg, &series, &physics)
110    }
111
112    /// Build model inputs for a node (public for training caches).
113    pub fn inputs_for(
114        &self,
115        graph: &SensorGraph,
116        node_idx: usize,
117    ) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
118        let feat_dim = self.config.feature_dim;
119        let x_self = pad_or_trunc(
120            graph
121                .nodes
122                .get(node_idx)
123                .map(|n| n.features.as_slice())
124                .unwrap_or(&[]),
125            feat_dim,
126        );
127        let x_agg = pad_or_trunc(&graph.aggregate_neighbors(node_idx, 1), feat_dim);
128        let series = graph.temporal_features(node_idx, self.config.temporal_window);
129        let physics = {
130            let raw = graph
131                .nodes
132                .get(node_idx)
133                .map(|n| n.physics())
134                .unwrap_or([0.0; PHYSICS_DIM]);
135            let need = self.physics_proj.in_dim;
136            pad_or_trunc(&raw, need)
137        };
138        (x_self, x_agg, series, physics)
139    }
140
141    /// Forward from pre-built tensors.
142    pub fn forward_tensors(
143        &self,
144        x_self: &[f32],
145        x_agg: &[f32],
146        series: &[f32],
147        physics: &[f32],
148    ) -> ForwardOutput {
149        let h_spatial = self.spatial.forward(x_self, x_agg);
150        let h_temporal = self.temporal.forward(series);
151        let h_physics = self.physics_proj.forward(physics);
152        let alpha = self.alpha();
153        let gamma = self.config.physics_scale;
154        let hidden = self.config.hidden;
155        let mut h_fused = vec![0.0f32; hidden];
156        for i in 0..hidden {
157            h_fused[i] =
158                alpha * h_spatial[i] + (1.0 - alpha) * h_temporal[i] + gamma * h_physics[i];
159        }
160        let logits = self.head.forward(&h_fused);
161        let probs = softmax(&logits);
162        ForwardOutput {
163            h_spatial,
164            h_temporal,
165            h_physics,
166            h_fused,
167            logits,
168            probs,
169            alpha,
170        }
171    }
172
173    /// Predicted class (argmax).
174    pub fn predict(&self, graph: &SensorGraph, node_idx: usize) -> usize {
175        let out = self.forward_node(graph, node_idx);
176        argmax(&out.logits)
177    }
178
179    /// Loss for one labelled node.
180    pub fn loss_node(&self, graph: &SensorGraph, node_idx: usize, label: usize) -> f32 {
181        let out = self.forward_node(graph, node_idx);
182        cross_entropy(&out.probs, label)
183    }
184
185    /// One SGD step on a single (graph, node, label) sample.
186    ///
187    /// Analytical gradients through GLU, GraphSAGE, fusion α, and head.
188    pub fn train_step(
189        &mut self,
190        graph: &SensorGraph,
191        node_idx: usize,
192        label: usize,
193        lr: f32,
194        weight_decay: f32,
195    ) -> f32 {
196        let (x_self, x_agg, series, physics) = self.inputs_for(graph, node_idx);
197        let out = self.forward_tensors(&x_self, &x_agg, &series, &physics);
198        let loss = cross_entropy(&out.probs, label);
199
200        // dL/dlogits
201        let dlogits = softmax_ce_grad(&out.probs, label);
202
203        // head backward
204        let mut dhead_w = vec![0.0; self.head.w.len()];
205        let mut dhead_b = vec![0.0; self.head.b.len()];
206        let dh_fused = self
207            .head
208            .backward(&out.h_fused, &dlogits, &mut dhead_w, &mut dhead_b);
209
210        let alpha = out.alpha;
211        let gamma = self.config.physics_scale;
212        let hidden = self.config.hidden;
213
214        // fusion: h = α h_s + (1-α) h_t + γ h_p
215        let mut dh_s = vec![0.0f32; hidden];
216        let mut dh_t = vec![0.0f32; hidden];
217        let mut dh_p = vec![0.0f32; hidden];
218        let mut d_alpha = 0.0f32;
219        for i in 0..hidden {
220            dh_s[i] = alpha * dh_fused[i];
221            dh_t[i] = (1.0 - alpha) * dh_fused[i];
222            dh_p[i] = gamma * dh_fused[i];
223            d_alpha += dh_fused[i] * (out.h_spatial[i] - out.h_temporal[i]);
224        }
225        // α = σ(logit) ⇒ dlogit = dα · α · (1-α)
226        let d_alpha_logit = d_alpha * alpha * (1.0 - alpha);
227
228        // temporal GLU
229        let mut dproj_w = vec![0.0; self.temporal.proj.w.len()];
230        let mut dproj_b = vec![0.0; self.temporal.proj.b.len()];
231        let _dseries = self
232            .temporal
233            .backward(&series, &dh_t, &mut dproj_w, &mut dproj_b);
234
235        // spatial GraphSAGE
236        let mut dws_w = vec![0.0; self.spatial.w_self.w.len()];
237        let mut dws_b = vec![0.0; self.spatial.w_self.b.len()];
238        let mut dwn_w = vec![0.0; self.spatial.w_neigh.w.len()];
239        let mut dwn_b = vec![0.0; self.spatial.w_neigh.b.len()];
240        let _ = self.spatial.backward(
241            &x_self,
242            &x_agg,
243            &dh_s,
244            &mut dws_w,
245            &mut dws_b,
246            &mut dwn_w,
247            &mut dwn_b,
248        );
249
250        // physics proj
251        let mut dphys_w = vec![0.0; self.physics_proj.w.len()];
252        let mut dphys_b = vec![0.0; self.physics_proj.b.len()];
253        let _ = self
254            .physics_proj
255            .backward(&physics, &dh_p, &mut dphys_w, &mut dphys_b);
256
257        // apply updates
258        self.head.sgd_step(&dhead_w, &dhead_b, lr, weight_decay);
259        self.temporal
260            .proj
261            .sgd_step(&dproj_w, &dproj_b, lr, weight_decay);
262        self.spatial
263            .w_self
264            .sgd_step(&dws_w, &dws_b, lr, weight_decay);
265        self.spatial
266            .w_neigh
267            .sgd_step(&dwn_w, &dwn_b, lr, weight_decay);
268        self.physics_proj
269            .sgd_step(&dphys_w, &dphys_b, lr, weight_decay);
270        self.alpha_logit -= lr * d_alpha_logit;
271
272        loss
273    }
274}
275
276fn sigmoid(x: f32) -> f32 {
277    1.0 / (1.0 + (-x).exp())
278}
279
280fn pad_or_trunc(v: &[f32], dim: usize) -> Vec<f32> {
281    let mut out = vec![0.0f32; dim];
282    let n = v.len().min(dim);
283    out[..n].copy_from_slice(&v[..n]);
284    out
285}
286
287fn argmax(v: &[f32]) -> usize {
288    let mut best_i = 0;
289    let mut best_v = f32::NEG_INFINITY;
290    for (i, &x) in v.iter().enumerate() {
291        if x > best_v {
292            best_v = x;
293            best_i = i;
294        }
295    }
296    best_i
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::sensor_graph::graph::SensorNode;
303
304    fn tiny_graph() -> SensorGraph {
305        let mut g = SensorGraph::new();
306        g.add_buoy(SensorNode {
307            id: "A".into(),
308            position: (0.0, 0.0, 10.0),
309            features: vec![0.5, 0.2, 0.1, 0.0, 0.3],
310        });
311        g.add_buoy(SensorNode {
312            id: "B".into(),
313            position: (1.0, 0.0, 10.0),
314            features: vec![0.4, 0.2, 0.2, 0.1, 0.3],
315        });
316        g.connect(0, 1);
317        g.push_temporal(0, &[0.1, 0.2, 0.8, 0.9, 0.7, 0.3, 0.1, 0.0]);
318        g.push_temporal(0, &[0.0; 8]);
319        g.push_temporal(1, &[0.05; 16]);
320        g
321    }
322
323    #[test]
324    fn forward_shapes() {
325        let cfg = KstemitConfig {
326            feature_dim: 5,
327            temporal_window: 16,
328            hidden: 4,
329            num_classes: 3,
330            physics_scale: 0.25,
331            seed: 1,
332        };
333        let model = KstemitModel::new(cfg);
334        let g = tiny_graph();
335        let out = model.forward_node(&g, 0);
336        assert_eq!(out.h_spatial.len(), 4);
337        assert_eq!(out.h_temporal.len(), 4);
338        assert_eq!(out.h_physics.len(), 4);
339        assert_eq!(out.h_fused.len(), 4);
340        assert_eq!(out.logits.len(), 3);
341        assert_eq!(out.probs.len(), 3);
342        let s: f32 = out.probs.iter().sum();
343        assert!((s - 1.0).abs() < 1e-5);
344        assert!(out.alpha > 0.0 && out.alpha < 1.0);
345    }
346
347    #[test]
348    fn train_step_decreases_loss_on_easy_label() {
349        let cfg = KstemitConfig {
350            feature_dim: 5,
351            temporal_window: 16,
352            hidden: 8,
353            num_classes: 3,
354            physics_scale: 0.25,
355            seed: 99,
356        };
357        let mut model = KstemitModel::new(cfg);
358        let g = tiny_graph();
359        let label = 1usize;
360        let loss0 = model.loss_node(&g, 0, label);
361        for _ in 0..40 {
362            let _ = model.train_step(&g, 0, label, 0.05, 1e-4);
363        }
364        let loss1 = model.loss_node(&g, 0, label);
365        assert!(
366            loss1 < loss0,
367            "expected loss drop: {loss0:.4} → {loss1:.4}"
368        );
369    }
370
371    #[test]
372    fn predict_returns_valid_class() {
373        let model = KstemitModel::new(KstemitConfig::default());
374        let g = tiny_graph();
375        let c = model.predict(&g, 0);
376        assert!(c < model.config.num_classes);
377    }
378}