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}