Skip to main content

oxiphysics_gpu/
gpu_neural_solver.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU neural network solver for physics (CPU mock backend).
5//!
6//! Provides a multi-layer perceptron (MLP) framework and physics-informed
7//! neural network (PINN) utilities:
8//! - [`NeuralLayer`]: single dense layer with several activation modes.
9//! - [`GpuNeuralSolver`]: stacked MLP forward pass.
10//! - [`PhysicsNeuralNet`]: PINN wrapper with PDE + boundary-condition loss.
11//! - Activation functions: [`ns_relu`], [`ns_sigmoid`], [`ns_softmax`].
12//! - Loss functions: [`ns_mse_loss`], [`ns_mae_loss`].
13//! - PINN residuals: [`pinn_residual`], [`pinn_boundary_loss`].
14
15#![allow(dead_code)]
16
17// ── Activation functions ──────────────────────────────────────────────────────
18
19/// Rectified linear unit activation: `max(0, x)`.
20pub fn ns_relu(x: f64) -> f64 {
21    x.max(0.0)
22}
23
24/// Logistic sigmoid activation: `1 / (1 + e^{-x})`.
25pub fn ns_sigmoid(x: f64) -> f64 {
26    1.0 / (1.0 + (-x).exp())
27}
28
29/// Softmax of a slice: normalised exponentials `exp(x_i) / Σ exp(x_j)`.
30///
31/// Uses the max-subtraction trick for numerical stability.
32/// Returns an empty `Vec` when `x` is empty.
33pub fn ns_softmax(x: &[f64]) -> Vec<f64> {
34    if x.is_empty() {
35        return Vec::new();
36    }
37    let max_val = x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
38    let exps: Vec<f64> = x.iter().map(|&v| (v - max_val).exp()).collect();
39    let sum: f64 = exps.iter().sum();
40    exps.iter().map(|&e| e / sum).collect()
41}
42
43// ── Loss functions ────────────────────────────────────────────────────────────
44
45/// Mean-squared error loss: `mean((predicted_i − target_i)²)`.
46///
47/// Returns `0.0` when `predicted` is empty.
48pub fn ns_mse_loss(predicted: &[f64], target: &[f64]) -> f64 {
49    if predicted.is_empty() {
50        return 0.0;
51    }
52    let n = predicted.len().min(target.len());
53    predicted[..n]
54        .iter()
55        .zip(target[..n].iter())
56        .map(|(p, t)| (p - t).powi(2))
57        .sum::<f64>()
58        / n as f64
59}
60
61/// Mean absolute error loss: `mean(|predicted_i − target_i|)`.
62///
63/// Returns `0.0` when `predicted` is empty.
64pub fn ns_mae_loss(predicted: &[f64], target: &[f64]) -> f64 {
65    if predicted.is_empty() {
66        return 0.0;
67    }
68    let n = predicted.len().min(target.len());
69    predicted[..n]
70        .iter()
71        .zip(target[..n].iter())
72        .map(|(p, t)| (p - t).abs())
73        .sum::<f64>()
74        / n as f64
75}
76
77// ── PINN residuals ────────────────────────────────────────────────────────────
78
79/// Physics-informed residual for the 1-D Poisson equation `−u_xx = source`.
80///
81/// Returns `−u_xx − source`; this should be driven to zero during training.
82pub fn pinn_residual(u: f64, u_xx: f64, source: f64) -> f64 {
83    let _ = u; // u itself is not needed for the Poisson residual
84    -u_xx - source
85}
86
87/// Boundary-condition loss: MSE between the predicted boundary values and the
88/// prescribed Dirichlet data.
89pub fn pinn_boundary_loss(u_boundary: &[f64], u_target: &[f64]) -> f64 {
90    ns_mse_loss(u_boundary, u_target)
91}
92
93// ── NeuralLayer ───────────────────────────────────────────────────────────────
94
95/// A single fully-connected (dense) neural network layer.
96///
97/// Weights are stored in row-major order: `weights[i * n_in + j]` is the
98/// weight from input `j` to output neuron `i`.
99#[derive(Debug, Clone)]
100pub struct NeuralLayer {
101    /// Flattened weight matrix of shape `[n_out × n_in]`.
102    pub weights: Vec<f64>,
103    /// Bias vector of length `n_out`.
104    pub biases: Vec<f64>,
105    /// Number of input features.
106    pub n_in: usize,
107    /// Number of output neurons.
108    pub n_out: usize,
109}
110
111impl NeuralLayer {
112    /// Create a new layer with all weights and biases initialised to `0.1`.
113    pub fn new(n_in: usize, n_out: usize) -> Self {
114        Self {
115            weights: vec![0.1; n_out * n_in],
116            biases: vec![0.0; n_out],
117            n_in,
118            n_out,
119        }
120    }
121
122    /// Linear forward pass (no activation): `W·x + b`.
123    pub fn forward(&self, input: &[f64]) -> Vec<f64> {
124        let n = self.n_in.min(input.len());
125        (0..self.n_out)
126            .map(|i| {
127                let base = i * self.n_in;
128                let dot: f64 = (0..n).map(|j| self.weights[base + j] * input[j]).sum();
129                dot + self.biases[i]
130            })
131            .collect()
132    }
133
134    /// Forward pass with ReLU activation applied element-wise.
135    pub fn relu_forward(&self, input: &[f64]) -> Vec<f64> {
136        self.forward(input).into_iter().map(ns_relu).collect()
137    }
138
139    /// Forward pass with tanh activation applied element-wise.
140    pub fn tanh_forward(&self, input: &[f64]) -> Vec<f64> {
141        self.forward(input).into_iter().map(|v| v.tanh()).collect()
142    }
143
144    /// Number of output neurons.
145    pub fn output_size(&self) -> usize {
146        self.n_out
147    }
148
149    /// Number of input features.
150    pub fn input_size(&self) -> usize {
151        self.n_in
152    }
153}
154
155// ── GpuNeuralSolver ───────────────────────────────────────────────────────────
156
157/// Multi-layer perceptron running on the CPU mock GPU backend.
158///
159/// Layers are stacked so that the output of layer `i` feeds into layer `i+1`.
160/// All hidden activations use ReLU; the final layer is linear.
161#[derive(Debug, Clone)]
162pub struct GpuNeuralSolver {
163    /// Ordered list of dense layers.
164    pub layers: Vec<NeuralLayer>,
165    /// Learning rate (stored for future back-prop use).
166    pub learning_rate: f64,
167}
168
169impl GpuNeuralSolver {
170    /// Build a network from an ordered list of layer widths.
171    ///
172    /// `layer_sizes` must contain at least two elements: the first is the
173    /// input dimension and the last is the output dimension.
174    pub fn new(layer_sizes: &[usize], lr: f64) -> Self {
175        assert!(
176            layer_sizes.len() >= 2,
177            "Need at least input and output sizes"
178        );
179        let layers = layer_sizes
180            .windows(2)
181            .map(|w| NeuralLayer::new(w[0], w[1]))
182            .collect();
183        Self {
184            layers,
185            learning_rate: lr,
186        }
187    }
188
189    /// Run a full forward pass through all layers (ReLU hidden, linear output).
190    pub fn forward_pass(&self, input: &[f64]) -> Vec<f64> {
191        let mut x: Vec<f64> = input.to_vec();
192        let last = self.layers.len().saturating_sub(1);
193        for (i, layer) in self.layers.iter().enumerate() {
194            x = if i < last {
195                layer.relu_forward(&x)
196            } else {
197                layer.forward(&x)
198            };
199        }
200        x
201    }
202
203    /// Number of layers (= number of weight matrices).
204    pub fn layer_count(&self) -> usize {
205        self.layers.len()
206    }
207
208    /// Alias for [`forward_pass`](GpuNeuralSolver::forward_pass).
209    pub fn predict(&self, input: &[f64]) -> Vec<f64> {
210        self.forward_pass(input)
211    }
212}
213
214// ── PhysicsNeuralNet ──────────────────────────────────────────────────────────
215
216/// Physics-informed neural network (PINN).
217///
218/// Wraps a [`GpuNeuralSolver`] and provides a composite loss that combines
219/// a PDE residual term with a boundary-condition MSE term.
220#[derive(Debug, Clone)]
221pub struct PhysicsNeuralNet {
222    /// Underlying neural solver.
223    pub solver: GpuNeuralSolver,
224    /// Weight applied to the PDE residual in the total loss.
225    pub pde_weight: f64,
226    /// Weight applied to the boundary-condition loss in the total loss.
227    pub bc_weight: f64,
228}
229
230impl PhysicsNeuralNet {
231    /// Create a new PINN from layer sizes, PDE weight, and BC weight.
232    pub fn new(layer_sizes: &[usize], pde_weight: f64, bc_weight: f64) -> Self {
233        Self {
234            solver: GpuNeuralSolver::new(layer_sizes, 1e-3),
235            pde_weight,
236            bc_weight,
237        }
238    }
239
240    /// Compute the weighted total loss:
241    /// `pde_weight * |pde_residual| + bc_weight * bc_loss`.
242    pub fn total_loss(&self, pde_residual: f64, bc_loss: f64) -> f64 {
243        self.pde_weight * pde_residual.abs() + self.bc_weight * bc_loss
244    }
245
246    /// Predict the solution at a 1-D coordinate `x`.
247    ///
248    /// Wraps the scalar input into a slice and extracts the first output.
249    pub fn predict(&self, x: f64) -> f64 {
250        let out = self.solver.predict(&[x]);
251        out.first().copied().unwrap_or(0.0)
252    }
253}
254
255// ── Tests ─────────────────────────────────────────────────────────────────────
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    // ── ns_relu ──────────────────────────────────────────────────────────
262
263    #[test]
264    fn relu_negative_is_zero() {
265        assert!((ns_relu(-1.0) - 0.0).abs() < 1e-12);
266    }
267
268    #[test]
269    fn relu_positive_identity() {
270        assert!((ns_relu(1.0) - 1.0).abs() < 1e-12);
271    }
272
273    #[test]
274    fn relu_zero_boundary() {
275        assert!((ns_relu(0.0) - 0.0).abs() < 1e-12);
276    }
277
278    #[test]
279    fn relu_large_positive() {
280        assert!((ns_relu(1000.0) - 1000.0).abs() < 1e-8);
281    }
282
283    // ── ns_sigmoid ───────────────────────────────────────────────────────
284
285    #[test]
286    fn sigmoid_at_zero_is_half() {
287        assert!((ns_sigmoid(0.0) - 0.5).abs() < 1e-12);
288    }
289
290    #[test]
291    fn sigmoid_large_positive_near_one() {
292        assert!(ns_sigmoid(100.0) > 0.999);
293    }
294
295    #[test]
296    fn sigmoid_large_negative_near_zero() {
297        assert!(ns_sigmoid(-100.0) < 0.001);
298    }
299
300    #[test]
301    fn sigmoid_symmetry() {
302        let s = ns_sigmoid(2.0);
303        assert!((ns_sigmoid(-2.0) - (1.0 - s)).abs() < 1e-12);
304    }
305
306    // ── ns_softmax ────────────────────────────────────────────────────────
307
308    #[test]
309    fn softmax_sums_to_one() {
310        let x = [1.0, 2.0, 3.0];
311        let s = ns_softmax(&x);
312        let total: f64 = s.iter().sum();
313        assert!((total - 1.0).abs() < 1e-12);
314    }
315
316    #[test]
317    fn softmax_empty_input() {
318        let s = ns_softmax(&[]);
319        assert!(s.is_empty());
320    }
321
322    #[test]
323    fn softmax_single_element() {
324        let s = ns_softmax(&[42.0]);
325        assert!((s[0] - 1.0).abs() < 1e-12);
326    }
327
328    #[test]
329    fn softmax_uniform_input() {
330        let x = [1.0f64; 4];
331        let s = ns_softmax(&x);
332        for &v in &s {
333            assert!((v - 0.25).abs() < 1e-12);
334        }
335    }
336
337    #[test]
338    fn softmax_all_non_negative() {
339        let x = [-3.0, 0.0, 1.0, 5.0];
340        let s = ns_softmax(&x);
341        for &v in &s {
342            assert!(v >= 0.0);
343        }
344    }
345
346    // ── ns_mse_loss ──────────────────────────────────────────────────────
347
348    #[test]
349    fn mse_zero_for_identical() {
350        let v = [1.0, 2.0, 3.0];
351        assert!((ns_mse_loss(&v, &v) - 0.0).abs() < 1e-12);
352    }
353
354    #[test]
355    fn mse_known_value() {
356        let pred = [3.0];
357        let target = [1.0];
358        // (3-1)^2 / 1 = 4
359        assert!((ns_mse_loss(&pred, &target) - 4.0).abs() < 1e-12);
360    }
361
362    #[test]
363    fn mse_empty_returns_zero() {
364        assert!((ns_mse_loss(&[], &[]) - 0.0).abs() < 1e-12);
365    }
366
367    #[test]
368    fn mse_positive_values() {
369        let pred = [1.0, 2.0];
370        let target = [0.0, 0.0];
371        let loss = ns_mse_loss(&pred, &target);
372        assert!(loss > 0.0);
373    }
374
375    // ── ns_mae_loss ──────────────────────────────────────────────────────
376
377    #[test]
378    fn mae_zero_for_identical() {
379        let v = [1.0, 2.0, 3.0];
380        assert!((ns_mae_loss(&v, &v) - 0.0).abs() < 1e-12);
381    }
382
383    #[test]
384    fn mae_known_value() {
385        let pred = [3.0, 1.0];
386        let target = [1.0, 1.0];
387        // |3-1| + |1-1| = 2; / 2 = 1
388        assert!((ns_mae_loss(&pred, &target) - 1.0).abs() < 1e-12);
389    }
390
391    #[test]
392    fn mae_empty_returns_zero() {
393        assert!((ns_mae_loss(&[], &[]) - 0.0).abs() < 1e-12);
394    }
395
396    // ── NeuralLayer ───────────────────────────────────────────────────────
397
398    #[test]
399    fn neural_layer_output_size() {
400        let layer = NeuralLayer::new(4, 3);
401        assert_eq!(layer.output_size(), 3);
402    }
403
404    #[test]
405    fn neural_layer_input_size() {
406        let layer = NeuralLayer::new(4, 3);
407        assert_eq!(layer.input_size(), 4);
408    }
409
410    #[test]
411    fn neural_layer_forward_output_length() {
412        let layer = NeuralLayer::new(4, 3);
413        let out = layer.forward(&[1.0, 2.0, 3.0, 4.0]);
414        assert_eq!(out.len(), 3);
415    }
416
417    #[test]
418    fn neural_layer_relu_forward_non_negative() {
419        let layer = NeuralLayer::new(2, 4);
420        let out = layer.relu_forward(&[-10.0, -10.0]);
421        for &v in &out {
422            assert!(v >= 0.0);
423        }
424    }
425
426    #[test]
427    fn neural_layer_tanh_bounded() {
428        let layer = NeuralLayer::new(3, 3);
429        let out = layer.tanh_forward(&[1.0, 2.0, 3.0]);
430        for &v in &out {
431            assert!(v > -1.0 && v < 1.0);
432        }
433    }
434
435    #[test]
436    fn neural_layer_zero_input() {
437        // With all weights=0.1 and biases=0, output should be 0
438        let mut layer = NeuralLayer::new(3, 2);
439        layer.weights = vec![0.0; 6];
440        let out = layer.forward(&[0.0, 0.0, 0.0]);
441        for &v in &out {
442            assert!(v.abs() < 1e-12);
443        }
444    }
445
446    // ── GpuNeuralSolver ───────────────────────────────────────────────────
447
448    #[test]
449    fn solver_layer_count() {
450        let s = GpuNeuralSolver::new(&[4, 8, 8, 2], 1e-3);
451        assert_eq!(s.layer_count(), 3);
452    }
453
454    #[test]
455    fn solver_forward_output_shape() {
456        let s = GpuNeuralSolver::new(&[3, 5, 2], 1e-3);
457        let out = s.forward_pass(&[1.0, 0.0, -1.0]);
458        assert_eq!(out.len(), 2);
459    }
460
461    #[test]
462    fn solver_predict_same_as_forward() {
463        let s = GpuNeuralSolver::new(&[2, 4, 1], 1e-3);
464        let input = [0.5, -0.5];
465        let a = s.forward_pass(&input);
466        let b = s.predict(&input);
467        assert_eq!(a, b);
468    }
469
470    #[test]
471    fn solver_single_layer() {
472        let s = GpuNeuralSolver::new(&[2, 1], 1e-3);
473        let out = s.forward_pass(&[1.0, 1.0]);
474        assert_eq!(out.len(), 1);
475    }
476
477    #[test]
478    fn solver_deep_network_no_panic() {
479        let s = GpuNeuralSolver::new(&[10, 20, 20, 20, 5], 1e-4);
480        let input = vec![0.1; 10];
481        let out = s.forward_pass(&input);
482        assert_eq!(out.len(), 5);
483    }
484
485    // ── pinn_residual ────────────────────────────────────────────────────
486
487    #[test]
488    fn pinn_residual_formula() {
489        // residual = -u_xx - source
490        let r = pinn_residual(0.0, 2.0, 1.0);
491        assert!((r - (-3.0)).abs() < 1e-12);
492    }
493
494    #[test]
495    fn pinn_residual_zero_when_satisfied() {
496        // If -u_xx == source then residual == 0
497        let u_xx = -1.0;
498        let source = 1.0;
499        let r = pinn_residual(0.0, u_xx, source);
500        assert!(r.abs() < 1e-12);
501    }
502
503    #[test]
504    fn pinn_boundary_loss_zero_for_equal() {
505        let v = [1.0, 0.0, -1.0];
506        assert!((pinn_boundary_loss(&v, &v) - 0.0).abs() < 1e-12);
507    }
508
509    #[test]
510    fn pinn_boundary_loss_positive_for_different() {
511        let u_boundary = [1.0, 2.0];
512        let u_target = [0.0, 0.0];
513        assert!(pinn_boundary_loss(&u_boundary, &u_target) > 0.0);
514    }
515
516    // ── PhysicsNeuralNet ─────────────────────────────────────────────────
517
518    #[test]
519    fn pinn_total_loss_formula() {
520        let net = PhysicsNeuralNet::new(&[1, 4, 1], 2.0, 3.0);
521        // pde_residual = 1, bc_loss = 1 => 2*1 + 3*1 = 5
522        let loss = net.total_loss(1.0, 1.0);
523        assert!((loss - 5.0).abs() < 1e-12);
524    }
525
526    #[test]
527    fn pinn_total_loss_zero_when_both_zero() {
528        let net = PhysicsNeuralNet::new(&[1, 4, 1], 1.0, 1.0);
529        assert!((net.total_loss(0.0, 0.0) - 0.0).abs() < 1e-12);
530    }
531
532    #[test]
533    fn pinn_predict_returns_scalar() {
534        let net = PhysicsNeuralNet::new(&[1, 8, 1], 1.0, 1.0);
535        let _v = net.predict(0.5); // should not panic
536    }
537
538    #[test]
539    fn pinn_total_loss_pde_only() {
540        let net = PhysicsNeuralNet::new(&[1, 4, 1], 5.0, 0.0);
541        assert!((net.total_loss(2.0, 100.0) - 10.0).abs() < 1e-12);
542    }
543
544    #[test]
545    fn pinn_total_loss_bc_only() {
546        let net = PhysicsNeuralNet::new(&[1, 4, 1], 0.0, 4.0);
547        assert!((net.total_loss(100.0, 3.0) - 12.0).abs() < 1e-12);
548    }
549}