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