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::{Attribute, DataType, Node, NodeId, is_default_domain, normalize_domain};
63
64use crate::LoaderError;
65use crate::proto::onnx::{
66 AttributeProto, FunctionProto, GraphProto, ModelProto, NodeProto, OperatorSetIdProto,
67 ValueInfoProto, attribute_proto, type_proto,
68};
69
70/// Predicate deciding whether a matched function-call node should be **kept as
71/// an op** (a fused kernel claims it) instead of being inlined into its body.
72///
73/// Receives a lightweight claim-view [`Node`] (op type, normalized domain, and
74/// scalar attributes — enough for an EP's `supports_op` claim gate), the
75/// effective `opset` for the node's domain, and the node's positional input
76/// dtypes (resolved from the model's `value_info`/inputs/initializers;
77/// [`DataType::Undefined`] where unknown). Returning `true` keeps the node;
78/// `false` (or an unresolved dtype the caller declines on) inlines it, so the
79/// default behavior is preserved for every op no kernel claims.
80pub type KeepAsOp<'a> = dyn Fn(&Node, u64, &[DataType]) -> bool + 'a;
81
82/// Per-inline claim context: the caller's keep-as-op predicate plus the
83/// proto-level metadata needed to evaluate it (value dtypes and per-domain
84/// opset). Built once, at the top-level graph, and shared by reference.
85struct InlineFilter<'a> {
86 keep_as_op: &'a KeepAsOp<'a>,
87 value_types: HashMap<String, DataType>,
88 opset_of: HashMap<String, u64>,
89}
90
91impl InlineFilter<'_> {
92 /// Whether `np` (already known to match a declared function) should be kept
93 /// as an op rather than inlined.
94 fn should_keep(&self, np: &NodeProto) -> bool {
95 let node = claim_view_node(np);
96 let opset = self
97 .opset_of
98 .get(normalize_domain(&np.domain))
99 .copied()
100 .unwrap_or(1);
101 let dtypes: Vec<DataType> = np
102 .input
103 .iter()
104 .map(|name| {
105 self.value_types
106 .get(name)
107 .copied()
108 .unwrap_or(DataType::Undefined)
109 })
110 .collect();
111 (self.keep_as_op)(&node, opset, &dtypes)
112 }
113}
114
115/// Build a claim-view [`Node`] from a proto call node: op type, normalized
116/// domain, and scalar attributes only. Inputs/outputs are left empty because EP
117/// claim gates key on op identity, attributes, and input dtypes — never on the
118/// IR value ids. Tensor/graph-valued attributes are dropped (claim gates never
119/// inspect them), keeping this graph-free.
120fn claim_view_node(np: &NodeProto) -> Node {
121 let mut node = Node::new(NodeId(0), np.op_type.clone(), Vec::new(), Vec::new());
122 node.name = np.name.clone();
123 node.domain = normalize_domain(&np.domain).to_string();
124 for ap in &np.attribute {
125 if let Some(attr) = scalar_attribute(ap) {
126 node.attributes.insert(ap.name.clone(), attr);
127 }
128 }
129 node
130}
131
132/// Convert a scalar/list ONNX attribute to its IR form for claim evaluation.
133/// Returns `None` for tensor/graph attributes (claim gates never read them).
134fn scalar_attribute(ap: &AttributeProto) -> Option<Attribute> {
135 use attribute_proto::AttributeType as AT;
136 match AT::try_from(ap.r#type).unwrap_or(AT::Undefined) {
137 AT::Float => Some(Attribute::Float(ap.f)),
138 AT::Int => Some(Attribute::Int(ap.i)),
139 AT::String => Some(Attribute::String(ap.s.clone())),
140 AT::Floats => Some(Attribute::Floats(ap.floats.clone())),
141 AT::Ints => Some(Attribute::Ints(ap.ints.clone())),
142 AT::Strings => Some(Attribute::Strings(ap.strings.clone())),
143 _ => None,
144 }
145}
146
147/// Elem dtype of a `value_info`/input/output entry, if it is a tensor type.
148fn tensor_elem_type(vi: &ValueInfoProto) -> Option<DataType> {
149 match vi.r#type.as_ref()?.value.as_ref()? {
150 type_proto::Value::TensorType(t) => DataType::from_onnx(t.elem_type),
151 _ => None,
152 }
153}
154
155/// Collect a name -> dtype map for a graph from its `value_info`, formal
156/// inputs/outputs, and initializers, so a kept-as-op decision can resolve a
157/// function-call node's input dtypes at proto level.
158fn collect_value_types(graph: &GraphProto) -> HashMap<String, DataType> {
159 let mut map = HashMap::new();
160 for vi in graph
161 .value_info
162 .iter()
163 .chain(&graph.input)
164 .chain(&graph.output)
165 {
166 if let Some(dt) = tensor_elem_type(vi) {
167 map.insert(vi.name.clone(), dt);
168 }
169 }
170 for init in &graph.initializer {
171 if let Some(dt) = DataType::from_onnx(init.data_type) {
172 map.entry(init.name.clone()).or_insert(dt);
173 }
174 }
175 map
176}
177
178/// Unique identity of a model-local function: `(domain, name, overload)`.
179type FnKey = (String, String, String);
180
181fn fn_key_of_function(f: &FunctionProto) -> FnKey {
182 (f.domain.clone(), f.name.clone(), f.overload.clone())
183}
184
185fn fn_key_of_call(n: &NodeProto) -> FnKey {
186 (n.domain.clone(), n.op_type.clone(), n.overload.clone())
187}
188
189/// Expand every call to a model-local function in `model` into the function's
190/// body, so the returned `ModelProto`'s graph (and all nested subgraphs) contain
191/// only calls to ops the runtime has kernels for.
192///
193/// When `model.functions` is empty this is a no-op and the input is borrowed
194/// back unchanged (`Cow::Borrowed`). Otherwise a rewritten owned `ModelProto`
195/// is returned with `functions` cleared and function opset imports merged in.
196pub fn inline_functions(model: &ModelProto) -> Result<Cow<'_, ModelProto>, LoaderError> {
197 inline_functions_impl(model, None)
198}
199
200/// Like [`inline_functions`], but a matched function-call node is **kept as an
201/// op** (not inlined) whenever `keep_as_op` returns `true` for it — the general
202/// "keep-as-op iff a kernel claims it, else inline" policy. `keep_as_op` is
203/// evaluated only on nodes that match a declared function; every other node,
204/// and every function the predicate declines, inlines exactly as
205/// [`inline_functions`] would, so the default path is unchanged.
206pub fn inline_functions_filtered<'a>(
207 model: &'a ModelProto,
208 keep_as_op: &KeepAsOp<'_>,
209) -> Result<Cow<'a, ModelProto>, LoaderError> {
210 inline_functions_impl(model, Some(keep_as_op))
211}
212
213fn inline_functions_impl<'a>(
214 model: &'a ModelProto,
215 keep_as_op: Option<&KeepAsOp<'_>>,
216) -> Result<Cow<'a, ModelProto>, LoaderError> {
217 if model.functions.is_empty() {
218 return Ok(Cow::Borrowed(model));
219 }
220
221 let mut funcs: HashMap<FnKey, &FunctionProto> = HashMap::new();
222 for f in &model.functions {
223 funcs.insert(fn_key_of_function(f), f);
224 }
225
226 let graph = model
227 .graph
228 .as_ref()
229 .ok_or_else(|| LoaderError::GraphBuild("ModelProto has no graph".into()))?;
230
231 // Claim context (value dtypes + per-domain opset) for the keep-as-op
232 // predicate, built once from the (merged) top-level metadata. `None` when no
233 // predicate is supplied, so the fast path allocates nothing extra.
234 let filter = keep_as_op.map(|keep| {
235 let mut opset_of: HashMap<String, u64> = HashMap::new();
236 for o in merged_opset_imports(model) {
237 if o.version > 0 {
238 opset_of.insert(normalize_domain(&o.domain).to_string(), o.version as u64);
239 }
240 }
241 InlineFilter {
242 keep_as_op: keep,
243 value_types: collect_value_types(graph),
244 opset_of,
245 }
246 });
247
248 let mut counter: usize = 0;
249 let mut stack: Vec<FnKey> = Vec::new();
250 // Every value name already in use model-wide, so generated internal names
251 // can be allocated to be globally fresh (BUG 4). Updated as inlining adds
252 // new node outputs.
253 let mut used: HashSet<String> = HashSet::new();
254 collect_used_names(graph, &mut used);
255 // Set when inlining synthesizes any default-domain (`""`/`ai.onnx`) node,
256 // e.g. a boundary `Identity` alias, so we can guarantee the model declares a
257 // default-domain opset import for it (BUG 3 regression).
258 let mut synthesized_default = false;
259 let new_graph = inline_graph(
260 graph,
261 &funcs,
262 &mut counter,
263 &mut stack,
264 &mut used,
265 &mut synthesized_default,
266 filter.as_ref(),
267 )?;
268
269 let mut out = model.clone();
270 out.graph = Some(new_graph);
271 out.opset_import = merged_opset_imports(model);
272 if synthesized_default {
273 ensure_default_opset_import(&mut out.opset_import);
274 }
275 out.functions.clear();
276 Ok(Cow::Owned(out))
277}
278
279/// Conservative default `ai.onnx` opset version used only when inlining
280/// synthesizes a default-domain node but the model (and its functions) declared
281/// no default-domain opset import at all — a valid ONNX model that, e.g., only
282/// called custom-domain functions. Any version ≥ 1 satisfies loader validation.
283const DEFAULT_ONNX_OPSET_VERSION: i64 = 17;
284
285/// Canonical map key for an opset-import domain: every spelling of the default
286/// domain collapses to a single key so duplicates cannot survive merging.
287const DEFAULT_DOMAIN_KEY: &str = "";
288
289fn domain_key(domain: &str) -> String {
290 if is_default_domain(domain) {
291 DEFAULT_DOMAIN_KEY.to_string()
292 } else {
293 domain.to_string()
294 }
295}
296
297/// Ensure `imports` contains a default-domain (`""`/`ai.onnx`) opset entry so a
298/// synthesized default-domain node (e.g. a boundary `Identity`) passes loader
299/// validation. An existing default-domain import (under either spelling) is left
300/// untouched — never downgraded, never duplicated.
301fn ensure_default_opset_import(imports: &mut Vec<OperatorSetIdProto>) {
302 let has_default = imports.iter().any(|o| is_default_domain(&o.domain));
303 if !has_default {
304 imports.push(OperatorSetIdProto {
305 domain: String::new(),
306 version: DEFAULT_ONNX_OPSET_VERSION,
307 });
308 }
309}
310
311/// Merge every function's `opset_import` into the model's, taking the highest
312/// version per domain. Preserves the model's original import ordering, then
313/// appends any domains introduced solely by functions (in first-seen order).
314///
315/// The default domain is canonicalized: `""` and `"ai.onnx"` collapse to a
316/// SINGLE entry at the highest contributed version, so a model importing
317/// `"ai.onnx"` plus a function (or synthesized Identity path) contributing `""`
318/// never yields two logically-duplicate default-domain imports. The emitted
319/// default entry keeps the model's original spelling if it declared one (so we
320/// don't gratuitously rewrite `"ai.onnx"`→`""`); otherwise it is spelled `""`.
321fn merged_opset_imports(model: &ModelProto) -> Vec<OperatorSetIdProto> {
322 let mut order: Vec<String> = Vec::new();
323 let mut best: HashMap<String, i64> = HashMap::new();
324 // Preferred spelling for the emitted default-domain entry: the model's
325 // original spelling if it imported the default domain, else `""`.
326 let mut default_spelling: Option<String> = None;
327 let mut note = |domain: &str, version: i64, from_model: bool| {
328 if from_model && is_default_domain(domain) && default_spelling.is_none() {
329 default_spelling = Some(domain.to_string());
330 }
331 let key = domain_key(domain);
332 match best.entry(key.clone()) {
333 std::collections::hash_map::Entry::Occupied(mut e) => {
334 if version > *e.get() {
335 *e.get_mut() = version;
336 }
337 }
338 std::collections::hash_map::Entry::Vacant(e) => {
339 order.push(key);
340 e.insert(version);
341 }
342 }
343 };
344 for o in &model.opset_import {
345 note(&o.domain, o.version, true);
346 }
347 for f in &model.functions {
348 for o in &f.opset_import {
349 note(&o.domain, o.version, false);
350 }
351 }
352 order
353 .into_iter()
354 .map(|key| {
355 let version = best[&key];
356 let domain = if key == DEFAULT_DOMAIN_KEY {
357 default_spelling.clone().unwrap_or_default()
358 } else {
359 key
360 };
361 OperatorSetIdProto { domain, version }
362 })
363 .collect()
364}
365
366/// Rewrite `gp` so its node list contains no calls to any declared function.
367/// Regular nodes are kept (with their control-flow subgraphs recursively
368/// inlined); function-call nodes are replaced by their expanded bodies.
369fn inline_graph(
370 gp: &GraphProto,
371 funcs: &HashMap<FnKey, &FunctionProto>,
372 counter: &mut usize,
373 stack: &mut Vec<FnKey>,
374 used: &mut HashSet<String>,
375 synthesized_default: &mut bool,
376 filter: Option<&InlineFilter<'_>>,
377) -> Result<GraphProto, LoaderError> {
378 let mut out = gp.clone();
379 out.node = Vec::with_capacity(gp.node.len());
380 for node in &gp.node {
381 expand_node(
382 node,
383 funcs,
384 counter,
385 stack,
386 used,
387 synthesized_default,
388 filter,
389 &mut out.node,
390 )?;
391 }
392 Ok(out)
393}
394
395/// Append the fully-inlined form of `node` to `sink`. If `node` calls a
396/// function, its body (recursively inlined) is appended — unless `filter` keeps
397/// it as an op, in which case the call node is emitted unchanged (with its
398/// subgraph attributes still recursively inlined). Otherwise the node is
399/// appended with its subgraph attributes recursively inlined.
400#[allow(clippy::too_many_arguments)]
401fn expand_node(
402 node: &NodeProto,
403 funcs: &HashMap<FnKey, &FunctionProto>,
404 counter: &mut usize,
405 stack: &mut Vec<FnKey>,
406 used: &mut HashSet<String>,
407 synthesized_default: &mut bool,
408 filter: Option<&InlineFilter<'_>>,
409 sink: &mut Vec<NodeProto>,
410) -> Result<(), LoaderError> {
411 if let Some(func) = funcs.get(&fn_key_of_call(node)) {
412 // Keep-as-op: a fused kernel claims this call, so leave it as an op node
413 // for the executor to dispatch (still inline any control-flow subgraph
414 // bodies it carries, for generality).
415 if filter.is_some_and(|f| f.should_keep(node)) {
416 sink.push(inline_subgraph_attrs(
417 node,
418 funcs,
419 counter,
420 stack,
421 used,
422 synthesized_default,
423 filter,
424 )?);
425 } else {
426 instantiate(
427 node,
428 func,
429 funcs,
430 counter,
431 stack,
432 used,
433 synthesized_default,
434 filter,
435 sink,
436 )?;
437 }
438 } else {
439 sink.push(inline_subgraph_attrs(
440 node,
441 funcs,
442 counter,
443 stack,
444 used,
445 synthesized_default,
446 filter,
447 )?);
448 }
449 Ok(())
450}
451
452/// Return a copy of `node` whose `Graph`/`Graphs` attribute bodies have had any
453/// function calls inside them inlined.
454fn inline_subgraph_attrs(
455 node: &NodeProto,
456 funcs: &HashMap<FnKey, &FunctionProto>,
457 counter: &mut usize,
458 stack: &mut Vec<FnKey>,
459 used: &mut HashSet<String>,
460 synthesized_default: &mut bool,
461 filter: Option<&InlineFilter<'_>>,
462) -> Result<NodeProto, LoaderError> {
463 let mut out = node.clone();
464 for attr in &mut out.attribute {
465 if let Some(g) = attr.g.as_mut() {
466 *g = inline_graph(g, funcs, counter, stack, used, synthesized_default, filter)?;
467 }
468 for g in &mut attr.graphs {
469 *g = inline_graph(g, funcs, counter, stack, used, synthesized_default, filter)?;
470 }
471 }
472 Ok(out)
473}
474
475/// Expand a single function call: substitute actual arguments and attributes
476/// into a fresh copy of the function body, then recursively inline any calls the
477/// body itself makes. Appends the resulting primitive nodes to `sink`.
478#[allow(clippy::too_many_arguments)]
479fn instantiate(
480 call: &NodeProto,
481 func: &FunctionProto,
482 funcs: &HashMap<FnKey, &FunctionProto>,
483 counter: &mut usize,
484 stack: &mut Vec<FnKey>,
485 used: &mut HashSet<String>,
486 synthesized_default: &mut bool,
487 filter: Option<&InlineFilter<'_>>,
488 sink: &mut Vec<NodeProto>,
489) -> Result<(), LoaderError> {
490 let key = fn_key_of_function(func);
491
492 if stack.contains(&key) {
493 let mut chain: Vec<String> = stack.iter().map(fmt_key).collect();
494 chain.push(fmt_key(&key));
495 return Err(LoaderError::RecursiveFunction {
496 function: fmt_key(&key),
497 chain: chain.join(" -> "),
498 });
499 }
500
501 // Arity: passing *more* actuals than the function declares is illegal;
502 // passing fewer is allowed (trailing optionals omitted, mapped to absent).
503 if call.input.len() > func.input.len() {
504 return Err(LoaderError::FunctionArityMismatch {
505 function: fmt_key(&key),
506 node: node_label(call),
507 kind: "input",
508 formal: func.input.len(),
509 actual: call.input.len(),
510 });
511 }
512 if call.output.len() > func.output.len() {
513 return Err(LoaderError::FunctionArityMismatch {
514 function: fmt_key(&key),
515 node: node_label(call),
516 kind: "output",
517 formal: func.output.len(),
518 actual: call.output.len(),
519 });
520 }
521
522 let inst_id = *counter;
523 *counter += 1;
524
525 // The set of formal names actually produced by a body node. A formal output
526 // that is *not* produced is a pass-through of an input (or otherwise-defined
527 // value) and needs a boundary alias rather than a rename (BUG 3).
528 let produced: HashSet<&str> = func
529 .node
530 .iter()
531 .flat_map(|n| n.output.iter())
532 .filter(|o| !o.is_empty())
533 .map(String::as_str)
534 .collect();
535
536 // 1. Value remapping: formals -> actuals, everything else -> globally fresh.
537 let mut rename: HashMap<String, String> = HashMap::new();
538 // Boundary `Identity` aliases (src_actual -> dst_actual) for pass-through
539 // outputs whose name aliases an input/other output (BUG 3).
540 let mut aliases: Vec<(String, String)> = Vec::new();
541
542 for (i, formal) in func.input.iter().enumerate() {
543 if formal.is_empty() {
544 continue;
545 }
546 let actual = call.input.get(i).cloned().unwrap_or_default();
547 rename.insert(formal.clone(), actual);
548 }
549 for (j, formal) in func.output.iter().enumerate() {
550 if formal.is_empty() {
551 continue;
552 }
553 let actual = call.output.get(j).cloned().unwrap_or_default();
554 if produced.contains(formal.as_str()) {
555 // Genuinely produced by the body: consumers read the output actual.
556 rename.insert(formal.clone(), actual);
557 } else if let Some(src) = rename.get(formal) {
558 // Pass-through: the formal is already bound (e.g. it is also an
559 // input, or an earlier output). Keep body references reading the
560 // source, and emit a boundary alias to the output actual.
561 if !actual.is_empty() && src != &actual {
562 aliases.push((src.clone(), actual));
563 }
564 } else {
565 // Output not produced and not otherwise bound: map it directly.
566 rename.insert(formal.clone(), actual);
567 }
568 }
569 // Fresh, globally-unique names for internal (non-formal) body value names.
570 for bn in &func.node {
571 for name in bn.input.iter().chain(bn.output.iter()) {
572 if name.is_empty() || rename.contains_key(name) {
573 continue;
574 }
575 let fresh = fresh_name(name, inst_id, used);
576 rename.insert(name.clone(), fresh);
577 }
578 }
579
580 // 2. Attribute binding + value renaming for each body node.
581 stack.push(key.clone());
582 let result = (|| {
583 let mut instantiated: Vec<NodeProto> = Vec::with_capacity(func.node.len());
584 for (idx, bn) in func.node.iter().enumerate() {
585 let mut nn = bn.clone();
586
587 // Rename node name to a fresh unique one to avoid duplicate-name
588 // collisions between instantiations.
589 nn.name = if bn.name.is_empty() {
590 format!("__fn{inst_id}_n{idx}")
591 } else {
592 format!("__fn{inst_id}_{}", bn.name)
593 };
594
595 // Bind attributes (resolve ref_attr_name against the call site) at
596 // every depth, including nodes inside control-flow subgraphs (BUG 1).
597 bind_node_attributes(&mut nn, call, func, &key)?;
598
599 // Rename value references (inputs/outputs + captured names inside
600 // any control-flow subgraph attributes, scope-aware).
601 rename_value_refs(&mut nn, &rename);
602
603 instantiated.push(nn);
604 }
605
606 // Boundary `Identity` aliases for pass-through outputs (BUG 3). Appended
607 // last so their source values are already produced. `Identity` is a
608 // default-domain op, so record that we synthesized one to guarantee the
609 // model declares a default-domain opset import (BUG 3 regression).
610 for (k, (src, dst)) in aliases.iter().enumerate() {
611 *synthesized_default = true;
612 instantiated.push(NodeProto {
613 op_type: "Identity".to_string(),
614 input: vec![src.clone()],
615 output: vec![dst.clone()],
616 name: format!("__fn{inst_id}_alias{k}"),
617 ..Default::default()
618 });
619 }
620
621 // 3. Recursively inline any function calls the body itself makes.
622 let mut expanded: Vec<NodeProto> = Vec::new();
623 for n in &instantiated {
624 expand_node(
625 n,
626 funcs,
627 counter,
628 stack,
629 used,
630 synthesized_default,
631 filter,
632 &mut expanded,
633 )?;
634 }
635 Ok::<Vec<NodeProto>, LoaderError>(expanded)
636 })();
637 stack.pop();
638
639 sink.extend(result?);
640 Ok(())
641}
642
643/// Bind a body node's attributes for a specific instantiation, recursing into
644/// any control-flow subgraph so that `ref_attr_name` references carried by
645/// nested nodes are resolved against the same call site (BUG 1).
646fn bind_node_attributes(
647 node: &mut NodeProto,
648 call: &NodeProto,
649 func: &FunctionProto,
650 key: &FnKey,
651) -> Result<(), LoaderError> {
652 let mut bound: Vec<AttributeProto> = Vec::with_capacity(node.attribute.len());
653 for attr in &node.attribute {
654 if let Some(mut resolved) = bind_attribute(attr, call, func, key)? {
655 if let Some(g) = resolved.g.as_mut() {
656 for sub in &mut g.node {
657 bind_node_attributes(sub, call, func, key)?;
658 }
659 }
660 for g in &mut resolved.graphs {
661 for sub in &mut g.node {
662 bind_node_attributes(sub, call, func, key)?;
663 }
664 }
665 bound.push(resolved);
666 }
667 }
668 node.attribute = bound;
669 Ok(())
670}
671
672/// Resolve a body-node attribute for a specific instantiation.
673///
674/// * Literal attribute (`ref_attr_name` empty): kept unchanged.
675/// * Reference attribute (`ref_attr_name = A`): replaced by the call-site
676/// attribute `A`, else the function's default for `A`, else dropped (if `A` is
677/// optional) or an error (if `A` is required). The emitted attribute keeps the
678/// body attribute's `name` and has `ref_attr_name` cleared.
679///
680/// Returns `Ok(None)` when the attribute should be omitted from the node.
681fn bind_attribute(
682 attr: &AttributeProto,
683 call: &NodeProto,
684 func: &FunctionProto,
685 key: &FnKey,
686) -> Result<Option<AttributeProto>, LoaderError> {
687 if attr.ref_attr_name.is_empty() {
688 return Ok(Some(attr.clone()));
689 }
690 let a = &attr.ref_attr_name;
691
692 // Call-site value wins.
693 if let Some(supplied) = call.attribute.iter().find(|ca| &ca.name == a) {
694 let mut bound = supplied.clone();
695 bound.name = attr.name.clone();
696 bound.ref_attr_name.clear();
697 return Ok(Some(bound));
698 }
699 // Otherwise the function's declared default, if any.
700 if let Some(default) = func.attribute_proto.iter().find(|d| &d.name == a) {
701 let mut bound = default.clone();
702 bound.name = attr.name.clone();
703 bound.ref_attr_name.clear();
704 return Ok(Some(bound));
705 }
706 // No value and no default: an error if the attribute is required, else drop.
707 if func.attribute.iter().any(|req| req == a) {
708 return Err(LoaderError::MissingRequiredFunctionAttribute {
709 function: fmt_key(key),
710 node: node_label(call),
711 attribute: a.clone(),
712 });
713 }
714 Ok(None)
715}
716
717/// Apply `rename` to a node's value references: its inputs, its outputs, and any
718/// value names captured inside its control-flow subgraph attributes. A name of
719/// `""` (absent optional) is left untouched; a name absent from `rename` is left
720/// as-is (subgraph-local names live in their own scope).
721///
722/// The node's own inputs/outputs live in the function-body scope, so they are
723/// remapped directly. Subgraph attributes are remapped scope-aware
724/// ([`rename_subgraph_refs`]).
725fn rename_value_refs(node: &mut NodeProto, rename: &HashMap<String, String>) {
726 for name in node.input.iter_mut().chain(node.output.iter_mut()) {
727 if let Some(new) = rename.get(name.as_str()) {
728 *name = new.clone();
729 }
730 }
731 for attr in &mut node.attribute {
732 if let Some(g) = attr.g.as_mut() {
733 rename_subgraph_refs(g, rename);
734 }
735 for g in &mut attr.graphs {
736 rename_subgraph_refs(g, rename);
737 }
738 }
739}
740
741/// Scope-aware renaming of outer-scope value captures inside a subgraph (BUG 2).
742///
743/// ONNX subgraphs have their own lexical scope. A subgraph's graph inputs,
744/// initializers, and node outputs are *locals* that shadow any outer name, so
745/// they must not be remapped. Only genuine captures of the enclosing scope —
746/// node inputs, and `GraphProto.output` entries that directly name a captured
747/// value — are rewritten to the outer actual. Shadowing is restored on descent
748/// into deeper subgraphs by recomputing the local set at each level.
749fn rename_subgraph_refs(gp: &mut GraphProto, rename: &HashMap<String, String>) {
750 // Names locally bound in this subgraph shadow the outer scope.
751 let mut locals: HashSet<&str> = HashSet::new();
752 for i in &gp.input {
753 if !i.name.is_empty() {
754 locals.insert(i.name.as_str());
755 }
756 }
757 for init in &gp.initializer {
758 if !init.name.is_empty() {
759 locals.insert(init.name.as_str());
760 }
761 }
762 // Sparse initializers are also initializers (schema: GraphProto.
763 // sparse_initializer), hence local bindings that shadow outer names.
764 for sparse in &gp.sparse_initializer {
765 if let Some(values) = &sparse.values
766 && !values.name.is_empty()
767 {
768 locals.insert(values.name.as_str());
769 }
770 }
771 for n in &gp.node {
772 for o in &n.output {
773 if !o.is_empty() {
774 locals.insert(o.as_str());
775 }
776 }
777 }
778
779 // Effective remap for this scope: outer captures minus anything shadowed.
780 let effective: HashMap<String, String> = rename
781 .iter()
782 .filter(|(k, _)| !locals.contains(k.as_str()))
783 .map(|(k, v)| (k.clone(), v.clone()))
784 .collect();
785
786 for n in &mut gp.node {
787 for name in n.input.iter_mut().chain(n.output.iter_mut()) {
788 if let Some(new) = effective.get(name.as_str()) {
789 *name = new.clone();
790 }
791 }
792 // Recurse into deeper subgraphs with this scope's effective map so a
793 // name shadowed here stays shadowed, and is restored on the way out.
794 for attr in &mut n.attribute {
795 if let Some(g) = attr.g.as_mut() {
796 rename_subgraph_refs(g, &effective);
797 }
798 for g in &mut attr.graphs {
799 rename_subgraph_refs(g, &effective);
800 }
801 }
802 }
803
804 // A subgraph output that directly names a captured value must follow it.
805 for o in &mut gp.output {
806 if let Some(new) = effective.get(o.name.as_str()) {
807 o.name = new.clone();
808 }
809 }
810}
811
812/// Collect every value name in use within `gp` (and its nested subgraphs):
813/// graph inputs/outputs, initializers, value_info, and all node inputs/outputs.
814/// Used to allocate globally-fresh generated names (BUG 4).
815fn collect_used_names(gp: &GraphProto, used: &mut HashSet<String>) {
816 for i in &gp.input {
817 if !i.name.is_empty() {
818 used.insert(i.name.clone());
819 }
820 }
821 for o in &gp.output {
822 if !o.name.is_empty() {
823 used.insert(o.name.clone());
824 }
825 }
826 for init in &gp.initializer {
827 if !init.name.is_empty() {
828 used.insert(init.name.clone());
829 }
830 }
831 for sparse in &gp.sparse_initializer {
832 if let Some(values) = &sparse.values
833 && !values.name.is_empty()
834 {
835 used.insert(values.name.clone());
836 }
837 }
838 for vi in &gp.value_info {
839 if !vi.name.is_empty() {
840 used.insert(vi.name.clone());
841 }
842 }
843 for n in &gp.node {
844 for name in n.input.iter().chain(n.output.iter()) {
845 if !name.is_empty() {
846 used.insert(name.clone());
847 }
848 }
849 for attr in &n.attribute {
850 if let Some(g) = &attr.g {
851 collect_used_names(g, used);
852 }
853 for g in &attr.graphs {
854 collect_used_names(g, used);
855 }
856 }
857 }
858}
859
860/// Allocate a generated name for internal body value `base`, guaranteed unique
861/// against every name already in use `used` (BUG 4). The chosen name is added to
862/// `used` so subsequent allocations remain distinct.
863fn fresh_name(base: &str, inst_id: usize, used: &mut HashSet<String>) -> String {
864 let mut candidate = format!("__fn{inst_id}_{base}");
865 let mut suffix = 0usize;
866 while used.contains(&candidate) {
867 suffix += 1;
868 candidate = format!("__fn{inst_id}_{base}__{suffix}");
869 }
870 used.insert(candidate.clone());
871 candidate
872}
873
874fn fmt_key(key: &FnKey) -> String {
875 let (domain, name, overload) = key;
876 let d = if domain.is_empty() { "ai.onnx" } else { domain };
877 if overload.is_empty() {
878 format!("{d}::{name}")
879 } else {
880 format!("{d}::{name}:{overload}")
881 }
882}
883
884fn node_label(n: &NodeProto) -> String {
885 if n.name.is_empty() {
886 format!("<{}::{} (unnamed)>", n.domain, n.op_type)
887 } else {
888 n.name.clone()
889 }
890}