Skip to main content

onnx_runtime_loader/
function_inline.rs

1//! Model-local function inlining (ONNX function expansion) at load time.
2//!
3//! An ONNX `ModelProto` may declare reusable subgraphs as `FunctionProto`s in
4//! `ModelProto.functions`. A node whose `(domain, op_type, overload)` matches a
5//! declared function's `(domain, name, overload)` is a *function call*: it is
6//! semantically equivalent to the function body with the call's actual inputs,
7//! outputs, and attributes substituted in.
8//!
9//! Our executor only has kernels for primitive ops, so this module rewrites the
10//! `ModelProto` at the proto level — **before** [`crate::graph_builder`] runs —
11//! so the rest of the pipeline never sees a function call. Because the rewrite
12//! is proto-level, the existing `NodeProto → IR` conversion (attributes,
13//! control-flow subgraphs) is reused unchanged.
14//!
15//! ## Algorithm (standard ONNX function expansion)
16//!
17//! For each function-call node, we splice in a fresh copy of the matched
18//! function body:
19//!
20//! 1. **Value remapping.** Formal `input[i]`/`output[j]` names are mapped to the
21//!    call's actual argument names (positionally). Every *other* value name in
22//!    the body (an intermediate result) is renamed to a globally-fresh unique
23//!    name (`__fn{K}_{orig}`, bumped until unused) so instantiations never
24//!    collide with each other or with pre-existing model names. The empty name
25//!    `""` (ONNX "absent optional") is never remapped. A pass-through output
26//!    whose formal name aliases an input is wired via a boundary `Identity`.
27//!
28//! 2. **Attribute binding.** A body-node attribute with a non-empty
29//!    `ref_attr_name = A` is a reference to the function's formal attribute `A`.
30//!    It is resolved from the call site (the call node's attribute `A`), else the
31//!    function's declared default (`attribute_proto` entry named `A`), else — if
32//!    `A` is a required attribute (`FunctionProto.attribute`) — an error; else
33//!    the attribute is dropped. Literal (non-`ref`) attributes are kept as-is.
34//!
35//! 3. **Recursion + fixpoint.** A function body may call other functions; those
36//!    calls are expanded recursively to a fixpoint. True recursion (a function
37//!    that transitively calls itself) is rejected rather than looped forever.
38//!
39//! 4. **Control-flow subgraphs.** Function calls may appear inside If/Loop/Scan
40//!    subgraph bodies, and function bodies may themselves contain control flow;
41//!    both are handled by recursing into every node's `Graph`/`Graphs`
42//!    attributes. Attribute binding and value remapping are scope-aware: nested
43//!    `ref_attr_name` references are bound at every depth, and a subgraph's own
44//!    locals (inputs, initializers, node outputs) shadow outer captures.
45//!
46//! ## Opset policy
47//!
48//! `FunctionProto.opset_import` domains/versions are merged into the model's
49//! `opset_import`, taking the highest version per domain. Per the ONNX spec the
50//! operator schemas for a shared domain must be compatible across the two opset
51//! lists, so a version difference is not treated as a conflict; a domain the
52//! model does not yet declare is added.
53//!
54//! ## Overload policy
55//!
56//! Matching is exact on the full `(domain, name, overload)` triple, so an
57//! overload set is disambiguated by the node's `overload` field.
58
59use std::borrow::Cow;
60use std::collections::{HashMap, HashSet};
61
62use onnx_runtime_ir::is_default_domain;
63
64use crate::LoaderError;
65use crate::proto::onnx::{
66    AttributeProto, FunctionProto, GraphProto, ModelProto, NodeProto, OperatorSetIdProto,
67};
68
69/// Unique identity of a model-local function: `(domain, name, overload)`.
70type FnKey = (String, String, String);
71
72fn fn_key_of_function(f: &FunctionProto) -> FnKey {
73    (f.domain.clone(), f.name.clone(), f.overload.clone())
74}
75
76fn fn_key_of_call(n: &NodeProto) -> FnKey {
77    (n.domain.clone(), n.op_type.clone(), n.overload.clone())
78}
79
80/// Expand every call to a model-local function in `model` into the function's
81/// body, so the returned `ModelProto`'s graph (and all nested subgraphs) contain
82/// only calls to ops the runtime has kernels for.
83///
84/// When `model.functions` is empty this is a no-op and the input is borrowed
85/// back unchanged (`Cow::Borrowed`). Otherwise a rewritten owned `ModelProto`
86/// is returned with `functions` cleared and function opset imports merged in.
87pub fn inline_functions(model: &ModelProto) -> Result<Cow<'_, ModelProto>, LoaderError> {
88    if model.functions.is_empty() {
89        return Ok(Cow::Borrowed(model));
90    }
91
92    let mut funcs: HashMap<FnKey, &FunctionProto> = HashMap::new();
93    for f in &model.functions {
94        funcs.insert(fn_key_of_function(f), f);
95    }
96
97    let graph = model
98        .graph
99        .as_ref()
100        .ok_or_else(|| LoaderError::GraphBuild("ModelProto has no graph".into()))?;
101
102    let mut counter: usize = 0;
103    let mut stack: Vec<FnKey> = Vec::new();
104    // Every value name already in use model-wide, so generated internal names
105    // can be allocated to be globally fresh (BUG 4). Updated as inlining adds
106    // new node outputs.
107    let mut used: HashSet<String> = HashSet::new();
108    collect_used_names(graph, &mut used);
109    // Set when inlining synthesizes any default-domain (`""`/`ai.onnx`) node,
110    // e.g. a boundary `Identity` alias, so we can guarantee the model declares a
111    // default-domain opset import for it (BUG 3 regression).
112    let mut synthesized_default = false;
113    let new_graph = inline_graph(
114        graph,
115        &funcs,
116        &mut counter,
117        &mut stack,
118        &mut used,
119        &mut synthesized_default,
120    )?;
121
122    let mut out = model.clone();
123    out.graph = Some(new_graph);
124    out.opset_import = merged_opset_imports(model);
125    if synthesized_default {
126        ensure_default_opset_import(&mut out.opset_import);
127    }
128    out.functions.clear();
129    Ok(Cow::Owned(out))
130}
131
132/// Conservative default `ai.onnx` opset version used only when inlining
133/// synthesizes a default-domain node but the model (and its functions) declared
134/// no default-domain opset import at all — a valid ONNX model that, e.g., only
135/// called custom-domain functions. Any version ≥ 1 satisfies loader validation.
136const DEFAULT_ONNX_OPSET_VERSION: i64 = 17;
137
138/// Canonical map key for an opset-import domain: every spelling of the default
139/// domain collapses to a single key so duplicates cannot survive merging.
140const DEFAULT_DOMAIN_KEY: &str = "";
141
142fn domain_key(domain: &str) -> String {
143    if is_default_domain(domain) {
144        DEFAULT_DOMAIN_KEY.to_string()
145    } else {
146        domain.to_string()
147    }
148}
149
150/// Ensure `imports` contains a default-domain (`""`/`ai.onnx`) opset entry so a
151/// synthesized default-domain node (e.g. a boundary `Identity`) passes loader
152/// validation. An existing default-domain import (under either spelling) is left
153/// untouched — never downgraded, never duplicated.
154fn ensure_default_opset_import(imports: &mut Vec<OperatorSetIdProto>) {
155    let has_default = imports.iter().any(|o| is_default_domain(&o.domain));
156    if !has_default {
157        imports.push(OperatorSetIdProto {
158            domain: String::new(),
159            version: DEFAULT_ONNX_OPSET_VERSION,
160        });
161    }
162}
163
164/// Merge every function's `opset_import` into the model's, taking the highest
165/// version per domain. Preserves the model's original import ordering, then
166/// appends any domains introduced solely by functions (in first-seen order).
167///
168/// The default domain is canonicalized: `""` and `"ai.onnx"` collapse to a
169/// SINGLE entry at the highest contributed version, so a model importing
170/// `"ai.onnx"` plus a function (or synthesized Identity path) contributing `""`
171/// never yields two logically-duplicate default-domain imports. The emitted
172/// default entry keeps the model's original spelling if it declared one (so we
173/// don't gratuitously rewrite `"ai.onnx"`→`""`); otherwise it is spelled `""`.
174fn merged_opset_imports(model: &ModelProto) -> Vec<OperatorSetIdProto> {
175    let mut order: Vec<String> = Vec::new();
176    let mut best: HashMap<String, i64> = HashMap::new();
177    // Preferred spelling for the emitted default-domain entry: the model's
178    // original spelling if it imported the default domain, else `""`.
179    let mut default_spelling: Option<String> = None;
180    let mut note = |domain: &str, version: i64, from_model: bool| {
181        if from_model && is_default_domain(domain) && default_spelling.is_none() {
182            default_spelling = Some(domain.to_string());
183        }
184        let key = domain_key(domain);
185        match best.entry(key.clone()) {
186            std::collections::hash_map::Entry::Occupied(mut e) => {
187                if version > *e.get() {
188                    *e.get_mut() = version;
189                }
190            }
191            std::collections::hash_map::Entry::Vacant(e) => {
192                order.push(key);
193                e.insert(version);
194            }
195        }
196    };
197    for o in &model.opset_import {
198        note(&o.domain, o.version, true);
199    }
200    for f in &model.functions {
201        for o in &f.opset_import {
202            note(&o.domain, o.version, false);
203        }
204    }
205    order
206        .into_iter()
207        .map(|key| {
208            let version = best[&key];
209            let domain = if key == DEFAULT_DOMAIN_KEY {
210                default_spelling.clone().unwrap_or_default()
211            } else {
212                key
213            };
214            OperatorSetIdProto { domain, version }
215        })
216        .collect()
217}
218
219/// Rewrite `gp` so its node list contains no calls to any declared function.
220/// Regular nodes are kept (with their control-flow subgraphs recursively
221/// inlined); function-call nodes are replaced by their expanded bodies.
222fn inline_graph(
223    gp: &GraphProto,
224    funcs: &HashMap<FnKey, &FunctionProto>,
225    counter: &mut usize,
226    stack: &mut Vec<FnKey>,
227    used: &mut HashSet<String>,
228    synthesized_default: &mut bool,
229) -> Result<GraphProto, LoaderError> {
230    let mut out = gp.clone();
231    out.node = Vec::with_capacity(gp.node.len());
232    for node in &gp.node {
233        expand_node(
234            node,
235            funcs,
236            counter,
237            stack,
238            used,
239            synthesized_default,
240            &mut out.node,
241        )?;
242    }
243    Ok(out)
244}
245
246/// Append the fully-inlined form of `node` to `sink`. If `node` calls a
247/// function, its body (recursively inlined) is appended; otherwise the node is
248/// appended with its subgraph attributes recursively inlined.
249fn expand_node(
250    node: &NodeProto,
251    funcs: &HashMap<FnKey, &FunctionProto>,
252    counter: &mut usize,
253    stack: &mut Vec<FnKey>,
254    used: &mut HashSet<String>,
255    synthesized_default: &mut bool,
256    sink: &mut Vec<NodeProto>,
257) -> Result<(), LoaderError> {
258    if let Some(func) = funcs.get(&fn_key_of_call(node)) {
259        instantiate(
260            node,
261            func,
262            funcs,
263            counter,
264            stack,
265            used,
266            synthesized_default,
267            sink,
268        )?;
269    } else {
270        sink.push(inline_subgraph_attrs(
271            node,
272            funcs,
273            counter,
274            stack,
275            used,
276            synthesized_default,
277        )?);
278    }
279    Ok(())
280}
281
282/// Return a copy of `node` whose `Graph`/`Graphs` attribute bodies have had any
283/// function calls inside them inlined.
284fn inline_subgraph_attrs(
285    node: &NodeProto,
286    funcs: &HashMap<FnKey, &FunctionProto>,
287    counter: &mut usize,
288    stack: &mut Vec<FnKey>,
289    used: &mut HashSet<String>,
290    synthesized_default: &mut bool,
291) -> Result<NodeProto, LoaderError> {
292    let mut out = node.clone();
293    for attr in &mut out.attribute {
294        if let Some(g) = attr.g.as_mut() {
295            *g = inline_graph(g, funcs, counter, stack, used, synthesized_default)?;
296        }
297        for g in &mut attr.graphs {
298            *g = inline_graph(g, funcs, counter, stack, used, synthesized_default)?;
299        }
300    }
301    Ok(out)
302}
303
304/// Expand a single function call: substitute actual arguments and attributes
305/// into a fresh copy of the function body, then recursively inline any calls the
306/// body itself makes. Appends the resulting primitive nodes to `sink`.
307#[allow(clippy::too_many_arguments)]
308fn instantiate(
309    call: &NodeProto,
310    func: &FunctionProto,
311    funcs: &HashMap<FnKey, &FunctionProto>,
312    counter: &mut usize,
313    stack: &mut Vec<FnKey>,
314    used: &mut HashSet<String>,
315    synthesized_default: &mut bool,
316    sink: &mut Vec<NodeProto>,
317) -> Result<(), LoaderError> {
318    let key = fn_key_of_function(func);
319
320    if stack.contains(&key) {
321        let mut chain: Vec<String> = stack.iter().map(fmt_key).collect();
322        chain.push(fmt_key(&key));
323        return Err(LoaderError::RecursiveFunction {
324            function: fmt_key(&key),
325            chain: chain.join(" -> "),
326        });
327    }
328
329    // Arity: passing *more* actuals than the function declares is illegal;
330    // passing fewer is allowed (trailing optionals omitted, mapped to absent).
331    if call.input.len() > func.input.len() {
332        return Err(LoaderError::FunctionArityMismatch {
333            function: fmt_key(&key),
334            node: node_label(call),
335            kind: "input",
336            formal: func.input.len(),
337            actual: call.input.len(),
338        });
339    }
340    if call.output.len() > func.output.len() {
341        return Err(LoaderError::FunctionArityMismatch {
342            function: fmt_key(&key),
343            node: node_label(call),
344            kind: "output",
345            formal: func.output.len(),
346            actual: call.output.len(),
347        });
348    }
349
350    let inst_id = *counter;
351    *counter += 1;
352
353    // The set of formal names actually produced by a body node. A formal output
354    // that is *not* produced is a pass-through of an input (or otherwise-defined
355    // value) and needs a boundary alias rather than a rename (BUG 3).
356    let produced: HashSet<&str> = func
357        .node
358        .iter()
359        .flat_map(|n| n.output.iter())
360        .filter(|o| !o.is_empty())
361        .map(String::as_str)
362        .collect();
363
364    // 1. Value remapping: formals -> actuals, everything else -> globally fresh.
365    let mut rename: HashMap<String, String> = HashMap::new();
366    // Boundary `Identity` aliases (src_actual -> dst_actual) for pass-through
367    // outputs whose name aliases an input/other output (BUG 3).
368    let mut aliases: Vec<(String, String)> = Vec::new();
369
370    for (i, formal) in func.input.iter().enumerate() {
371        if formal.is_empty() {
372            continue;
373        }
374        let actual = call.input.get(i).cloned().unwrap_or_default();
375        rename.insert(formal.clone(), actual);
376    }
377    for (j, formal) in func.output.iter().enumerate() {
378        if formal.is_empty() {
379            continue;
380        }
381        let actual = call.output.get(j).cloned().unwrap_or_default();
382        if produced.contains(formal.as_str()) {
383            // Genuinely produced by the body: consumers read the output actual.
384            rename.insert(formal.clone(), actual);
385        } else if let Some(src) = rename.get(formal) {
386            // Pass-through: the formal is already bound (e.g. it is also an
387            // input, or an earlier output). Keep body references reading the
388            // source, and emit a boundary alias to the output actual.
389            if !actual.is_empty() && src != &actual {
390                aliases.push((src.clone(), actual));
391            }
392        } else {
393            // Output not produced and not otherwise bound: map it directly.
394            rename.insert(formal.clone(), actual);
395        }
396    }
397    // Fresh, globally-unique names for internal (non-formal) body value names.
398    for bn in &func.node {
399        for name in bn.input.iter().chain(bn.output.iter()) {
400            if name.is_empty() || rename.contains_key(name) {
401                continue;
402            }
403            let fresh = fresh_name(name, inst_id, used);
404            rename.insert(name.clone(), fresh);
405        }
406    }
407
408    // 2. Attribute binding + value renaming for each body node.
409    stack.push(key.clone());
410    let result = (|| {
411        let mut instantiated: Vec<NodeProto> = Vec::with_capacity(func.node.len());
412        for (idx, bn) in func.node.iter().enumerate() {
413            let mut nn = bn.clone();
414
415            // Rename node name to a fresh unique one to avoid duplicate-name
416            // collisions between instantiations.
417            nn.name = if bn.name.is_empty() {
418                format!("__fn{inst_id}_n{idx}")
419            } else {
420                format!("__fn{inst_id}_{}", bn.name)
421            };
422
423            // Bind attributes (resolve ref_attr_name against the call site) at
424            // every depth, including nodes inside control-flow subgraphs (BUG 1).
425            bind_node_attributes(&mut nn, call, func, &key)?;
426
427            // Rename value references (inputs/outputs + captured names inside
428            // any control-flow subgraph attributes, scope-aware).
429            rename_value_refs(&mut nn, &rename);
430
431            instantiated.push(nn);
432        }
433
434        // Boundary `Identity` aliases for pass-through outputs (BUG 3). Appended
435        // last so their source values are already produced. `Identity` is a
436        // default-domain op, so record that we synthesized one to guarantee the
437        // model declares a default-domain opset import (BUG 3 regression).
438        for (k, (src, dst)) in aliases.iter().enumerate() {
439            *synthesized_default = true;
440            instantiated.push(NodeProto {
441                op_type: "Identity".to_string(),
442                input: vec![src.clone()],
443                output: vec![dst.clone()],
444                name: format!("__fn{inst_id}_alias{k}"),
445                ..Default::default()
446            });
447        }
448
449        // 3. Recursively inline any function calls the body itself makes.
450        let mut expanded: Vec<NodeProto> = Vec::new();
451        for n in &instantiated {
452            expand_node(
453                n,
454                funcs,
455                counter,
456                stack,
457                used,
458                synthesized_default,
459                &mut expanded,
460            )?;
461        }
462        Ok::<Vec<NodeProto>, LoaderError>(expanded)
463    })();
464    stack.pop();
465
466    sink.extend(result?);
467    Ok(())
468}
469
470/// Bind a body node's attributes for a specific instantiation, recursing into
471/// any control-flow subgraph so that `ref_attr_name` references carried by
472/// nested nodes are resolved against the same call site (BUG 1).
473fn bind_node_attributes(
474    node: &mut NodeProto,
475    call: &NodeProto,
476    func: &FunctionProto,
477    key: &FnKey,
478) -> Result<(), LoaderError> {
479    let mut bound: Vec<AttributeProto> = Vec::with_capacity(node.attribute.len());
480    for attr in &node.attribute {
481        if let Some(mut resolved) = bind_attribute(attr, call, func, key)? {
482            if let Some(g) = resolved.g.as_mut() {
483                for sub in &mut g.node {
484                    bind_node_attributes(sub, call, func, key)?;
485                }
486            }
487            for g in &mut resolved.graphs {
488                for sub in &mut g.node {
489                    bind_node_attributes(sub, call, func, key)?;
490                }
491            }
492            bound.push(resolved);
493        }
494    }
495    node.attribute = bound;
496    Ok(())
497}
498
499/// Resolve a body-node attribute for a specific instantiation.
500///
501/// * Literal attribute (`ref_attr_name` empty): kept unchanged.
502/// * Reference attribute (`ref_attr_name = A`): replaced by the call-site
503///   attribute `A`, else the function's default for `A`, else dropped (if `A` is
504///   optional) or an error (if `A` is required). The emitted attribute keeps the
505///   body attribute's `name` and has `ref_attr_name` cleared.
506///
507/// Returns `Ok(None)` when the attribute should be omitted from the node.
508fn bind_attribute(
509    attr: &AttributeProto,
510    call: &NodeProto,
511    func: &FunctionProto,
512    key: &FnKey,
513) -> Result<Option<AttributeProto>, LoaderError> {
514    if attr.ref_attr_name.is_empty() {
515        return Ok(Some(attr.clone()));
516    }
517    let a = &attr.ref_attr_name;
518
519    // Call-site value wins.
520    if let Some(supplied) = call.attribute.iter().find(|ca| &ca.name == a) {
521        let mut bound = supplied.clone();
522        bound.name = attr.name.clone();
523        bound.ref_attr_name.clear();
524        return Ok(Some(bound));
525    }
526    // Otherwise the function's declared default, if any.
527    if let Some(default) = func.attribute_proto.iter().find(|d| &d.name == a) {
528        let mut bound = default.clone();
529        bound.name = attr.name.clone();
530        bound.ref_attr_name.clear();
531        return Ok(Some(bound));
532    }
533    // No value and no default: an error if the attribute is required, else drop.
534    if func.attribute.iter().any(|req| req == a) {
535        return Err(LoaderError::MissingRequiredFunctionAttribute {
536            function: fmt_key(key),
537            node: node_label(call),
538            attribute: a.clone(),
539        });
540    }
541    Ok(None)
542}
543
544/// Apply `rename` to a node's value references: its inputs, its outputs, and any
545/// value names captured inside its control-flow subgraph attributes. A name of
546/// `""` (absent optional) is left untouched; a name absent from `rename` is left
547/// as-is (subgraph-local names live in their own scope).
548///
549/// The node's own inputs/outputs live in the function-body scope, so they are
550/// remapped directly. Subgraph attributes are remapped scope-aware
551/// ([`rename_subgraph_refs`]).
552fn rename_value_refs(node: &mut NodeProto, rename: &HashMap<String, String>) {
553    for name in node.input.iter_mut().chain(node.output.iter_mut()) {
554        if let Some(new) = rename.get(name.as_str()) {
555            *name = new.clone();
556        }
557    }
558    for attr in &mut node.attribute {
559        if let Some(g) = attr.g.as_mut() {
560            rename_subgraph_refs(g, rename);
561        }
562        for g in &mut attr.graphs {
563            rename_subgraph_refs(g, rename);
564        }
565    }
566}
567
568/// Scope-aware renaming of outer-scope value captures inside a subgraph (BUG 2).
569///
570/// ONNX subgraphs have their own lexical scope. A subgraph's graph inputs,
571/// initializers, and node outputs are *locals* that shadow any outer name, so
572/// they must not be remapped. Only genuine captures of the enclosing scope —
573/// node inputs, and `GraphProto.output` entries that directly name a captured
574/// value — are rewritten to the outer actual. Shadowing is restored on descent
575/// into deeper subgraphs by recomputing the local set at each level.
576fn rename_subgraph_refs(gp: &mut GraphProto, rename: &HashMap<String, String>) {
577    // Names locally bound in this subgraph shadow the outer scope.
578    let mut locals: HashSet<&str> = HashSet::new();
579    for i in &gp.input {
580        if !i.name.is_empty() {
581            locals.insert(i.name.as_str());
582        }
583    }
584    for init in &gp.initializer {
585        if !init.name.is_empty() {
586            locals.insert(init.name.as_str());
587        }
588    }
589    // Sparse initializers are also initializers (schema: GraphProto.
590    // sparse_initializer), hence local bindings that shadow outer names.
591    for sparse in &gp.sparse_initializer {
592        if let Some(values) = &sparse.values
593            && !values.name.is_empty()
594        {
595            locals.insert(values.name.as_str());
596        }
597    }
598    for n in &gp.node {
599        for o in &n.output {
600            if !o.is_empty() {
601                locals.insert(o.as_str());
602            }
603        }
604    }
605
606    // Effective remap for this scope: outer captures minus anything shadowed.
607    let effective: HashMap<String, String> = rename
608        .iter()
609        .filter(|(k, _)| !locals.contains(k.as_str()))
610        .map(|(k, v)| (k.clone(), v.clone()))
611        .collect();
612
613    for n in &mut gp.node {
614        for name in n.input.iter_mut().chain(n.output.iter_mut()) {
615            if let Some(new) = effective.get(name.as_str()) {
616                *name = new.clone();
617            }
618        }
619        // Recurse into deeper subgraphs with this scope's effective map so a
620        // name shadowed here stays shadowed, and is restored on the way out.
621        for attr in &mut n.attribute {
622            if let Some(g) = attr.g.as_mut() {
623                rename_subgraph_refs(g, &effective);
624            }
625            for g in &mut attr.graphs {
626                rename_subgraph_refs(g, &effective);
627            }
628        }
629    }
630
631    // A subgraph output that directly names a captured value must follow it.
632    for o in &mut gp.output {
633        if let Some(new) = effective.get(o.name.as_str()) {
634            o.name = new.clone();
635        }
636    }
637}
638
639/// Collect every value name in use within `gp` (and its nested subgraphs):
640/// graph inputs/outputs, initializers, value_info, and all node inputs/outputs.
641/// Used to allocate globally-fresh generated names (BUG 4).
642fn collect_used_names(gp: &GraphProto, used: &mut HashSet<String>) {
643    for i in &gp.input {
644        if !i.name.is_empty() {
645            used.insert(i.name.clone());
646        }
647    }
648    for o in &gp.output {
649        if !o.name.is_empty() {
650            used.insert(o.name.clone());
651        }
652    }
653    for init in &gp.initializer {
654        if !init.name.is_empty() {
655            used.insert(init.name.clone());
656        }
657    }
658    for sparse in &gp.sparse_initializer {
659        if let Some(values) = &sparse.values
660            && !values.name.is_empty()
661        {
662            used.insert(values.name.clone());
663        }
664    }
665    for vi in &gp.value_info {
666        if !vi.name.is_empty() {
667            used.insert(vi.name.clone());
668        }
669    }
670    for n in &gp.node {
671        for name in n.input.iter().chain(n.output.iter()) {
672            if !name.is_empty() {
673                used.insert(name.clone());
674            }
675        }
676        for attr in &n.attribute {
677            if let Some(g) = &attr.g {
678                collect_used_names(g, used);
679            }
680            for g in &attr.graphs {
681                collect_used_names(g, used);
682            }
683        }
684    }
685}
686
687/// Allocate a generated name for internal body value `base`, guaranteed unique
688/// against every name already in use `used` (BUG 4). The chosen name is added to
689/// `used` so subsequent allocations remain distinct.
690fn fresh_name(base: &str, inst_id: usize, used: &mut HashSet<String>) -> String {
691    let mut candidate = format!("__fn{inst_id}_{base}");
692    let mut suffix = 0usize;
693    while used.contains(&candidate) {
694        suffix += 1;
695        candidate = format!("__fn{inst_id}_{base}__{suffix}");
696    }
697    used.insert(candidate.clone());
698    candidate
699}
700
701fn fmt_key(key: &FnKey) -> String {
702    let (domain, name, overload) = key;
703    let d = if domain.is_empty() { "ai.onnx" } else { domain };
704    if overload.is_empty() {
705        format!("{d}::{name}")
706    } else {
707        format!("{d}::{name}:{overload}")
708    }
709}
710
711fn node_label(n: &NodeProto) -> String {
712    if n.name.is_empty() {
713        format!("<{}::{} (unnamed)>", n.domain, n.op_type)
714    } else {
715        n.name.clone()
716    }
717}