use super::graph::{SensorGraph, PHYSICS_DIM};
use super::layers::{
cross_entropy, softmax, softmax_ce_grad, GraphSageLayer, Linear, TemporalGlu,
};
const DEFAULT_SEED: u64 = 0x00c5_7e71;
#[derive(Debug, Clone)]
pub struct KstemitConfig {
pub feature_dim: usize,
pub temporal_window: usize,
pub hidden: usize,
pub num_classes: usize,
pub physics_scale: f32,
pub seed: u64,
}
impl Default for KstemitConfig {
fn default() -> Self {
Self {
feature_dim: PHYSICS_DIM,
temporal_window: 16,
hidden: 8,
num_classes: 3,
physics_scale: 0.25,
seed: DEFAULT_SEED,
}
}
}
#[derive(Debug, Clone)]
pub struct ForwardOutput {
pub h_spatial: Vec<f32>,
pub h_temporal: Vec<f32>,
pub h_physics: Vec<f32>,
pub h_fused: Vec<f32>,
pub logits: Vec<f32>,
pub probs: Vec<f32>,
pub alpha: f32,
}
#[derive(Debug, Clone)]
pub struct KstemitModel {
pub config: KstemitConfig,
pub temporal: TemporalGlu,
pub spatial: GraphSageLayer,
pub physics_proj: Linear,
pub alpha_logit: f32,
pub head: Linear,
}
impl KstemitModel {
pub fn new(config: KstemitConfig) -> Self {
assert!(config.feature_dim > 0);
assert!(config.temporal_window > 0);
assert!(config.hidden > 0);
assert!(config.num_classes > 0);
let s = config.seed;
Self {
temporal: TemporalGlu::new(config.temporal_window, config.hidden, s),
spatial: GraphSageLayer::new(config.feature_dim, config.hidden, s.wrapping_add(1)),
physics_proj: Linear::xavier(
PHYSICS_DIM.min(config.feature_dim).max(1),
config.hidden,
s.wrapping_add(2),
),
alpha_logit: 0.0, head: Linear::xavier(config.hidden, config.num_classes, s.wrapping_add(3)),
config,
}
}
pub fn alpha(&self) -> f32 {
sigmoid(self.alpha_logit)
}
pub fn forward_node(&self, graph: &SensorGraph, node_idx: usize) -> ForwardOutput {
let (x_self, x_agg, series, physics) = self.inputs_for(graph, node_idx);
self.forward_tensors(&x_self, &x_agg, &series, &physics)
}
pub fn inputs_for(
&self,
graph: &SensorGraph,
node_idx: usize,
) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
let feat_dim = self.config.feature_dim;
let x_self = pad_or_trunc(
graph
.nodes
.get(node_idx)
.map(|n| n.features.as_slice())
.unwrap_or(&[]),
feat_dim,
);
let x_agg = pad_or_trunc(&graph.aggregate_neighbors(node_idx, 1), feat_dim);
let series = graph.temporal_features(node_idx, self.config.temporal_window);
let physics = {
let raw = graph
.nodes
.get(node_idx)
.map(|n| n.physics())
.unwrap_or([0.0; PHYSICS_DIM]);
let need = self.physics_proj.in_dim;
pad_or_trunc(&raw, need)
};
(x_self, x_agg, series, physics)
}
pub fn forward_tensors(
&self,
x_self: &[f32],
x_agg: &[f32],
series: &[f32],
physics: &[f32],
) -> ForwardOutput {
let h_spatial = self.spatial.forward(x_self, x_agg);
let h_temporal = self.temporal.forward(series);
let h_physics = self.physics_proj.forward(physics);
let alpha = self.alpha();
let gamma = self.config.physics_scale;
let hidden = self.config.hidden;
let mut h_fused = vec![0.0f32; hidden];
for i in 0..hidden {
h_fused[i] =
alpha * h_spatial[i] + (1.0 - alpha) * h_temporal[i] + gamma * h_physics[i];
}
let logits = self.head.forward(&h_fused);
let probs = softmax(&logits);
ForwardOutput {
h_spatial,
h_temporal,
h_physics,
h_fused,
logits,
probs,
alpha,
}
}
pub fn predict(&self, graph: &SensorGraph, node_idx: usize) -> usize {
let out = self.forward_node(graph, node_idx);
argmax(&out.logits)
}
pub fn loss_node(&self, graph: &SensorGraph, node_idx: usize, label: usize) -> f32 {
let out = self.forward_node(graph, node_idx);
cross_entropy(&out.probs, label)
}
pub fn train_step(
&mut self,
graph: &SensorGraph,
node_idx: usize,
label: usize,
lr: f32,
weight_decay: f32,
) -> f32 {
let (x_self, x_agg, series, physics) = self.inputs_for(graph, node_idx);
let out = self.forward_tensors(&x_self, &x_agg, &series, &physics);
let loss = cross_entropy(&out.probs, label);
let dlogits = softmax_ce_grad(&out.probs, label);
let mut dhead_w = vec![0.0; self.head.w.len()];
let mut dhead_b = vec![0.0; self.head.b.len()];
let dh_fused = self
.head
.backward(&out.h_fused, &dlogits, &mut dhead_w, &mut dhead_b);
let alpha = out.alpha;
let gamma = self.config.physics_scale;
let hidden = self.config.hidden;
let mut dh_s = vec![0.0f32; hidden];
let mut dh_t = vec![0.0f32; hidden];
let mut dh_p = vec![0.0f32; hidden];
let mut d_alpha = 0.0f32;
for i in 0..hidden {
dh_s[i] = alpha * dh_fused[i];
dh_t[i] = (1.0 - alpha) * dh_fused[i];
dh_p[i] = gamma * dh_fused[i];
d_alpha += dh_fused[i] * (out.h_spatial[i] - out.h_temporal[i]);
}
let d_alpha_logit = d_alpha * alpha * (1.0 - alpha);
let mut dproj_w = vec![0.0; self.temporal.proj.w.len()];
let mut dproj_b = vec![0.0; self.temporal.proj.b.len()];
let _dseries = self
.temporal
.backward(&series, &dh_t, &mut dproj_w, &mut dproj_b);
let mut dws_w = vec![0.0; self.spatial.w_self.w.len()];
let mut dws_b = vec![0.0; self.spatial.w_self.b.len()];
let mut dwn_w = vec![0.0; self.spatial.w_neigh.w.len()];
let mut dwn_b = vec![0.0; self.spatial.w_neigh.b.len()];
let _ = self.spatial.backward(
&x_self,
&x_agg,
&dh_s,
&mut dws_w,
&mut dws_b,
&mut dwn_w,
&mut dwn_b,
);
let mut dphys_w = vec![0.0; self.physics_proj.w.len()];
let mut dphys_b = vec![0.0; self.physics_proj.b.len()];
let _ = self
.physics_proj
.backward(&physics, &dh_p, &mut dphys_w, &mut dphys_b);
self.head.sgd_step(&dhead_w, &dhead_b, lr, weight_decay);
self.temporal
.proj
.sgd_step(&dproj_w, &dproj_b, lr, weight_decay);
self.spatial
.w_self
.sgd_step(&dws_w, &dws_b, lr, weight_decay);
self.spatial
.w_neigh
.sgd_step(&dwn_w, &dwn_b, lr, weight_decay);
self.physics_proj
.sgd_step(&dphys_w, &dphys_b, lr, weight_decay);
self.alpha_logit -= lr * d_alpha_logit;
loss
}
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
fn pad_or_trunc(v: &[f32], dim: usize) -> Vec<f32> {
let mut out = vec![0.0f32; dim];
let n = v.len().min(dim);
out[..n].copy_from_slice(&v[..n]);
out
}
fn argmax(v: &[f32]) -> usize {
let mut best_i = 0;
let mut best_v = f32::NEG_INFINITY;
for (i, &x) in v.iter().enumerate() {
if x > best_v {
best_v = x;
best_i = i;
}
}
best_i
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sensor_graph::graph::SensorNode;
fn tiny_graph() -> SensorGraph {
let mut g = SensorGraph::new();
g.add_buoy(SensorNode {
id: "A".into(),
position: (0.0, 0.0, 10.0),
features: vec![0.5, 0.2, 0.1, 0.0, 0.3],
});
g.add_buoy(SensorNode {
id: "B".into(),
position: (1.0, 0.0, 10.0),
features: vec![0.4, 0.2, 0.2, 0.1, 0.3],
});
g.connect(0, 1);
g.push_temporal(0, &[0.1, 0.2, 0.8, 0.9, 0.7, 0.3, 0.1, 0.0]);
g.push_temporal(0, &[0.0; 8]);
g.push_temporal(1, &[0.05; 16]);
g
}
#[test]
fn forward_shapes() {
let cfg = KstemitConfig {
feature_dim: 5,
temporal_window: 16,
hidden: 4,
num_classes: 3,
physics_scale: 0.25,
seed: 1,
};
let model = KstemitModel::new(cfg);
let g = tiny_graph();
let out = model.forward_node(&g, 0);
assert_eq!(out.h_spatial.len(), 4);
assert_eq!(out.h_temporal.len(), 4);
assert_eq!(out.h_physics.len(), 4);
assert_eq!(out.h_fused.len(), 4);
assert_eq!(out.logits.len(), 3);
assert_eq!(out.probs.len(), 3);
let s: f32 = out.probs.iter().sum();
assert!((s - 1.0).abs() < 1e-5);
assert!(out.alpha > 0.0 && out.alpha < 1.0);
}
#[test]
fn train_step_decreases_loss_on_easy_label() {
let cfg = KstemitConfig {
feature_dim: 5,
temporal_window: 16,
hidden: 8,
num_classes: 3,
physics_scale: 0.25,
seed: 99,
};
let mut model = KstemitModel::new(cfg);
let g = tiny_graph();
let label = 1usize;
let loss0 = model.loss_node(&g, 0, label);
for _ in 0..40 {
let _ = model.train_step(&g, 0, label, 0.05, 1e-4);
}
let loss1 = model.loss_node(&g, 0, label);
assert!(
loss1 < loss0,
"expected loss drop: {loss0:.4} → {loss1:.4}"
);
}
#[test]
fn predict_returns_valid_class() {
let model = KstemitModel::new(KstemitConfig::default());
let g = tiny_graph();
let c = model.predict(&g, 0);
assert!(c < model.config.num_classes);
}
}