Skip to main content

hrx/artifacts/
onnx.rs

1//! Strict ONNX protobuf loading and graph/tensor inspection.
2
3use super::onnx_proto::{AttributeProto, GraphProto, ModelProto, NodeProto, TensorProto};
4use crate::{Error, Result};
5use protobuf::Message;
6use std::{
7    collections::{HashMap, HashSet},
8    path::Path,
9};
10
11/// ONNX protobuf messages and serialization using HRX's matching runtime.
12pub mod proto {
13    pub use super::super::onnx_proto::*;
14    pub use protobuf::Message;
15}
16
17/// A parsed ONNX model with initializer and edge indexes.
18pub struct Model {
19    name: String,
20    inputs: Vec<String>,
21    input_shapes: HashMap<String, Vec<Option<usize>>>,
22    outputs: Vec<String>,
23    opsets: Vec<(String, i64)>,
24    nodes: Vec<Node>,
25    initializers: HashMap<String, Tensor>,
26    initializer_order: Vec<String>,
27    producers: HashMap<String, usize>,
28    consumers: HashMap<String, Vec<usize>>,
29}
30
31impl Model {
32    /// Read and parse an ONNX protobuf file.
33    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
34        let path = path.as_ref();
35        let bytes = std::fs::read(path)
36            .map_err(|error| Error::Io(error).context(format!("reading {}", path.display())))?;
37        Self::from_bytes(&bytes)
38            .map_err(|error| error.context(format!("parsing {}", path.display())))
39    }
40
41    /// Parse an ONNX protobuf document and build graph indexes.
42    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
43        let mut protobuf = ModelProto::parse_from_bytes(bytes)
44            .map_err(|error| Error::Message(format!("invalid ONNX protobuf: {error}")))?;
45        let opsets = protobuf
46            .opset_import
47            .iter()
48            .map(|opset| (opset.domain.clone(), opset.version))
49            .collect();
50        let graph = protobuf
51            .graph
52            .take()
53            .ok_or_else(|| Error::Message("missing ONNX graph".into()))?;
54        Self::from_graph(graph, opsets)
55    }
56
57    fn from_graph(mut graph: GraphProto, opsets: Vec<(String, i64)>) -> Result<Self> {
58        let mut initializers = HashMap::new();
59        let mut initializer_order = Vec::new();
60        for protobuf in std::mem::take(&mut graph.initializer) {
61            if protobuf.name.is_empty() {
62                return Err(Error::Message("unnamed ONNX initializer".into()));
63            }
64            let name = protobuf.name.clone();
65            if initializers
66                .insert(name.clone(), Tensor { protobuf })
67                .is_some()
68            {
69                return Err(Error::Message(format!("duplicate ONNX initializer {name}")));
70            }
71            initializer_order.push(name);
72        }
73        let nodes = std::mem::take(&mut graph.node)
74            .into_iter()
75            .map(|protobuf| Node { protobuf })
76            .collect::<Vec<_>>();
77        let mut producers = HashMap::new();
78        let mut consumers = HashMap::<String, Vec<usize>>::new();
79        for (index, node) in nodes.iter().enumerate() {
80            for output in node.outputs().iter().filter(|name| !name.is_empty()) {
81                if producers.insert(output.clone(), index).is_some() {
82                    return Err(Error::Message(format!("duplicate ONNX value {output}")));
83                }
84            }
85            for input in node.inputs().iter().filter(|name| !name.is_empty()) {
86                consumers.entry(input.clone()).or_default().push(index);
87            }
88        }
89        let mut inputs = Vec::with_capacity(graph.input.len());
90        let mut input_shapes = HashMap::new();
91        for value in graph.input {
92            let shape = value
93                .type_
94                .as_ref()
95                .map(|kind| kind.tensor_type())
96                .and_then(|tensor| tensor.shape.as_ref())
97                .map(|shape| {
98                    shape
99                        .dim
100                        .iter()
101                        .map(|dimension| {
102                            usize::try_from(dimension.dim_value())
103                                .ok()
104                                .filter(|&value| value > 0)
105                        })
106                        .collect()
107                })
108                .unwrap_or_default();
109            input_shapes.insert(value.name.clone(), shape);
110            inputs.push(value.name);
111        }
112        Ok(Self {
113            name: graph.name,
114            inputs,
115            input_shapes,
116            outputs: graph.output.into_iter().map(|value| value.name).collect(),
117            opsets,
118            nodes,
119            initializers,
120            initializer_order,
121            producers,
122            consumers,
123        })
124    }
125
126    /// Graph name.
127    #[must_use]
128    pub fn name(&self) -> &str {
129        &self.name
130    }
131
132    /// Nodes in serialized order.
133    #[must_use]
134    pub fn nodes(&self) -> &[Node] {
135        &self.nodes
136    }
137
138    /// Model input names.
139    #[must_use]
140    pub fn inputs(&self) -> &[String] {
141        &self.inputs
142    }
143
144    /// Declared dimensions for a model input. Symbolic dimensions are `None`.
145    pub fn input_shape(&self, name: &str) -> Result<&[Option<usize>]> {
146        self.input_shapes
147            .get(name)
148            .map(Vec::as_slice)
149            .ok_or_else(|| Error::Message(format!("missing ONNX input {name}")))
150    }
151
152    /// Imported operator-set domains and versions in serialized order.
153    #[must_use]
154    pub fn opsets(&self) -> &[(String, i64)] {
155        &self.opsets
156    }
157
158    /// Model output names.
159    #[must_use]
160    pub fn outputs(&self) -> &[String] {
161        &self.outputs
162    }
163
164    /// Look up an initializer.
165    pub fn initializer(&self, name: &str) -> Result<&Tensor> {
166        self.initializers
167            .get(name)
168            .ok_or_else(|| Error::Message(format!("missing ONNX initializer {name}")))
169    }
170
171    /// Initializers in serialized order.
172    pub fn initializers(&self) -> impl Iterator<Item = (&str, &Tensor)> {
173        self.initializer_order.iter().map(|name| {
174            (
175                name.as_str(),
176                self.initializers
177                    .get(name)
178                    .expect("initializer order and index agree"),
179            )
180        })
181    }
182
183    /// Node producing a value, when it is not a graph input or initializer.
184    #[must_use]
185    pub fn producer(&self, value: &str) -> Option<&Node> {
186        self.producers.get(value).map(|&index| &self.nodes[index])
187    }
188
189    /// Nodes consuming a value.
190    pub fn consumers<'a>(&'a self, value: &'a str) -> impl Iterator<Item = &'a Node> + 'a {
191        self.consumers
192            .get(value)
193            .into_iter()
194            .flatten()
195            .map(|&index| &self.nodes[index])
196    }
197}
198
199/// One ONNX graph node with checked attribute accessors.
200pub struct Node {
201    protobuf: NodeProto,
202}
203
204impl Node {
205    /// Optional diagnostic name.
206    #[must_use]
207    pub fn name(&self) -> &str {
208        &self.protobuf.name
209    }
210
211    /// Operator identifier.
212    #[must_use]
213    pub fn op_type(&self) -> &str {
214        &self.protobuf.op_type
215    }
216
217    /// Operator domain; empty means the standard ONNX domain.
218    #[must_use]
219    pub fn domain(&self) -> &str {
220        &self.protobuf.domain
221    }
222
223    /// Input value names.
224    #[must_use]
225    pub fn inputs(&self) -> &[String] {
226        &self.protobuf.input
227    }
228
229    /// Output value names.
230    #[must_use]
231    pub fn outputs(&self) -> &[String] {
232        &self.protobuf.output
233    }
234
235    /// Require exactly one nonempty output and return it.
236    pub fn output(&self) -> Result<&str> {
237        match self.outputs() {
238            [output] if !output.is_empty() => Ok(output),
239            _ => Err(Error::Message(format!(
240                "{} must have one output",
241                self.op_type()
242            ))),
243        }
244    }
245
246    /// Whether an attribute is present.
247    #[must_use]
248    pub fn has(&self, name: &str) -> bool {
249        self.attribute(name).is_some()
250    }
251
252    /// Require that all attributes are allowed and unique.
253    pub fn validate_attributes(&self, allowed: &[&str]) -> Result<()> {
254        let mut names = HashSet::new();
255        if self.protobuf.attribute.iter().any(|attribute| {
256            !allowed.contains(&attribute.name.as_str()) || !names.insert(attribute.name.as_str())
257        }) {
258            return Err(Error::Message(format!(
259                "{} has an unknown or duplicate attribute",
260                self.op_type()
261            )));
262        }
263        Ok(())
264    }
265
266    /// Integer attribute or default.
267    #[must_use]
268    pub fn integer(&self, name: &str, default: i64) -> i64 {
269        self.attribute(name)
270            .map_or(default, |attribute| attribute.i)
271    }
272
273    /// Float attribute or default.
274    #[must_use]
275    pub fn float(&self, name: &str, default: f64) -> f64 {
276        self.attribute(name)
277            .map_or(default, |attribute| f64::from(attribute.f))
278    }
279
280    /// Integer-list attribute or default.
281    #[must_use]
282    pub fn integers(&self, name: &str, default: &[i64]) -> Vec<i64> {
283        self.attribute(name)
284            .map_or_else(|| default.to_vec(), |attribute| attribute.ints.clone())
285    }
286
287    /// UTF-8 text attribute or default. Invalid bytes are rejected.
288    pub fn text(&self, name: &str, default: &str) -> Result<String> {
289        match self.attribute(name) {
290            None => Ok(default.to_owned()),
291            Some(attribute) => String::from_utf8(attribute.s.clone())
292                .map_err(|_| Error::Message(format!("{name} is not UTF-8"))),
293        }
294    }
295
296    /// Embedded tensor attribute.
297    pub fn tensor(&self, name: &str) -> Result<TensorView<'_>> {
298        self.attribute(name)
299            .and_then(|attribute| attribute.t.as_ref())
300            .map(|protobuf| TensorView { protobuf })
301            .ok_or_else(|| Error::Message(format!("missing tensor attribute {name}")))
302    }
303
304    fn attribute(&self, name: &str) -> Option<&AttributeProto> {
305        self.protobuf
306            .attribute
307            .iter()
308            .find(|attribute| attribute.name == name)
309    }
310}
311
312/// One embedded ONNX initializer.
313pub struct Tensor {
314    protobuf: TensorProto,
315}
316
317impl Tensor {
318    /// Borrow this initializer as a tensor view.
319    #[must_use]
320    pub fn view(&self) -> TensorView<'_> {
321        TensorView {
322            protobuf: &self.protobuf,
323        }
324    }
325    /// Tensor name.
326    #[must_use]
327    pub fn name(&self) -> &str {
328        &self.protobuf.name
329    }
330
331    /// Non-negative dimensions converted to `usize`.
332    ///
333    /// Zero denotes an empty tensor, as used by INSwapper for an unused
334    /// optional Resize ROI. A rank-zero shape denotes a scalar.
335    pub fn shape(&self) -> Result<Vec<usize>> {
336        self.protobuf
337            .dims
338            .iter()
339            .map(|&dimension| {
340                usize::try_from(dimension).ok().ok_or_else(|| {
341                    Error::Message(format!("{} has an invalid dimension", self.name()))
342                })
343            })
344            .collect()
345    }
346
347    /// Checked element count.
348    pub fn count(&self) -> Result<usize> {
349        self.shape()?
350            .into_iter()
351            .try_fold(1usize, |count, dimension| {
352                count.checked_mul(dimension).ok_or_else(|| {
353                    Error::Message(format!("{} element count overflow", self.name()))
354                })
355            })
356    }
357
358    /// Decode an embedded float32 tensor.
359    pub fn f32s(&self) -> Result<Vec<f32>> {
360        if self.protobuf.data_type != 1 || self.protobuf.data_location.value() != 0 {
361            return Err(Error::Message(format!(
362                "{} is not embedded float32",
363                self.name()
364            )));
365        }
366        let count = self.count()?;
367        let values = if self.protobuf.raw_data.is_empty() {
368            self.protobuf.float_data.clone()
369        } else {
370            let bytes = count
371                .checked_mul(4)
372                .ok_or_else(|| Error::Message("ONNX tensor byte count overflow".into()))?;
373            if self.protobuf.raw_data.len() != bytes {
374                return Err(Error::Message(format!("{} is truncated", self.name())));
375            }
376            self.protobuf
377                .raw_data
378                .as_chunks::<4>()
379                .0
380                .iter()
381                .map(|bytes| f32::from_le_bytes(*bytes))
382                .collect()
383        };
384        if values.len() != count || values.iter().any(|value| !value.is_finite()) {
385            return Err(Error::Message(format!(
386                "{} has invalid float values",
387                self.name()
388            )));
389        }
390        Ok(values)
391    }
392
393    /// Decode an embedded float32 tensor and widen its values to `f64`.
394    pub fn f64s(&self) -> Result<Vec<f64>> {
395        Ok(self.f32s()?.into_iter().map(f64::from).collect())
396    }
397
398    /// Decode an embedded int64 tensor.
399    pub fn i64s(&self) -> Result<Vec<i64>> {
400        if self.protobuf.data_type != 7 || self.protobuf.data_location.value() != 0 {
401            return Err(Error::Message(format!(
402                "{} is not embedded int64",
403                self.name()
404            )));
405        }
406        let count = self.count()?;
407        let values = if self.protobuf.raw_data.is_empty() {
408            self.protobuf.int64_data.clone()
409        } else {
410            let bytes = count
411                .checked_mul(8)
412                .ok_or_else(|| Error::Message("ONNX tensor byte count overflow".into()))?;
413            if self.protobuf.raw_data.len() != bytes {
414                return Err(Error::Message(format!("{} is truncated", self.name())));
415            }
416            self.protobuf
417                .raw_data
418                .as_chunks::<8>()
419                .0
420                .iter()
421                .map(|bytes| i64::from_le_bytes(*bytes))
422                .collect()
423        };
424        if values.len() != count {
425            return Err(Error::Message(format!("{} is truncated", self.name())));
426        }
427        Ok(values)
428    }
429}
430
431/// A borrowed ONNX tensor, including tensor-valued node attributes.
432#[derive(Clone, Copy)]
433pub struct TensorView<'a> {
434    protobuf: &'a TensorProto,
435}
436
437impl TensorView<'_> {
438    /// Tensor name, which may be empty for an attribute.
439    #[must_use]
440    pub fn name(&self) -> &str {
441        &self.protobuf.name
442    }
443
444    /// Non-negative dimensions converted to `usize`.
445    ///
446    /// Zero denotes an empty tensor, as used by INSwapper for an unused
447    /// optional Resize ROI. A rank-zero shape denotes a scalar.
448    pub fn shape(&self) -> Result<Vec<usize>> {
449        self.protobuf
450            .dims
451            .iter()
452            .map(|&dimension| {
453                usize::try_from(dimension).ok().ok_or_else(|| {
454                    Error::Message(format!("{} has an invalid dimension", self.name()))
455                })
456            })
457            .collect()
458    }
459
460    /// Checked element count.
461    pub fn count(&self) -> Result<usize> {
462        self.shape()?
463            .into_iter()
464            .try_fold(1usize, |count, dimension| {
465                count.checked_mul(dimension).ok_or_else(|| {
466                    Error::Message(format!("{} element count overflow", self.name()))
467                })
468            })
469    }
470
471    /// Decode an embedded float32 tensor.
472    pub fn f32s(&self) -> Result<Vec<f32>> {
473        if self.protobuf.data_type != 1 || self.protobuf.data_location.value() != 0 {
474            return Err(Error::Message(format!(
475                "{} is not embedded float32",
476                self.name()
477            )));
478        }
479        let count = self.count()?;
480        let values = if self.protobuf.raw_data.is_empty() {
481            self.protobuf.float_data.clone()
482        } else {
483            let bytes = count
484                .checked_mul(4)
485                .ok_or_else(|| Error::Message("ONNX tensor byte count overflow".into()))?;
486            if self.protobuf.raw_data.len() != bytes {
487                return Err(Error::Message(format!("{} is truncated", self.name())));
488            }
489            self.protobuf
490                .raw_data
491                .as_chunks::<4>()
492                .0
493                .iter()
494                .map(|bytes| f32::from_le_bytes(*bytes))
495                .collect()
496        };
497        if values.len() != count || values.iter().any(|value| !value.is_finite()) {
498            return Err(Error::Message(format!(
499                "{} has invalid float values",
500                self.name()
501            )));
502        }
503        Ok(values)
504    }
505
506    /// Decode an embedded int64 tensor.
507    pub fn i64s(&self) -> Result<Vec<i64>> {
508        if self.protobuf.data_type != 7 || self.protobuf.data_location.value() != 0 {
509            return Err(Error::Message(format!(
510                "{} is not embedded int64",
511                self.name()
512            )));
513        }
514        let count = self.count()?;
515        let values = if self.protobuf.raw_data.is_empty() {
516            self.protobuf.int64_data.clone()
517        } else {
518            let bytes = count
519                .checked_mul(8)
520                .ok_or_else(|| Error::Message("ONNX tensor byte count overflow".into()))?;
521            if self.protobuf.raw_data.len() != bytes {
522                return Err(Error::Message(format!("{} is truncated", self.name())));
523            }
524            self.protobuf
525                .raw_data
526                .as_chunks::<8>()
527                .0
528                .iter()
529                .map(|bytes| i64::from_le_bytes(*bytes))
530                .collect()
531        };
532        if values.len() != count {
533            return Err(Error::Message(format!("{} is truncated", self.name())));
534        }
535        Ok(values)
536    }
537}
538
539/// Resolve a possibly negative axis against a rank.
540pub fn axis(value: i64, rank: usize) -> Result<usize> {
541    let rank_i64 = i64::try_from(rank).map_err(|_| Error::Message("rank overflow".into()))?;
542    let resolved = if value < 0 { value + rank_i64 } else { value };
543    usize::try_from(resolved)
544        .ok()
545        .filter(|&axis| axis < rank)
546        .ok_or_else(|| Error::Message(format!("axis {value} is invalid for rank {rank}")))
547}
548
549/// Row-major element strides.
550pub fn strides(shape: &[usize]) -> Result<Vec<usize>> {
551    let mut stride = 1usize;
552    let mut output = vec![0; shape.len()];
553    for (index, &dimension) in shape.iter().enumerate().rev() {
554        output[index] = stride;
555        stride = stride
556            .checked_mul(dimension)
557            .ok_or_else(|| Error::Message("shape stride overflow".into()))?;
558    }
559    Ok(output)
560}
561
562/// Standard right-aligned multidirectional broadcast shape.
563pub fn broadcast(left: &[usize], right: &[usize]) -> Result<Vec<usize>> {
564    let rank = left.len().max(right.len());
565    (0..rank)
566        .map(|index| {
567            let from_right = rank - index;
568            let a = left
569                .len()
570                .checked_sub(from_right)
571                .and_then(|position| left.get(position))
572                .copied()
573                .unwrap_or(1);
574            let b = right
575                .len()
576                .checked_sub(from_right)
577                .and_then(|position| right.get(position))
578                .copied()
579                .unwrap_or(1);
580            if a == b || a == 1 || b == 1 {
581                Ok(a.max(b))
582            } else {
583                Err(Error::Message(format!(
584                    "cannot broadcast {left:?} and {right:?}"
585                )))
586            }
587        })
588        .collect()
589}
590
591/// Map a flat broadcast-output index into one input's flat row-major index.
592pub fn broadcast_index(index: usize, output: &[usize], input: &[usize]) -> Result<usize> {
593    if input.len() > output.len() {
594        return Err(Error::Message(
595            "broadcast input rank exceeds output rank".into(),
596        ));
597    }
598    let count = output.iter().try_fold(1usize, |count, dimension| {
599        count
600            .checked_mul(*dimension)
601            .ok_or_else(|| Error::Message("broadcast output size overflow".into()))
602    })?;
603    if index >= count {
604        return Err(Error::Message("broadcast index exceeds output".into()));
605    }
606    let output_strides = strides(output)?;
607    let input_strides = strides(input)?;
608    let offset = output.len() - input.len();
609    let mut result = 0usize;
610    for dimension in 0..input.len() {
611        let output_dimension = output[offset + dimension];
612        let input_dimension = input[dimension];
613        if input_dimension != 1 && input_dimension != output_dimension {
614            return Err(Error::Message("input is not broadcast-compatible".into()));
615        }
616        let coordinate = (index / output_strides[offset + dimension]) % output_dimension;
617        if input_dimension != 1 {
618            result = result
619                .checked_add(
620                    coordinate
621                        .checked_mul(input_strides[dimension])
622                        .ok_or_else(|| Error::Message("broadcast index overflow".into()))?,
623                )
624                .ok_or_else(|| Error::Message("broadcast index overflow".into()))?;
625        }
626    }
627    Ok(result)
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn roundtrip_tensor(tensor: TensorProto) -> Model {
635        let model = ModelProto {
636            graph: Some(GraphProto {
637                initializer: vec![tensor.clone()],
638                node: vec![NodeProto {
639                    op_type: "Constant".into(),
640                    output: vec!["constant".into()],
641                    attribute: vec![AttributeProto {
642                        name: "value".into(),
643                        t: Some(tensor).into(),
644                        ..Default::default()
645                    }],
646                    ..Default::default()
647                }],
648                ..Default::default()
649            })
650            .into(),
651            ..Default::default()
652        };
653        Model::from_bytes(&model.write_to_bytes().unwrap()).unwrap()
654    }
655
656    #[test]
657    fn empty_initializers_and_tensor_attributes_decode() {
658        for dims in [vec![0], vec![2, 0, 3]] {
659            for data_type in [1, 7] {
660                let model = roundtrip_tensor(TensorProto {
661                    name: "onnx::Resize_787".into(),
662                    dims: dims.clone(),
663                    data_type,
664                    ..Default::default()
665                });
666                let tensor = model.initializer("onnx::Resize_787").unwrap();
667                let expected: Vec<usize> = dims.iter().map(|&dim| dim as usize).collect();
668                assert_eq!(tensor.shape().unwrap(), expected);
669                assert_eq!(tensor.count().unwrap(), 0);
670                if data_type == 1 {
671                    assert!(tensor.f32s().unwrap().is_empty());
672                    assert!(tensor.f64s().unwrap().is_empty());
673                } else {
674                    assert!(tensor.i64s().unwrap().is_empty());
675                }
676                for view in [tensor.view(), model.nodes()[0].tensor("value").unwrap()] {
677                    assert_eq!(view.shape().unwrap(), expected);
678                    assert_eq!(view.count().unwrap(), 0);
679                    if data_type == 1 {
680                        assert!(view.f32s().unwrap().is_empty());
681                    } else {
682                        assert!(view.i64s().unwrap().is_empty());
683                    }
684                }
685            }
686        }
687    }
688
689    #[test]
690    fn zero_dimensions_do_not_relax_data_or_negative_dimension_checks() {
691        for dims in [vec![0], vec![-1], vec![0, -1]] {
692            let model = roundtrip_tensor(TensorProto {
693                name: "invalid".into(),
694                dims: dims.clone(),
695                data_type: 1,
696                float_data: vec![1.0],
697                ..Default::default()
698            });
699            let tensor = model.initializer("invalid").unwrap();
700            assert!(tensor.f32s().is_err());
701            assert!(tensor.view().f32s().is_err());
702            if dims.contains(&-1) {
703                assert!(tensor.shape().is_err());
704                assert!(tensor.view().shape().is_err());
705            }
706        }
707    }
708
709    #[test]
710    fn rank_zero_tensors_remain_scalars() {
711        let model = roundtrip_tensor(TensorProto {
712            name: "scalar".into(),
713            data_type: 1,
714            float_data: vec![2.5],
715            ..Default::default()
716        });
717        let tensor = model.initializer("scalar").unwrap();
718        assert!(tensor.shape().unwrap().is_empty());
719        assert_eq!(tensor.count().unwrap(), 1);
720        assert_eq!(tensor.f32s().unwrap(), [2.5]);
721        let view = model.nodes()[0].tensor("value").unwrap();
722        assert!(view.shape().unwrap().is_empty());
723        assert_eq!(view.count().unwrap(), 1);
724        assert_eq!(view.f32s().unwrap(), [2.5]);
725    }
726
727    #[test]
728    fn axes_and_broadcasts_are_checked() {
729        assert_eq!(axis(-1, 4).unwrap(), 3);
730        assert!(axis(4, 4).is_err());
731        assert_eq!(broadcast(&[2, 1, 4], &[3, 4]).unwrap(), [2, 3, 4]);
732        assert!(broadcast(&[2], &[3]).is_err());
733        assert_eq!(broadcast_index(23, &[2, 3, 4], &[3, 1]).unwrap(), 2);
734        assert!(broadcast_index(24, &[2, 3, 4], &[3, 1]).is_err());
735    }
736}