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