Skip to main content

onnx_ir/
proto_conversion.rs

1use std::path::Path;
2use std::str::{FromStr, from_utf8};
3
4use super::graph_state::GraphState;
5use super::ir::{
6    ArgType, Argument, AttributeValue, Attributes, NodeType, RawNode, TensorData, TensorDataExt,
7    TensorType,
8};
9use super::protos::{
10    AttributeProto, NodeProto, TensorProto, ValueInfoProto,
11    attribute_proto::AttributeType,
12    tensor_proto::{DataLocation, DataType as DT},
13    tensor_shape_proto::dimension::Value,
14};
15use crate::external_data::ExternalDataInfo;
16use crate::tensor_store::TensorDataRef;
17
18use burn_tensor::{BoolStore, DType};
19use protobuf::Enum;
20
21/// Default ONNX opset version used when opset information is not available.
22/// This is typically used as a fallback during post-processing.
23/// Note: Opset 16 is recommended for best compatibility with Burn's ONNX support.
24pub const DEFAULT_OPSET_VERSION: usize = 16;
25
26/// Error type for parsing ONNX model
27#[derive(Debug)]
28pub enum ParseError {
29    VariantNotFound(String),
30}
31
32/// Sanitize ONNX names to be valid Rust identifiers in snake_case
33///
34/// This function converts ONNX variable names (which can contain special characters
35/// like ':',  '/', '.', etc.) into valid Rust identifiers by:
36/// 1. Keeping empty strings as-is (they represent optional inputs in ONNX)
37/// 2. Converting to snake_case (lowercase with underscores)
38/// 3. Replacing invalid characters with underscores
39/// 4. Prepending an underscore if the name starts with a digit
40///
41/// Examples:
42/// - "" -> "" (empty strings represent optional inputs)
43/// - "input:0" -> "input_0"
44/// - "jax2tf/model/layer.weight" -> "jax2tf_model_layer_weight"
45/// - "123tensor" -> "_123tensor"
46/// - "onnx__GlobalAveragePool_0" -> "onnx_global_average_pool_0"
47/// - "MyVariable" -> "my_variable"
48pub fn sanitize_name(name: &str) -> String {
49    // Empty strings represent optional inputs in ONNX - keep them as-is
50    if name.is_empty() {
51        return String::new();
52    }
53
54    let mut result = String::with_capacity(name.len() * 2);
55    let mut prev_is_lower = false;
56    let mut prev_is_underscore = false;
57
58    for (i, c) in name.chars().enumerate() {
59        if c == '_' {
60            // Keep existing underscores, but avoid consecutive ones
61            if !prev_is_underscore || i == 0 {
62                result.push('_');
63                prev_is_underscore = true;
64            }
65            prev_is_lower = false;
66        } else if c.is_ascii_alphanumeric() {
67            // Insert underscore before uppercase letters that follow lowercase letters
68            if c.is_ascii_uppercase() && prev_is_lower && !prev_is_underscore {
69                result.push('_');
70            }
71
72            result.push(c.to_ascii_lowercase());
73            prev_is_lower = c.is_ascii_lowercase();
74            prev_is_underscore = false;
75        } else {
76            // Replace invalid characters with underscores, but avoid consecutive underscores
77            if !prev_is_underscore && i > 0 {
78                result.push('_');
79                prev_is_underscore = true;
80            }
81            prev_is_lower = false;
82        }
83    }
84
85    // Remove trailing underscores (but not if the entire string is underscores)
86    while result.ends_with('_') && result.len() > 1 {
87        // Check if removing this underscore would leave us with something
88        let check = result.trim_end_matches('_');
89        if check.is_empty() {
90            // All underscores, keep them
91            break;
92        }
93        result.pop();
94    }
95
96    // Ensure the first character is valid to start an identifier
97    if !result.is_empty() && !result.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
98        result = format!("_{result}");
99    }
100
101    result
102}
103
104/// Convert ONNX protobuf DataType to DType
105pub fn element_type_from_proto(dt_i32: i32) -> Result<DType, String> {
106    match DT::from_i32(dt_i32).ok_or_else(|| format!("unknown dtype {}", dt_i32))? {
107        DT::FLOAT => Ok(DType::F32),
108        DT::DOUBLE => Ok(DType::F64),
109        DT::FLOAT16 => Ok(DType::F16),
110        DT::BFLOAT16 => Ok(DType::BF16),
111        DT::INT64 => Ok(DType::I64),
112        DT::INT32 => Ok(DType::I32),
113        DT::INT16 => Ok(DType::I16),
114        DT::INT8 => Ok(DType::I8),
115        DT::UINT64 => Ok(DType::U64),
116        DT::UINT32 => Ok(DType::U32),
117        DT::UINT16 => Ok(DType::U16),
118        DT::UINT8 => Ok(DType::U8),
119        DT::BOOL => Ok(DType::Bool(BoolStore::Native)),
120        DT::STRING => Err("String tensors not supported".to_string()),
121        other => Err(format!("unsupported dtype {:?}", other)),
122    }
123}
124
125/// Create an Argument and TensorData from an ONNX initializer
126///
127/// Converts ONNX tensor initializers (weights, biases, etc.) into IR types.
128/// Handles various ONNX encoding quirks including scalars and empty tensors.
129///
130/// Returns (Argument with type info, TensorData with actual values)
131pub fn argument_from_initializer(initializer: &TensorProto) -> (Argument, TensorData) {
132    use crate::ir::ValueSource;
133
134    let name = initializer.name.clone();
135
136    // 1) Canonical path first.
137    match TensorData::try_from(initializer.clone()) {
138        Ok(td) => {
139            let arg = if td.shape.is_empty() {
140                // rank-0 (scalar)
141                Argument {
142                    name,
143                    ty: ArgType::ScalarNative(td.elem_type()),
144                    value_source: ValueSource::Constant, // Initializers are constants
145                    value_store: None,
146                }
147            } else {
148                Argument {
149                    name,
150                    ty: ArgType::Tensor(TensorType {
151                        dtype: td.elem_type(),
152                        rank: td.shape.len(),
153                        static_shape: Some(td.shape.iter().map(|&d| Some(d)).collect()),
154                    }),
155                    value_source: ValueSource::Constant, // Initializers are constants
156                    value_store: None,
157                }
158            };
159            (arg, td)
160        }
161        Err(orig_err) => {
162            // 2) Fallback handling for scalars & empty tensors, with precise diagnostics.
163            let dims: Vec<i64> = initializer.dims.clone();
164            if dims.iter().any(|&d| d < 0) {
165                panic!(
166                    "invalid tensor shape (negative dims) for initializer '{}': {:?}",
167                    name, dims
168                );
169            }
170
171            // Element count implied by dims (treat [] as scalar => 1).
172            let dim_elems: usize = if dims.is_empty() {
173                1
174            } else {
175                dims.iter().map(|&d| d as usize).product()
176            };
177
178            // Payload len across typed fields (best-effort).
179            let payload_len = {
180                let i32n = initializer.int32_data.len();
181                let i64n = initializer.int64_data.len();
182                let f32n = initializer.float_data.len();
183                let f64n = initializer.double_data.len();
184                let sn = initializer.string_data.len();
185                let typed = *[i32n, i64n, f32n, f64n, sn].iter().max().unwrap_or(&0);
186                if typed > 0 {
187                    typed
188                } else {
189                    // raw_data fallback: many exporters put single scalars here
190                    if !initializer.raw_data.is_empty() && dim_elems == 1 {
191                        1
192                    } else {
193                        0
194                    }
195                }
196            };
197
198            // 2.a) Accept scalar encodings: [] or [1] with one element.
199            let looks_scalar = dims.is_empty() || (dims.len() == 1 && dims[0] == 1);
200            if looks_scalar && payload_len == 1 {
201                let td = TensorData::try_from(initializer.clone()).unwrap_or_else(|_| {
202                    panic!(
203                        "failed to decode scalar initializer '{}': dims={:?}",
204                        name, dims
205                    )
206                });
207                let arg = Argument {
208                    name,
209                    ty: ArgType::ScalarNative(td.elem_type()),
210                    value_source: ValueSource::Constant, // Initializers are constants
211                    value_store: None,
212                };
213                return (arg, td);
214            }
215
216            // 2.b) Accept EMPTY tensors: dim_elems == 0 with payload_len == 0.
217            if dim_elems == 0 && payload_len == 0 && !dims.is_empty() {
218                // Map ONNX data_type -> DType.
219                // (Covers common types used in initializers; extend as needed.)
220                let dtype = element_type_from_proto(initializer.data_type).unwrap_or_else(|e| {
221                    panic!(
222                        "unsupported empty-tensor data_type={} for '{}': {}",
223                        initializer.data_type, name, e
224                    )
225                });
226
227                // Build empty tensor using burn-tensor
228                let shape_usize: Vec<usize> = dims.iter().map(|&d| d as usize).collect();
229
230                let td = match dtype {
231                    DType::F16 => TensorData::new(Vec::<half::f16>::new(), shape_usize.clone()),
232                    DType::BF16 => TensorData::new(Vec::<half::bf16>::new(), shape_usize.clone()),
233                    DType::F32 => TensorData::new(Vec::<f32>::new(), shape_usize.clone()),
234                    DType::F64 => TensorData::new(Vec::<f64>::new(), shape_usize.clone()),
235                    DType::I8 => TensorData::new(Vec::<i8>::new(), shape_usize.clone()),
236                    DType::I16 => TensorData::new(Vec::<i16>::new(), shape_usize.clone()),
237                    DType::I32 => TensorData::new(Vec::<i32>::new(), shape_usize.clone()),
238                    DType::I64 => TensorData::new(Vec::<i64>::new(), shape_usize.clone()),
239                    DType::U8 => TensorData::new(Vec::<u8>::new(), shape_usize.clone()),
240                    DType::U16 => TensorData::new(Vec::<u16>::new(), shape_usize.clone()),
241                    DType::U32 => TensorData::new(Vec::<u32>::new(), shape_usize.clone()),
242                    DType::U64 => TensorData::new(Vec::<u64>::new(), shape_usize.clone()),
243                    DType::Bool(_) => TensorData::new(Vec::<bool>::new(), shape_usize.clone()),
244                    _ => panic!(
245                        "Unsupported dtype {:?} for empty tensor '{}' (data_type={})",
246                        dtype, name, initializer.data_type
247                    ),
248                };
249
250                let arg = Argument {
251                    name,
252                    ty: ArgType::Tensor(TensorType {
253                        dtype,
254                        rank: shape_usize.len(),
255                        static_shape: Some(shape_usize.iter().map(|&d| Some(d)).collect()),
256                    }),
257                    value_source: ValueSource::Constant, // Initializers are constants
258                    value_store: None,
259                };
260                return (arg, td);
261            }
262
263            // Not scalar, not empty-tensor; fail with context.
264            panic!(
265                "invalid tensor '{}' (dims {:?} => {} elems) with payload {} elems; original error: {:?}",
266                name, dims, dim_elems, payload_len, orig_err
267            );
268        }
269    }
270}
271
272/// Create an Argument and TensorDataRef from an ONNX initializer (zero-copy path)
273///
274/// This is the preferred path for mmap loading - it creates TensorDataRef directly
275/// from the TensorProto without going through TensorData, avoiding unnecessary copies.
276/// The tensor bytes remain as references to the mmap'd buffer until actually accessed.
277///
278/// When `base_path` is provided, external data tensors (data_location == EXTERNAL)
279/// will be resolved relative to that directory and loaded lazily.
280pub fn argument_from_initializer_lazy_with_context(
281    initializer: TensorProto,
282    base_path: Option<&Path>,
283) -> Result<(Argument, TensorDataRef), ParseError> {
284    use crate::ir::ValueSource;
285
286    let name = initializer.name.clone();
287
288    // Try to create TensorDataRef directly (zero-copy for raw_data, lazy for external)
289    let data_ref = tensor_data_ref_from_proto(initializer, base_path)?;
290
291    let arg = if data_ref.shape().is_empty() {
292        // rank-0 (scalar)
293        Argument {
294            name,
295            ty: ArgType::ScalarNative(data_ref.dtype()),
296            value_source: ValueSource::Constant,
297            value_store: None,
298        }
299    } else {
300        Argument {
301            name,
302            ty: ArgType::Tensor(TensorType {
303                dtype: data_ref.dtype(),
304                rank: data_ref.shape().len(),
305                static_shape: Some(data_ref.shape().iter().map(|&d| Some(d)).collect()),
306            }),
307            value_source: ValueSource::Constant,
308            value_store: None,
309        }
310    };
311
312    Ok((arg, data_ref))
313}
314
315/// Convert TensorProto to TensorDataRef for zero-copy mmap support
316///
317/// This stores raw bytes directly without copying, deferring conversion
318/// to TensorData until the data is actually accessed.
319///
320/// Note: This does NOT support external data (data_location == EXTERNAL).
321/// Use `tensor_data_ref_from_proto` with a base_path for external data support.
322impl TryFrom<TensorProto> for TensorDataRef {
323    type Error = ParseError;
324
325    fn try_from(tensor: TensorProto) -> Result<TensorDataRef, Self::Error> {
326        tensor_data_ref_from_proto(tensor, None)
327    }
328}
329
330/// Convert TensorProto to TensorDataRef with optional external data support
331///
332/// This is the main conversion function that handles:
333/// - Embedded data (raw_data or type-specific fields) - zero-copy from mmap
334/// - External data (data_location == EXTERNAL) - lazy loading from external file
335///
336/// # Arguments
337/// * `tensor` - The TensorProto to convert
338/// * `base_path` - Optional base directory for resolving external data paths.
339///   Required when tensor.data_location == EXTERNAL.
340pub fn tensor_data_ref_from_proto(
341    tensor: TensorProto,
342    base_path: Option<&Path>,
343) -> Result<TensorDataRef, ParseError> {
344    let shape = convert_shape(tensor.dims.clone());
345    let elem = element_type_from_proto(tensor.data_type).map_err(ParseError::VariantNotFound)?;
346
347    // Check if this tensor uses external data storage
348    if tensor.data_location.enum_value() == Ok(DataLocation::EXTERNAL) {
349        return create_external_data_ref(&tensor, base_path, shape, elem);
350    }
351
352    // Embedded data path: use raw_data directly when available (zero-copy from mmap)
353    // Note: For Bool, raw bytes are stored as u8 (0 or 1) and will be reinterpreted
354    // as bool during to_tensor_data(). TensorData::as_slice handles this via transmute.
355    if !tensor.raw_data.is_empty() {
356        match elem {
357            DType::F32
358            | DType::F64
359            | DType::F16
360            | DType::BF16
361            | DType::I64
362            | DType::I32
363            | DType::I16
364            | DType::I8
365            | DType::U64
366            | DType::U32
367            | DType::U16
368            | DType::U8
369            | DType::Bool(_) => Ok(TensorDataRef::new(tensor.raw_data, shape, elem)),
370            _ => Err(ParseError::VariantNotFound(format!(
371                "Unsupported dtype {:?}",
372                elem
373            ))),
374        }
375    } else {
376        // Convert typed fields to bytes
377        // Per ONNX spec:
378        // - int32_data: INT32, INT16, INT8, UINT16, UINT8, BOOL, FLOAT16, BFLOAT16
379        // - int64_data: INT64
380        // - uint64_data: UINT32, UINT64
381        // - float_data: FLOAT
382        // - double_data: DOUBLE
383        let raw_bytes = match elem {
384            DType::F32 => vec_to_bytes(&tensor.float_data),
385            DType::F64 => vec_to_bytes(&tensor.double_data),
386            DType::I64 => vec_to_bytes(&tensor.int64_data),
387            DType::I32 => vec_to_bytes(&tensor.int32_data),
388            DType::I16 => {
389                let data: Vec<i16> = tensor.int32_data.iter().map(|&x| x as i16).collect();
390                vec_to_bytes(&data)
391            }
392            DType::I8 => {
393                let data: Vec<i8> = tensor.int32_data.iter().map(|&x| x as i8).collect();
394                vec_to_bytes(&data)
395            }
396            DType::U64 => vec_to_bytes(&tensor.uint64_data),
397            DType::U32 => {
398                let data: Vec<u32> = tensor.uint64_data.iter().map(|&x| x as u32).collect();
399                vec_to_bytes(&data)
400            }
401            DType::U16 => {
402                let data: Vec<u16> = tensor.int32_data.iter().map(|&x| x as u16).collect();
403                vec_to_bytes(&data)
404            }
405            DType::U8 => {
406                let data: Vec<u8> = tensor.int32_data.iter().map(|&x| x as u8).collect();
407                bytes::Bytes::from(data)
408            }
409            DType::Bool(_) => {
410                let data: Vec<u8> = tensor.int32_data.iter().map(|&x| (x != 0) as u8).collect();
411                bytes::Bytes::from(data)
412            }
413            DType::F16 => {
414                // F16 stored as u16 bits in int32_data
415                let data: Vec<u16> = tensor.int32_data.iter().map(|&x| x as u16).collect();
416                vec_to_bytes(&data)
417            }
418            DType::BF16 => {
419                // BF16 stored as u16 bits in int32_data
420                let data: Vec<u16> = tensor.int32_data.iter().map(|&x| x as u16).collect();
421                vec_to_bytes(&data)
422            }
423            _ => {
424                return Err(ParseError::VariantNotFound(format!(
425                    "empty/unsupported payload for {:?}",
426                    elem
427                )));
428            }
429        };
430        Ok(TensorDataRef::new(raw_bytes, shape, elem))
431    }
432}
433
434/// Create a TensorDataRef for externally stored tensor data
435///
436/// Parses the external_data field and creates a lazy reference that will
437/// load from the external file when accessed.
438fn create_external_data_ref(
439    tensor: &TensorProto,
440    base_path: Option<&Path>,
441    shape: Vec<usize>,
442    dtype: DType,
443) -> Result<TensorDataRef, ParseError> {
444    // Parse external_data key-value pairs
445    let entries = tensor
446        .external_data
447        .iter()
448        .map(|e| (e.key.as_str(), e.value.as_str()));
449
450    let external_info = ExternalDataInfo::from_proto_entries(entries).map_err(|e| {
451        ParseError::VariantNotFound(format!(
452            "Failed to parse external_data for tensor '{}': {}",
453            tensor.name, e
454        ))
455    })?;
456
457    // Resolve the external file path
458    let base = base_path.ok_or_else(|| {
459        ParseError::VariantNotFound(format!(
460            "Tensor '{}' uses external data but no base_path provided. \
461             External data requires loading from a file path, not bytes.",
462            tensor.name
463        ))
464    })?;
465
466    let file_path = external_info
467        .resolve_path(base)
468        .map_err(ParseError::VariantNotFound)?;
469
470    // Calculate the length if not specified
471    // When length is not provided, we need to calculate it from shape and dtype
472    let length = external_info.length.unwrap_or_else(|| {
473        let num_elements: usize = if shape.is_empty() {
474            1 // scalar
475        } else {
476            shape.iter().product()
477        };
478        (num_elements * dtype.size()) as u64
479    });
480
481    log::debug!(
482        "Creating external data ref for '{}': file={}, offset={}, length={}",
483        tensor.name,
484        file_path.display(),
485        external_info.offset,
486        length
487    );
488
489    Ok(TensorDataRef::new_external(
490        file_path,
491        external_info.offset,
492        length,
493        shape,
494        dtype,
495    ))
496}
497
498/// Helper to convert a Vec of POD elements to bytes::Bytes
499fn vec_to_bytes<T: bytemuck::Pod>(data: &[T]) -> bytes::Bytes {
500    bytes::Bytes::copy_from_slice(bytemuck::cast_slice(data))
501}
502
503/// Convert TensorProto to TensorData (convenience wrapper)
504///
505/// This goes through TensorDataRef, which means the data is copied
506/// to ensure proper alignment for typed access.
507impl TryFrom<TensorProto> for TensorData {
508    type Error = ParseError;
509
510    fn try_from(tensor: TensorProto) -> Result<TensorData, Self::Error> {
511        let data_ref = TensorDataRef::try_from(tensor)?;
512        Ok(data_ref.to_tensor_data())
513    }
514}
515fn convert_vec_tensor_proto(tensors: Vec<TensorProto>) -> Result<Vec<TensorData>, ParseError> {
516    let mut result = Vec::new();
517    for tensor in tensors {
518        result.push(TensorData::try_from(tensor)?);
519    }
520    Ok(result)
521}
522
523/// Convert a vector of AttributeProto to a HashMap of AttributeValue
524impl TryFrom<AttributeProto> for AttributeValue {
525    type Error = ParseError;
526
527    fn try_from(attr: AttributeProto) -> Result<AttributeValue, Self::Error> {
528        let value = match attr.type_.unwrap() {
529            AttributeType::FLOAT => AttributeValue::Float32(attr.f),
530            AttributeType::INT => AttributeValue::Int64(attr.i),
531            AttributeType::STRING => AttributeValue::String(to_string(attr.s)),
532
533            // warning: tensor can be empty TODO: check if it is empty
534            AttributeType::TENSOR => AttributeValue::Tensor(TensorData::try_from(attr.t.unwrap())?),
535
536            // Graph attributes (used by If, Loop, Scan)
537            AttributeType::GRAPH => {
538                // Note: We can't convert the graph here without the opset version
539                // This conversion will be handled during node processing where we have access to opset
540                // For now, we'll store a placeholder and do the actual conversion in the If processor
541                panic!(
542                    "Graph attributes should be converted during node processing, not during proto conversion"
543                )
544            }
545            AttributeType::FLOATS => AttributeValue::Float32s(attr.floats),
546            AttributeType::INTS => AttributeValue::Int64s(attr.ints),
547            AttributeType::STRINGS => AttributeValue::Strings(to_string_vec(attr.strings)),
548            AttributeType::TENSORS => {
549                AttributeValue::Tensors(convert_vec_tensor_proto(attr.tensors)?)
550            }
551            AttributeType::GRAPHS => {
552                panic!(
553                    "Graphs attributes should be converted during node processing, not during proto conversion"
554                )
555            }
556            // AttributeType::SPARSE_TENSORS => AttributeValue::SparseTensors(attr.sparse_tensors),
557            // AttributeType::SPARSE_TENSOR => AttributeValue::SparseTensor(attr.sparse_tensor),
558            attribute_type => {
559                return Err(ParseError::VariantNotFound(format!("{attribute_type:?}")));
560            }
561        };
562
563        Ok(value)
564    }
565}
566
567/// Convert a vector of AttributeProto to a HashMap of AttributeValue
568/// Skips GRAPH and GRAPHS attributes as they need special handling with opset version
569pub fn convert_vec_attrs_proto(attrs: Vec<AttributeProto>) -> Attributes {
570    let mut result = Attributes::new();
571    for attr in attrs {
572        // Skip GRAPH/GRAPHS attributes - they'll be handled separately with opset version
573        if let Ok(attr_type) = attr.type_.enum_value()
574            && (attr_type == AttributeType::GRAPH || attr_type == AttributeType::GRAPHS)
575        {
576            continue;
577        }
578        result.insert(attr.name.clone(), AttributeValue::try_from(attr).unwrap());
579    }
580    result
581}
582
583pub fn convert_node_proto(node: &NodeProto, graph_data: &GraphState) -> RawNode {
584    let name = sanitize_name(&node.name);
585
586    let inputs = node.input.iter().map(|x| graph_data.init_in(x)).collect();
587
588    let outputs = node
589        .output
590        .iter()
591        .map(|output_name| {
592            // Sanitize the output name for Rust compatibility
593            let mut arg = Argument::from_name(sanitize_name(output_name));
594            // Try to get type from: 1) graph outputs, 2) value_info (intermediate values)
595            // Note: lookups use original ONNX names (unsanitized)
596            if let Some(graph_output_type) = graph_data.get_output_type(output_name) {
597                arg.ty = graph_output_type.clone();
598            } else if let Some(value_info_type) = graph_data.get_value_info_type(output_name) {
599                arg.ty = value_info_type.clone();
600            }
601            arg
602        })
603        .collect();
604
605    let attrs = convert_vec_attrs_proto(node.attribute.clone());
606
607    let node_type = NodeType::from_str(&node.op_type).expect("Unknown node type");
608
609    RawNode {
610        node_type,
611        name,
612        inputs,
613        outputs,
614        attrs,
615    }
616}
617
618fn to_string(bytes: bytes::Bytes) -> String {
619    from_utf8(&bytes).unwrap().to_string()
620}
621
622fn to_string_vec(bytes: Vec<bytes::Bytes>) -> Vec<String> {
623    bytes.into_iter().map(to_string).collect()
624}
625
626fn convert_shape(shape: Vec<i64>) -> Vec<usize> {
627    shape.iter().map(|s| *s as usize).collect()
628}
629
630/// Extract outer-scope references from a GraphProto
631///
632/// Returns a set of names that are referenced within the subgraph but not defined
633/// within it (not in inputs, initializers, or node outputs). These are outer-scope
634/// references that must be resolved from the parent graph.
635///
636/// This also handles nested subgraphs recursively.
637pub fn extract_outer_scope_references(
638    graph_proto: &crate::protos::GraphProto,
639) -> std::collections::HashSet<String> {
640    use std::collections::HashSet;
641
642    // Collect initializer names for lookup
643    let initializer_names: HashSet<String> = graph_proto
644        .initializer
645        .iter()
646        .filter(|i| !i.name.is_empty())
647        .map(|i| sanitize_name(&i.name))
648        .collect();
649
650    // Helper: check if an input is an outer-scope reference.
651    // In ONNX subgraphs, inputs WITHOUT a corresponding initializer are outer-scope references
652    // (they must be provided by the parent graph). Inputs WITH initializers are locally defined.
653    let is_outer_scope_input = |name: &str| -> bool {
654        !name.is_empty() && !initializer_names.contains(&sanitize_name(name))
655    };
656
657    // Collect all names defined within this subgraph
658    let mut defined_names: HashSet<String> = HashSet::new();
659
660    // Inputs with initializers are locally defined (not outer-scope)
661    for input in &graph_proto.input {
662        if !input.name.is_empty() && !is_outer_scope_input(&input.name) {
663            defined_names.insert(sanitize_name(&input.name));
664        }
665    }
666
667    // Initializers are defined within the subgraph
668    for init in &graph_proto.initializer {
669        if !init.name.is_empty() {
670            defined_names.insert(sanitize_name(&init.name));
671        }
672    }
673
674    // Node outputs are defined within the subgraph
675    for node in &graph_proto.node {
676        for output in &node.output {
677            if !output.is_empty() {
678                defined_names.insert(sanitize_name(output));
679            }
680        }
681    }
682
683    // Collect all referenced names
684    let mut referenced_names: HashSet<String> = HashSet::new();
685
686    // Subgraph inputs without initializers are outer-scope references
687    for input in &graph_proto.input {
688        if is_outer_scope_input(&input.name) {
689            referenced_names.insert(sanitize_name(&input.name));
690        }
691    }
692
693    // Node inputs are references
694    for node in &graph_proto.node {
695        for input in &node.input {
696            if !input.is_empty() {
697                referenced_names.insert(sanitize_name(input));
698            }
699        }
700
701        // Recursively extract from nested subgraphs
702        // For Loop/Scan nodes, their body inputs are provided by the loop construct,
703        // so we should NOT include them as outer-scope references for our graph.
704        let is_loop_or_scan = node.op_type == "Loop" || node.op_type == "Scan";
705
706        for attr in &node.attribute {
707            if let Ok(attr_type) = attr.type_.enum_value() {
708                use crate::protos::attribute_proto::AttributeType;
709                match attr_type {
710                    AttributeType::GRAPH => {
711                        if let Some(nested_graph) = attr.g.as_ref() {
712                            // For Loop/Scan body subgraphs, collect body input names.
713                            // These are loop-provided (iteration count, condition, loop-carried vars),
714                            // not outer-scope references.
715                            // Note: "body" is the ONNX-specified attribute name for Loop/Scan subgraphs.
716                            // See: https://onnx.ai/onnx/operators/onnx__Loop.html
717                            //      https://onnx.ai/onnx/operators/onnx__Scan.html
718                            let loop_provided_names: std::collections::HashSet<String> =
719                                if is_loop_or_scan && attr.name == "body" {
720                                    nested_graph
721                                        .input
722                                        .iter()
723                                        .filter(|i| !i.name.is_empty())
724                                        .map(|i| sanitize_name(&i.name))
725                                        .collect()
726                                } else {
727                                    std::collections::HashSet::new()
728                                };
729
730                            // Nested subgraph references can be outer-scope for us too
731                            let nested_refs = extract_outer_scope_references(nested_graph);
732                            for name in nested_refs {
733                                // Skip if it's provided by the loop construct or already defined
734                                if !defined_names.contains(&name)
735                                    && !loop_provided_names.contains(&name)
736                                {
737                                    referenced_names.insert(name);
738                                }
739                            }
740                        }
741                    }
742                    AttributeType::GRAPHS => {
743                        for nested_graph in &attr.graphs {
744                            let nested_refs = extract_outer_scope_references(nested_graph);
745                            for name in nested_refs {
746                                if !defined_names.contains(&name) {
747                                    referenced_names.insert(name);
748                                }
749                            }
750                        }
751                    }
752                    _ => {}
753                }
754            }
755        }
756    }
757
758    // Graph outputs can also reference names
759    for output in &graph_proto.output {
760        if !output.name.is_empty() {
761            referenced_names.insert(sanitize_name(&output.name));
762        }
763    }
764
765    // Outer-scope references = referenced - defined
766    referenced_names
767        .difference(&defined_names)
768        .cloned()
769        .collect()
770}
771
772/// Extract all outer-scope references from all subgraphs in a NodeProto
773///
774/// This scans all GRAPH and GRAPHS attributes and returns all names that are
775/// referenced but not defined within the subgraphs. These need to be added
776/// as implicit inputs to ensure proper topological ordering.
777pub fn extract_node_outer_scope_references(
778    node_proto: &NodeProto,
779) -> std::collections::HashSet<String> {
780    use std::collections::HashSet;
781
782    let mut all_refs: HashSet<String> = HashSet::new();
783
784    for attr in &node_proto.attribute {
785        if let Ok(attr_type) = attr.type_.enum_value() {
786            match attr_type {
787                AttributeType::GRAPH => {
788                    if let Some(graph_proto) = attr.g.as_ref() {
789                        let refs = extract_outer_scope_references(graph_proto);
790                        all_refs.extend(refs);
791                    }
792                }
793                AttributeType::GRAPHS => {
794                    for graph_proto in &attr.graphs {
795                        let refs = extract_outer_scope_references(graph_proto);
796                        all_refs.extend(refs);
797                    }
798                }
799                _ => {}
800            }
801        }
802    }
803
804    all_refs
805}
806
807/// Convert graph attributes from NodeProto for control flow nodes (If, Loop, Scan)
808///
809/// **IMPORTANT**: This function now creates **deferred** graph attributes that store
810/// the raw GraphProto. The actual subgraph building is deferred until type inference,
811/// when all outer-scope references have been resolved.
812///
813/// If parent_registry is provided, it will be used to ensure unique names across nested subgraphs.
814/// Otherwise, a new registry is created for sibling subgraphs.
815///
816/// The `base_path` is inherited from the parent graph for external data resolution.
817pub fn convert_graph_attributes(
818    node_proto: &NodeProto,
819    opset_version: usize,
820    parent_registry: Option<crate::graph_state::NameRegistry>,
821    base_path: Option<&Path>,
822) -> Attributes {
823    use crate::ir::DeferredGraph;
824    use std::sync::Arc;
825
826    let mut result = Attributes::new();
827
828    // Use parent registry if provided, otherwise create a new one for sibling subgraphs
829    // This ensures node names are unique across nested levels and sibling branches
830    let name_registry = parent_registry.unwrap_or_default();
831
832    for attr in &node_proto.attribute {
833        if let Ok(attr_type) = attr.type_.enum_value() {
834            match attr_type {
835                AttributeType::GRAPH => {
836                    if let Some(graph_proto) = attr.g.as_ref() {
837                        // Store as deferred graph - will be built during type inference
838                        let deferred = DeferredGraph {
839                            proto: Arc::new(graph_proto.clone()),
840                            opset_version,
841                            name_registry: Some(name_registry.clone()),
842                            base_path: base_path.map(|p| p.to_path_buf()),
843                        };
844                        result.insert(attr.name.clone(), AttributeValue::DeferredGraph(deferred));
845                    }
846                }
847                AttributeType::GRAPHS => {
848                    let deferred_graphs: Vec<_> = attr
849                        .graphs
850                        .iter()
851                        .map(|graph_proto| DeferredGraph {
852                            proto: Arc::new(graph_proto.clone()),
853                            opset_version,
854                            name_registry: Some(name_registry.clone()),
855                            base_path: base_path.map(|p| p.to_path_buf()),
856                        })
857                        .collect();
858                    result.insert(
859                        attr.name.clone(),
860                        AttributeValue::DeferredGraphs(deferred_graphs),
861                    );
862                }
863                _ => {}
864            }
865        }
866    }
867    result
868}
869
870impl TryFrom<ValueInfoProto> for Argument {
871    type Error = ParseError;
872
873    fn try_from(value: ValueInfoProto) -> Result<Argument, Self::Error> {
874        let name = sanitize_name(&value.name);
875        let proto_type = value
876            .type_
877            .as_ref()
878            .ok_or(ParseError::VariantNotFound("missing type".into()))?;
879
880        if !proto_type.has_tensor_type() {
881            // Return error instead of panicking - this can happen for subgraph inputs
882            // that reference outer scope values without explicit type info
883            return Err(ParseError::VariantNotFound(format!(
884                "Unsupported argument type: no tensor_type in {:?}",
885                proto_type
886            )));
887        }
888
889        let tensor_proto = proto_type.tensor_type();
890        let elem_type =
891            element_type_from_proto(tensor_proto.elem_type).map_err(ParseError::VariantNotFound)?;
892
893        let ty = if tensor_proto.shape.dim.is_empty() {
894            ArgType::ScalarNative(elem_type)
895        } else {
896            let static_shape: Vec<Option<usize>> = tensor_proto
897                .shape
898                .dim
899                .iter()
900                .map(|d| match &d.value {
901                    Some(Value::DimValue(v)) => Some(*v as usize),
902                    _ => None,
903                })
904                .collect();
905            let static_shape = Some(static_shape);
906
907            ArgType::Tensor(TensorType {
908                rank: tensor_proto.shape.dim.len(),
909                dtype: elem_type,
910                static_shape,
911            })
912        };
913
914        Ok(Argument {
915            ty,
916            name,
917            value_source: crate::ir::ValueSource::Dynamic, // Graph inputs/outputs are runtime values
918            value_store: None,
919        })
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn test_sanitize_name_basic() {
929        // Already snake_case
930        assert_eq!(sanitize_name("valid_name"), "valid_name");
931        assert_eq!(sanitize_name("_underscore"), "_underscore");
932        assert_eq!(sanitize_name("a"), "a");
933
934        // Convert to snake_case
935        assert_eq!(sanitize_name("ValidName123"), "valid_name123");
936        assert_eq!(sanitize_name("MyVariable"), "my_variable");
937        assert_eq!(sanitize_name("HTTPResponse"), "httpresponse");
938    }
939
940    #[test]
941    fn test_sanitize_name_special_chars() {
942        // TensorFlow/JAX style names with colons and slashes
943        assert_eq!(sanitize_name("input:0"), "input_0");
944        assert_eq!(sanitize_name("layer/weight"), "layer_weight");
945        assert_eq!(sanitize_name("jax2tf/model:0"), "jax2tf_model_0");
946
947        // ONNX names with dots and dashes
948        assert_eq!(sanitize_name("bert.encoder.layer"), "bert_encoder_layer");
949        assert_eq!(sanitize_name("layer-norm"), "layer_norm");
950
951        // Complex real-world example from GitHub issue #2878
952        assert_eq!(
953            sanitize_name("jax2tf_rhs_/pjit_silu_/Const_2:0"),
954            "jax2tf_rhs_pjit_silu_const_2_0"
955        );
956    }
957
958    #[test]
959    fn test_sanitize_name_camel_to_snake() {
960        // Convert CamelCase and PascalCase to snake_case
961        assert_eq!(
962            sanitize_name("onnx__GlobalAveragePool_0"),
963            "onnx_global_average_pool_0"
964        );
965        assert_eq!(sanitize_name("onnx__Gemm_0"), "onnx_gemm_0");
966        assert_eq!(sanitize_name("onnx__Greater_0"), "onnx_greater_0");
967        assert_eq!(sanitize_name("MyClassName"), "my_class_name");
968        assert_eq!(sanitize_name("HTTPSConnection"), "httpsconnection");
969    }
970
971    #[test]
972    fn test_sanitize_name_starts_with_digit() {
973        assert_eq!(sanitize_name("123tensor"), "_123tensor");
974        assert_eq!(sanitize_name("0input"), "_0input");
975    }
976
977    #[test]
978    fn test_sanitize_name_unicode() {
979        // Unicode characters should be replaced with underscores
980        assert_eq!(sanitize_name("tensor™"), "tensor");
981        assert_eq!(sanitize_name("input€output"), "input_output");
982    }
983
984    #[test]
985    fn test_sanitize_name_empty_and_edge_cases() {
986        // Empty strings represent optional inputs in ONNX - should remain empty
987        assert_eq!(sanitize_name(""), "");
988
989        assert_eq!(sanitize_name("_"), "_");
990
991        // Consecutive underscores are collapsed to single underscore
992        assert_eq!(sanitize_name("___"), "_");
993        assert_eq!(sanitize_name("a__b"), "a_b");
994
995        // All special chars become single underscore
996        assert_eq!(sanitize_name(":/:"), "_");
997
998        // Consecutive special chars become single underscore
999        assert_eq!(sanitize_name("a:::b"), "a_b");
1000
1001        // Trailing underscores removed
1002        assert_eq!(sanitize_name("name_:"), "name");
1003    }
1004}