Skip to main content

onnx_runtime_loader/
function_inline.rs

1//! Model-local function inlining (ONNX function expansion) at load time.
2//!
3//! An ONNX `ModelProto` may declare reusable subgraphs as `FunctionProto`s in
4//! `ModelProto.functions`. A node whose `(domain, op_type, overload)` matches a
5//! declared function's `(domain, name, overload)` is a *function call*: it is
6//! semantically equivalent to the function body with the call's actual inputs,
7//! outputs, and attributes substituted in.
8//!
9//! Our executor only has kernels for primitive ops, so this module rewrites the
10//! `ModelProto` at the proto level — **before** [`crate::graph_builder`] runs —
11//! so the rest of the pipeline never sees a function call. Because the rewrite
12//! is proto-level, the existing `NodeProto → IR` conversion (attributes,
13//! control-flow subgraphs) is reused unchanged.
14//!
15//! ## Algorithm (standard ONNX function expansion)
16//!
17//! For each function-call node, we splice in a fresh copy of the matched
18//! function body:
19//!
20//! 1. **Value remapping.** Formal `input[i]`/`output[j]` names are mapped to the
21//!    call's actual argument names (positionally). Every *other* value name in
22//!    the body (an intermediate result) is renamed to a globally-fresh unique
23//!    name (`__fn{K}_{orig}`, bumped until unused) so instantiations never
24//!    collide with each other or with pre-existing model names. The empty name
25//!    `""` (ONNX "absent optional") is never remapped. A pass-through output
26//!    whose formal name aliases an input is wired via a boundary `Identity`.
27//!
28//! 2. **Attribute binding.** A body-node attribute with a non-empty
29//!    `ref_attr_name = A` is a reference to the function's formal attribute `A`.
30//!    It is resolved from the call site (the call node's attribute `A`), else the
31//!    function's declared default (`attribute_proto` entry named `A`), else — if
32//!    `A` is a required attribute (`FunctionProto.attribute`) — an error; else
33//!    the attribute is dropped. Literal (non-`ref`) attributes are kept as-is.
34//!
35//! 3. **Recursion + fixpoint.** A function body may call other functions; those
36//!    calls are expanded recursively to a fixpoint. True recursion (a function
37//!    that transitively calls itself) is rejected rather than looped forever.
38//!
39//! 4. **Control-flow subgraphs.** Function calls may appear inside If/Loop/Scan
40//!    subgraph bodies, and function bodies may themselves contain control flow;
41//!    both are handled by recursing into every node's `Graph`/`Graphs`
42//!    attributes. Attribute binding and value remapping are scope-aware: nested
43//!    `ref_attr_name` references are bound at every depth, and a subgraph's own
44//!    locals (inputs, initializers, node outputs) shadow outer captures.
45//!
46//! ## Opset policy
47//!
48//! `FunctionProto.opset_import` domains/versions are merged into the model's
49//! `opset_import`, taking the highest version per domain. Per the ONNX spec the
50//! operator schemas for a shared domain must be compatible across the two opset
51//! lists, so a version difference is not treated as a conflict; a domain the
52//! model does not yet declare is added.
53//!
54//! ## Overload policy
55//!
56//! Matching is exact on the full `(domain, name, overload)` triple, so an
57//! overload set is disambiguated by the node's `overload` field.
58
59use std::borrow::Cow;
60use std::collections::{HashMap, HashSet};
61
62use onnx_runtime_ir::{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}