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}