Skip to main content

thrust_rl/policy/
universal_inference.rs

1//! Universal inference system for diverse neural network architectures
2//!
3//! This module provides a flexible, extensible inference engine that can handle
4//! various model architectures defined in JSON format, without hardcoding
5//! specific layer types or architectures.
6
7use std::collections::HashMap;
8
9use anyhow::{Result, anyhow};
10use serde::{Deserialize, Serialize};
11
12/// Activation function types supported by the inference engine
13///
14/// Serialized as the enum variant name by default. `Gelu` and `Swish` use
15/// the custom serde names `"GELU"` and `"Swish"` for compatibility with the
16/// upstream JSON wire format produced by the training-side exporter.
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
18pub enum Activation {
19    /// Rectified linear unit, `max(x, 0)`. Most common hidden activation.
20    ReLU,
21    /// Hyperbolic tangent, `x.tanh()`. Bounded output in `(-1, 1)`.
22    Tanh,
23    /// Logistic sigmoid, `1 / (1 + exp(-x))`. Bounded output in `(0, 1)`.
24    Sigmoid,
25    /// No-op pass-through; useful for explicit "linear" output heads.
26    Identity,
27    /// Approximate Gaussian error linear unit (`tanh` approximation, see
28    /// [`Activation::apply`]). Serialized as `"GELU"`.
29    #[serde(rename = "GELU")]
30    Gelu,
31    /// `x * sigmoid(x)` (also known as SiLU). Serialized as `"Swish"`.
32    #[serde(rename = "Swish")]
33    Swish,
34}
35
36impl Activation {
37    /// Apply activation function to a single value
38    #[inline]
39    pub fn apply(&self, x: f32) -> f32 {
40        match self {
41            Activation::ReLU => x.max(0.0),
42            Activation::Tanh => x.tanh(),
43            Activation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
44            Activation::Identity => x,
45            Activation::Gelu => {
46                // Approximate GELU: 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))
47                const SQRT_2_OVER_PI: f32 = 0.797_884_6;
48                0.5 * x * (1.0 + (SQRT_2_OVER_PI * (x + 0.044715 * x.powi(3))).tanh())
49            }
50            Activation::Swish => x / (1.0 + (-x).exp()), // x * sigmoid(x)
51        }
52    }
53
54    /// Apply activation function to a vector in-place
55    #[inline]
56    pub fn apply_vec(&self, vec: &mut [f32]) {
57        for val in vec.iter_mut() {
58            *val = self.apply(*val);
59        }
60    }
61}
62
63/// Layer type definition for the universal inference system
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(tag = "type")]
66pub enum Layer {
67    /// Fully connected (linear) layer
68    #[serde(rename = "linear")]
69    Linear {
70        /// Human-readable layer name, propagated from the exporter (e.g.
71        /// `"fc1"`). Used only for diagnostics / pretty-printing.
72        name: String,
73        /// Input feature count. Must match the size of the incoming 1D
74        /// tensor or [`Layer::forward`] returns an error.
75        in_features: usize,
76        /// Output feature count, equal to `weight.len()` and `bias.len()`.
77        out_features: usize,
78        /// Dense weight matrix, shape `[out_features, in_features]`.
79        weight: Vec<Vec<f32>>,
80        /// Per-output bias, shape `[out_features]`.
81        bias: Vec<f32>,
82    },
83
84    /// 2D Convolutional layer
85    #[serde(rename = "conv2d")]
86    Conv2d {
87        /// Human-readable layer name (e.g. `"conv1"`).
88        name: String,
89        /// Expected channel depth of the input tensor.
90        in_channels: usize,
91        /// Channel depth of the produced tensor (`= weight.len()`).
92        out_channels: usize,
93        /// Side length of the square kernel; both height and width.
94        kernel_size: usize,
95        /// Zero-padding applied symmetrically on all four sides before the
96        /// kernel sweep.
97        padding: usize,
98        /// Step between successive kernel positions along both axes.
99        stride: usize,
100        /// Kernel weights, shape
101        /// `[out_channels, in_channels, kernel_size, kernel_size]`.
102        weight: Vec<Vec<Vec<Vec<f32>>>>,
103        /// Per-output-channel bias, shape `[out_channels]`.
104        bias: Vec<f32>,
105    },
106
107    /// Activation layer
108    #[serde(rename = "activation")]
109    Activation {
110        /// Human-readable layer name (e.g. `"relu1"`).
111        name: String,
112        /// Element-wise function applied in place; see [`Activation::apply`].
113        activation: Activation,
114    },
115
116    /// Reshape/Flatten layer
117    #[serde(rename = "flatten")]
118    Flatten {
119        /// Human-readable layer name. Flatten collapses any input rank into
120        /// a 1D tensor in `[c, h, w]` order.
121        name: String,
122    },
123
124    /// Reshape layer with specific dimensions
125    #[serde(rename = "reshape")]
126    Reshape {
127        /// Human-readable layer name.
128        name: String,
129        /// Target tensor shape. Supported lengths are `1` (1D `[size]`) and
130        /// `3` (3D `[channels, height, width]`); other lengths cause
131        /// [`Layer::forward`] to return an error.
132        shape: Vec<usize>,
133    },
134
135    /// Residual/Skip connection (adds input to output)
136    #[serde(rename = "residual")]
137    Residual {
138        /// Human-readable layer name (e.g. `"resblock1"`).
139        name: String,
140        /// Sub-layers applied to a clone of the input; the result is added
141        /// element-wise back into the original input. Shapes of the
142        /// pre- and post-sub-layer tensors must match.
143        layers: Vec<Layer>,
144    },
145}
146
147impl Layer {
148    /// Get the layer name
149    pub fn name(&self) -> &str {
150        match self {
151            Layer::Linear { name, .. } => name,
152            Layer::Conv2d { name, .. } => name,
153            Layer::Activation { name, .. } => name,
154            Layer::Flatten { name } => name,
155            Layer::Reshape { name, .. } => name,
156            Layer::Residual { name, .. } => name,
157        }
158    }
159
160    /// Apply this layer to input data
161    // Reshape branches compute flat indices from per-axis counters
162    // (`idx = ch * h * w + row * w + col`); enumerate() cannot express that.
163    #[allow(clippy::needless_range_loop)]
164    pub fn forward(&self, input: &Tensor) -> Result<Tensor> {
165        match self {
166            Layer::Linear { weight, bias, out_features, .. } => {
167                // Input should be 1D [in_features]
168                let in_vec = input.as_1d()?;
169                let mut output = vec![0.0; *out_features];
170
171                for (i, row) in weight.iter().enumerate() {
172                    for (j, &val) in in_vec.iter().enumerate() {
173                        output[i] += row[j] * val;
174                    }
175                    output[i] += bias[i];
176                }
177
178                Ok(Tensor::D1(output))
179            }
180
181            Layer::Conv2d { weight, bias, padding, stride, .. } => {
182                // Input should be 3D [channels, height, width]
183                let input_3d = input.as_3d()?;
184                let output = Self::conv2d_forward(input_3d, weight, bias, *padding, *stride)?;
185                Ok(Tensor::D3(output))
186            }
187
188            Layer::Activation { activation, .. } => {
189                let mut output = input.clone();
190                match &mut output {
191                    Tensor::D1(v) => activation.apply_vec(v),
192                    Tensor::D3(v) => {
193                        for channel in v.iter_mut() {
194                            for row in channel.iter_mut() {
195                                activation.apply_vec(row);
196                            }
197                        }
198                    }
199                }
200                Ok(output)
201            }
202
203            Layer::Flatten { .. } => {
204                // Flatten any tensor to 1D
205                let flat = match input {
206                    Tensor::D1(v) => v.clone(),
207                    Tensor::D3(v) => {
208                        let mut flat = Vec::new();
209                        for channel in v {
210                            for row in channel {
211                                flat.extend_from_slice(row);
212                            }
213                        }
214                        flat
215                    }
216                };
217                Ok(Tensor::D1(flat))
218            }
219
220            Layer::Reshape { shape, .. } => {
221                // Flatten input first, then reshape
222                let flat = match input {
223                    Tensor::D1(v) => v.clone(),
224                    Tensor::D3(v) => {
225                        let mut flat = Vec::new();
226                        for channel in v {
227                            for row in channel {
228                                flat.extend_from_slice(row);
229                            }
230                        }
231                        flat
232                    }
233                };
234
235                // Reshape based on target shape
236                if shape.len() == 1 {
237                    if flat.len() != shape[0] {
238                        return Err(anyhow!(
239                            "Reshape size mismatch: {} != {}",
240                            flat.len(),
241                            shape[0]
242                        ));
243                    }
244                    Ok(Tensor::D1(flat))
245                } else if shape.len() == 3 {
246                    let (c, h, w) = (shape[0], shape[1], shape[2]);
247                    if flat.len() != c * h * w {
248                        return Err(anyhow!(
249                            "Reshape size mismatch: {} != {}",
250                            flat.len(),
251                            c * h * w
252                        ));
253                    }
254
255                    let mut reshaped = vec![vec![vec![0.0; w]; h]; c];
256                    for ch in 0..c {
257                        for row in 0..h {
258                            for col in 0..w {
259                                let idx = ch * h * w + row * w + col;
260                                reshaped[ch][row][col] = flat[idx];
261                            }
262                        }
263                    }
264                    Ok(Tensor::D3(reshaped))
265                } else {
266                    Err(anyhow!("Unsupported reshape dimensions: {:?}", shape))
267                }
268            }
269
270            Layer::Residual { layers, .. } => {
271                // Apply sub-layers and add result to input
272                let mut x = input.clone();
273                for layer in layers {
274                    x = layer.forward(&x)?;
275                }
276
277                // Add input to output (residual connection)
278                let mut result = input.clone();
279                match (&mut result, &x) {
280                    (Tensor::D1(r), Tensor::D1(x)) => {
281                        for (r_val, &x_val) in r.iter_mut().zip(x.iter()) {
282                            *r_val += x_val;
283                        }
284                    }
285                    (Tensor::D3(r), Tensor::D3(x)) => {
286                        for (r_ch, x_ch) in r.iter_mut().zip(x.iter()) {
287                            for (r_row, x_row) in r_ch.iter_mut().zip(x_ch.iter()) {
288                                for (r_val, &x_val) in r_row.iter_mut().zip(x_row.iter()) {
289                                    *r_val += x_val;
290                                }
291                            }
292                        }
293                    }
294                    _ => return Err(anyhow!("Residual connection dimension mismatch")),
295                }
296                Ok(result)
297            }
298        }
299    }
300
301    /// Perform 2D convolution
302    // Indexed loops mirror the [out_c][h][w][in_c][kh][kw] tensor layout and the
303    // strided/padded neighbor arithmetic; enumerate() would obscure the indexing.
304    #[allow(clippy::needless_range_loop)]
305    fn conv2d_forward(
306        input: &[Vec<Vec<f32>>],
307        weight: &[Vec<Vec<Vec<f32>>>],
308        bias: &[f32],
309        padding: usize,
310        stride: usize,
311    ) -> Result<Vec<Vec<Vec<f32>>>> {
312        let out_channels = weight.len();
313        let in_channels = input.len();
314        let in_height = input[0].len();
315        let in_width = input[0][0].len();
316        let kernel_size = weight[0][0].len();
317
318        let out_height = (in_height + 2 * padding - kernel_size) / stride + 1;
319        let out_width = (in_width + 2 * padding - kernel_size) / stride + 1;
320
321        let mut output = vec![vec![vec![0.0; out_width]; out_height]; out_channels];
322
323        for out_c in 0..out_channels {
324            for h in 0..out_height {
325                for w in 0..out_width {
326                    let mut sum = bias[out_c];
327
328                    for in_c in 0..in_channels {
329                        for kh in 0..kernel_size {
330                            for kw in 0..kernel_size {
331                                let ih = h * stride + kh;
332                                let iw = w * stride + kw;
333
334                                // Apply padding
335                                let ih = ih as i32 - padding as i32;
336                                let iw = iw as i32 - padding as i32;
337
338                                if ih >= 0
339                                    && ih < in_height as i32
340                                    && iw >= 0
341                                    && iw < in_width as i32
342                                {
343                                    sum += input[in_c][ih as usize][iw as usize]
344                                        * weight[out_c][in_c][kh][kw];
345                                }
346                            }
347                        }
348                    }
349
350                    output[out_c][h][w] = sum;
351                }
352            }
353        }
354
355        Ok(output)
356    }
357}
358
359/// Multi-dimensional tensor representation for intermediate activations
360#[derive(Debug, Clone)]
361pub enum Tensor {
362    /// 1D tensor `[size]`
363    D1(Vec<f32>),
364    /// 3D tensor [channels, height, width]
365    D3(Vec<Vec<Vec<f32>>>),
366}
367
368impl Tensor {
369    /// Extract as 1D tensor
370    pub fn as_1d(&self) -> Result<&Vec<f32>> {
371        match self {
372            Tensor::D1(v) => Ok(v),
373            _ => Err(anyhow!("Expected 1D tensor")),
374        }
375    }
376
377    /// Extract as 3D tensor
378    pub fn as_3d(&self) -> Result<&Vec<Vec<Vec<f32>>>> {
379        match self {
380            Tensor::D3(v) => Ok(v),
381            _ => Err(anyhow!("Expected 3D tensor")),
382        }
383    }
384
385    /// Get the total number of elements
386    pub fn size(&self) -> usize {
387        match self {
388            Tensor::D1(v) => v.len(),
389            Tensor::D3(v) => v.len() * v[0].len() * v[0][0].len(),
390        }
391    }
392}
393
394/// Input specification for the model
395#[derive(Debug, Clone, Serialize, Deserialize)]
396#[serde(tag = "type")]
397pub enum InputSpec {
398    /// 1D vector input (e.g., for CartPole, SimpleBandit)
399    #[serde(rename = "vector")]
400    Vector {
401        /// Length of the flat input vector. Callers must pass exactly this
402        /// many floats into [`UniversalModel::forward`].
403        size: usize,
404    },
405
406    /// 3D grid input (e.g., for Snake)
407    #[serde(rename = "grid")]
408    Grid {
409        /// Number of input channels stacked along the leading axis (e.g.
410        /// 5 for Snake's body / head / food / etc. planes).
411        channels: usize,
412        /// Grid height in cells.
413        height: usize,
414        /// Grid width in cells. The expected flat input length is
415        /// `channels * height * width`, laid out as channel-major,
416        /// then row-major within each channel.
417        width: usize,
418    },
419}
420
421/// Output specification for the model
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct OutputSpec {
424    /// Length of the policy-head logit vector. For discrete-action
425    /// environments this is the action vocabulary size.
426    pub num_actions: usize,
427    /// If `true`, the model's `value_head` is expected to be present and
428    /// produces a scalar state-value estimate alongside the policy logits.
429    pub has_value: bool,
430}
431
432/// Training metadata
433///
434/// All fields are optional so older `.model.json` files (or models exported
435/// without a particular field populated) continue to deserialize cleanly.
436/// `None` values are skipped during serialization.
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct ModelMetadata {
439    /// Total gradient steps the optimizer took during training.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub total_steps: Option<usize>,
442    /// Total environment episodes consumed during training.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub total_episodes: Option<usize>,
445    /// Final evaluation metric (typically mean episode return) recorded by
446    /// the trainer at the time of export. Units are algorithm-specific.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub final_performance: Option<f64>,
449    /// Wall-clock training time in seconds.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub training_time_secs: Option<f64>,
452    /// Name of the compute device used (e.g. `"cuda:0"`, `"cpu"`, `"mps"`).
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub device: Option<String>,
455    /// Environment identifier (e.g. `"snake-10x10"`, `"cartpole"`).
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub environment: Option<String>,
458    /// Training algorithm identifier (e.g. `"ppo"`).
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub algorithm: Option<String>,
461    /// ISO-8601 timestamp of export, set by the trainer.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub timestamp: Option<String>,
464    /// Free-form notes captured at export (e.g. "trained on 4xH100").
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub notes: Option<String>,
467    /// Hyperparameter snapshot captured at export. Values are kept as
468    /// `serde_json::Value` so the wire format is open to additional keys
469    /// without trainer/inference coupling.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub hyperparameters: Option<HashMap<String, serde_json::Value>>,
472}
473
474/// Universal model definition
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct UniversalModel {
477    /// Model version (for future compatibility)
478    pub version: String,
479
480    /// Input specification
481    pub input: InputSpec,
482
483    /// Output specification
484    pub output: OutputSpec,
485
486    /// Optional training metadata
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub metadata: Option<ModelMetadata>,
489
490    /// Shared feature extraction layers
491    pub shared_layers: Vec<Layer>,
492
493    /// Policy head layers (outputs action logits)
494    pub policy_head: Vec<Layer>,
495
496    /// Value head layers (outputs state value estimate)
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub value_head: Option<Vec<Layer>>,
499}
500
501impl UniversalModel {
502    /// Save model to JSON file
503    pub fn save_json<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
504        let json = serde_json::to_string_pretty(self)?;
505        std::fs::write(path, json)?;
506        Ok(())
507    }
508
509    /// Load model from JSON file
510    pub fn load_json<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
511        let json = std::fs::read_to_string(path)?;
512        let model = serde_json::from_str(&json)?;
513        Ok(model)
514    }
515
516    /// Load model from JSON string
517    pub fn from_json_str(json: &str) -> Result<Self> {
518        let model = serde_json::from_str(json)?;
519        Ok(model)
520    }
521
522    /// Convert raw input to tensor based on input spec
523    // The grid reshape computes a flat index from three counters
524    // (`idx = c * height * width + h * width + w`); enumerate() cannot express it.
525    #[allow(clippy::needless_range_loop)]
526    fn input_to_tensor(&self, input: &[f32]) -> Result<Tensor> {
527        match &self.input {
528            InputSpec::Vector { size } => {
529                if input.len() != *size {
530                    return Err(anyhow!(
531                        "Input size mismatch: expected {}, got {}",
532                        size,
533                        input.len()
534                    ));
535                }
536                Ok(Tensor::D1(input.to_vec()))
537            }
538            InputSpec::Grid { channels, height, width } => {
539                let grid_size = channels * height * width;
540                if input.len() != grid_size {
541                    return Err(anyhow!(
542                        "Input size mismatch: expected {}, got {}",
543                        grid_size,
544                        input.len()
545                    ));
546                }
547
548                // Reshape to [channels, height, width]
549                let mut grid = vec![vec![vec![0.0; *width]; *height]; *channels];
550                for c in 0..*channels {
551                    for h in 0..*height {
552                        for w in 0..*width {
553                            let idx = c * height * width + h * width + w;
554                            grid[c][h][w] = input[idx];
555                        }
556                    }
557                }
558                Ok(Tensor::D3(grid))
559            }
560        }
561    }
562
563    /// Forward pass through the model
564    ///
565    /// # Returns
566    /// * `(logits, value)` - Action logits and optional state value
567    pub fn forward(&self, input: &[f32]) -> Result<(Vec<f32>, Option<f32>)> {
568        // Convert input to tensor
569        let mut x = self.input_to_tensor(input)?;
570
571        // Apply shared layers
572        for layer in &self.shared_layers {
573            x = layer.forward(&x)?;
574        }
575
576        // Apply policy head
577        let mut policy_features = x.clone();
578        for layer in &self.policy_head {
579            policy_features = layer.forward(&policy_features)?;
580        }
581        let logits = policy_features.as_1d()?.clone();
582
583        // Apply value head if present
584        let value = if let Some(value_head) = &self.value_head {
585            let mut value_features = x;
586            for layer in value_head {
587                value_features = layer.forward(&value_features)?;
588            }
589            let value_vec = value_features.as_1d()?;
590            if value_vec.len() != 1 {
591                return Err(anyhow!("Value head output should be size 1, got {}", value_vec.len()));
592            }
593            Some(value_vec[0])
594        } else {
595            None
596        };
597
598        Ok((logits, value))
599    }
600
601    /// Get action from input using argmax (deterministic)
602    pub fn get_action(&self, input: &[f32]) -> Result<usize> {
603        let (logits, _value) = self.forward(input)?;
604
605        let action = logits
606            .iter()
607            .enumerate()
608            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
609            .map(|(idx, _)| idx)
610            .ok_or_else(|| anyhow!("No actions available"))?;
611
612        Ok(action)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_activation_functions() {
622        assert_eq!(Activation::ReLU.apply(-1.0), 0.0);
623        assert_eq!(Activation::ReLU.apply(1.0), 1.0);
624
625        let tanh_result = Activation::Tanh.apply(0.0);
626        assert!((tanh_result - 0.0).abs() < 1e-6);
627
628        let sigmoid_result = Activation::Sigmoid.apply(0.0);
629        assert!((sigmoid_result - 0.5).abs() < 1e-6);
630    }
631
632    #[test]
633    fn test_simple_mlp() -> Result<()> {
634        // Create a simple 2-layer MLP
635        let model = UniversalModel {
636            version: "1.0".to_string(),
637            input: InputSpec::Vector { size: 4 },
638            output: OutputSpec { num_actions: 2, has_value: true },
639            metadata: None,
640            shared_layers: vec![
641                Layer::Linear {
642                    name: "fc1".to_string(),
643                    in_features: 4,
644                    out_features: 8,
645                    weight: vec![vec![0.1; 4]; 8],
646                    bias: vec![0.0; 8],
647                },
648                Layer::Activation { name: "relu1".to_string(), activation: Activation::ReLU },
649                Layer::Linear {
650                    name: "fc2".to_string(),
651                    in_features: 8,
652                    out_features: 8,
653                    weight: vec![vec![0.1; 8]; 8],
654                    bias: vec![0.0; 8],
655                },
656                Layer::Activation { name: "relu2".to_string(), activation: Activation::ReLU },
657            ],
658            policy_head: vec![Layer::Linear {
659                name: "policy".to_string(),
660                in_features: 8,
661                out_features: 2,
662                weight: vec![vec![0.1; 8]; 2],
663                bias: vec![0.0; 2],
664            }],
665            value_head: Some(vec![Layer::Linear {
666                name: "value".to_string(),
667                in_features: 8,
668                out_features: 1,
669                weight: vec![vec![0.1; 8]],
670                bias: vec![0.0],
671            }]),
672        };
673
674        let input = vec![1.0, 2.0, 3.0, 4.0];
675        let (logits, value) = model.forward(&input)?;
676
677        assert_eq!(logits.len(), 2);
678        assert!(value.is_some());
679
680        Ok(())
681    }
682
683    #[test]
684    fn test_get_action() -> Result<()> {
685        let model = UniversalModel {
686            version: "1.0".to_string(),
687            input: InputSpec::Vector { size: 2 },
688            output: OutputSpec { num_actions: 2, has_value: false },
689            metadata: None,
690            shared_layers: vec![],
691            policy_head: vec![Layer::Linear {
692                name: "policy".to_string(),
693                in_features: 2,
694                out_features: 2,
695                weight: vec![vec![1.0, 0.0], vec![0.0, 1.0]],
696                bias: vec![0.0, 0.0],
697            }],
698            value_head: None,
699        };
700
701        let input = vec![2.0, 1.0];
702        let action = model.get_action(&input)?;
703
704        assert_eq!(action, 0); // First action should have higher logit (2.0 > 1.0)
705
706        Ok(())
707    }
708}