1use 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#[derive(Debug, Clone)]
12pub struct KstemitConfig {
13 pub feature_dim: usize,
15 pub temporal_window: usize,
17 pub hidden: usize,
19 pub num_classes: usize,
21 pub physics_scale: f32,
23 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#[derive(Debug, Clone)]
42pub struct ForwardOutput {
43 pub h_spatial: Vec<f32>,
45 pub h_temporal: Vec<f32>,
47 pub h_physics: Vec<f32>,
49 pub h_fused: Vec<f32>,
51 pub logits: Vec<f32>,
53 pub probs: Vec<f32>,
55 pub alpha: f32,
57}
58
59#[derive(Debug, Clone)]
69pub struct KstemitModel {
70 pub config: KstemitConfig,
71 pub temporal: TemporalGlu,
72 pub spatial: GraphSageLayer,
73 pub physics_proj: Linear,
74 pub alpha_logit: f32,
76 pub head: Linear,
77}
78
79impl KstemitModel {
80 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, head: Linear::xavier(config.hidden, config.num_classes, s.wrapping_add(3)),
97 config,
98 }
99 }
100
101 pub fn alpha(&self) -> f32 {
103 sigmoid(self.alpha_logit)
104 }
105
106 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 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 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 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 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 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 let dlogits = softmax_ce_grad(&out.probs, label);
202
203 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 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 let d_alpha_logit = d_alpha * alpha * (1.0 - alpha);
227
228 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 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 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 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}