This guide distills the guardrails we enforce around onnx_ir._tape.Builder: how to wire values, record initializers, and keep tests green now that the IR pipeline is builder-first.
name= when calling builder.initializer(...) or ctx.builder.add_initializer_from_*. tests/extra_tests/framework/test_no_onnx_in_converter_plugins.py verifies this._outputs must be a list/tuple (or alias that resolves to one); string literals are rejected by tests/extra_tests/framework/test_ir_builder_contracts.py and scripts/check_ir_builder_usage.py.onnx protobuf helpers—per the same policy suite.scripts/check_ir_builder_usage.py before sending patches (it is also wired into the pre-commit stack).ctx.builder (or _tape.Builder) rather than constructing ir.Node manually. Fall back only in function-mode/legacy paths where the builder cannot express the behaviour._stamp_type_and_shape(...) and run _ensure_value_metadata(...) so the ir.Value carries normalized shape/type metadata (no separate value_info bucket).builder.initializer(...) / ctx.bind_const_for_var(...); never smuggle tensors through ad-hoc ir.Value(const_value=...) without keeping the initializer list in sync.construct_and_call(...).with_requested_dtype(...).with_rng_seed(...) to honour the single-use RNG policy and keep tests deterministic across f32/f64 runs.construct_and_call(...) so the test harness can rebuild modules for each dtype. Pair it with with_requested_dtype() and with_rng_seed(...)/with_prng_key(...) helpers instead of inlining lambdas or seeding at import time.callable_factory. The test generator now raises if metadata still relies on factories—callable entries must be concrete construct_and_call(...) results._const_i64) that delegate to ctx.builder so they participate in initializer bookkeeping.construct_and_call(...).with_dtype(...) already handles per-dtype reuse.tests/extra_tests/framework/test_no_onnx_in_converter_plugins.py enforces both the “no protobuf” policy and initializer naming for every builder call.tests/extra_tests/framework/test_ir_builder_contracts.py walks the AST to guarantee _outputs= uses sequence types.scripts/check_ir_builder_usage.py wraps the same heuristics for local iteration and runs as a pre-commit hook. Invoke it manually with poetry run python scripts/check_ir_builder_usage.py when editing converter/plugins code.Everything below expands on the why and how behind those rules.
onnx_ir; install onnx-script or onnx-ir and ensure runtime dependencies (notably numpy) are available.PYTHONPATH=src before importing.import onnx_ir as ir
from onnx_ir._tape import Builder
Stability note:
_tape.Builderis currently internal API (the leading underscore is intentional) and can change. Keep the wrapper that instantiates it confined to XY so updates are easy.
Legacy note: The converter no longer maintains a
builder.value_infolist. Plugins should rely exclusively on_ensure_value_metadata(...)and the fields on eachir.Valuewhen they need shape/type information. Avoid appending to or expecting a globalvalue_inforegistry.
Builder subclasses onnx_ir.tape.Tape. It records nodes, initializers, and the opsets they require while exposing every ONNX operator as a dynamic method (for example, builder.Add, builder.Conv).
Use it when you want to script graph construction but still hand the collected nodes to ir.Graph or ir.Function later. If you need finer-grained control (custom outputs, metadata, overload selection, or pre-existing ir.Value objects), drop down to Tape.op / Tape.op_multi_out or construct ir.Node directly.
import numpy as np
import onnx_ir as ir
from onnx_ir._tape import Builder
# 1. Provide typed graph values up front.
X = ir.val("X", dtype=ir.DataType.FLOAT, shape=[1])
Y = ir.val("Y", dtype=ir.DataType.FLOAT, shape=[1])
# 2. Create a builder (optionally tie it to an existing graph/function).
builder = Builder()
# 3. Register any constant tensors through the builder so outputs stay in sync.
weight_init = builder.initializer(
ir.tensor(np.array([0.25], dtype=np.float32)),
name="weight",
)
# 4. Emit operators. Positional args become inputs; keyword args become ONNX attributes.
scaled = builder.Mul(X, weight_init, _outputs=["scaled"]) # returns ir.Value
summed = builder.Add(scaled, Y, _domain="", _version=18)
# 5. Package the recording into a graph/model when ready.
def to_opset_imports(used_opsets: set[tuple[str, int | None]]):
result: dict[str, int] = {}
for domain, version in used_opsets:
if version is None:
continue # fall back to the containing graph's default
previous = result.get(domain)
if previous is not None and previous != version:
raise ValueError(
f"Mixed opset versions requested for domain '{domain}': {previous} vs {version}"
)
result[domain] = version
return result or {"": 18} # choose an explicit default for the model
graph = ir.Graph(
inputs=[X, Y],
outputs=[summed],
nodes=builder.nodes,
initializers=builder.initializers,
opset_imports=to_opset_imports(builder.used_opsets),
name="scale_and_sum",
)
model = ir.Model(graph=graph, ir_version=10)
The official docs highlight converting onnx.ModelProto to the IR via ir.from_proto or onnx_ir.load. That makes it easy to combine scripted nodes with imported graphs:
import onnx
import onnx_ir as ir
from onnx_ir._tape import Builder
model_proto = onnx.parser.parse_model(MODEL_TEXT)
model = ir.from_proto(model_proto)
builder = Builder(model.graph)
extra = builder.Identity(model.graph.outputs[0])
model.graph.outputs.append(extra)
You can reverse the process with ir.to_proto(model) when you need to serialize back to protobuf.
ir.Node in insertion order via builder.nodes so you can extend a graph or build a new one.builder.initializer aligned with the eventual graph. When the builder is constructed with graph_like=ir.Graph(...), the initializer is immediately registered on that graph.builder.used_opsets as (domain, version) pairs so you can populate Graph.opset_imports consistently.Builder intercepts a few keyword arguments before treating the remainder as ONNX attributes:
_domain: operator domain (default "")._version: opset version for the operator. Use one consistent value per domain._outputs: either an int (number of outputs) or a sequence of output names.
Everything else in **kwargs is fed to _convenience.convert_attributes, which automatically turns Python scalars, sequences, tensors, and graphs into the right ir.Attr instances.
The public documentation for onnx_ir.tape at https://onnx.ai/ir-py/api/ir_tape.html spells out the signatures for Tape.op, Tape.op_multi_out, and Tape.initializer:
Tape.op returns the first output ir.Value and accepts keyword-only arguments such as overload, graph, name, doc_string, metadata_props, and output.Tape.op_multi_out requires either num_outputs or outputs (but not both) and returns an immutable tuple of ir.Value objects.Tape.initializer insists on a name and on the provided tensor having const_value set; ONNX functions intentionally reject initializers.Keep these signatures in mind when deciding between builder convenience and direct tape usage.
values = builder.If(condition, _outputs=["then_out", "else_out"], _version=18)
then_out, else_out = values
ir.Value. Pull out the node again with then_out.producer() if you need to mutate metadata.None in the positional inputs (for example, builder.MaxPool(X, None, strides=[1, 1], _outputs=2)).None) create the attribute yourself: builder.Cast(X, to=ir.Attr("to", ir.AttributeType.INT, 1)).ir.tensor(...) to guarantee dtype/shape correctness.ir.AttrGraph or ir.AttrGraphs.IRBuilder now keeps its inputs, outputs, and nodes in sync with the underlying onnx_ir.Graph via proxy setters. Reassigning builder.inputs = [...] (or .outputs/.nodes) clears and repopulates the graph-side containers, while builder.initializers remains a list-like shim that delegates to graph.initializers. Prefer mutating these sequences in place, but reassignment is safe when you need to reset them.ir.Model or into ONNX graph-typed attributes—clone it first using jax2onnx.converter.ir_clone.clone_graph. The helper copies values, initializers, metadata, and nested graphs so the detached graph can be owned by another model/function without triggering “Value … is already owned by a different graph” errors. Function scopes and control-flow plugins (cond, fori_loop, scan, while_loop) already adopt this pattern; follow suit for any new subgraph emission.graph = ir.Graph(inputs=[X], outputs=[Z], nodes=[])
builder = Builder(graph)
intermediate = builder.Relu(X)
# The node is already appended to `graph`, and names are assigned by the graph's name authority.
graph_like is an ir.Function.Tape.opBecause _make_node forwards the remaining keyword arguments into the attribute map, the builder cannot set certain Tape parameters at construction time:
overload, graph, name, doc_string, metadata_props, and output are interpreted as attributes. Set them on the resulting node (value.producer()) after creation or call Tape.op directly when you need those parameters.builder.graph_like, instantiate another builder or fall back to Tape.op(graph=...).ir.Value outputs, call Tape.op(output=existing_value) or Tape.op_multi_out(outputs=[...]) rather than relying on _outputs.builder.Add(..., name="foo") creates an attribute named name; it does not rename the node. Use summed.producer().name = "foo" after creation instead.node = summed.producer(); node.doc_string = "...")._outputs=["y"]), not a bare string.Tape.initializer raises otherwise.None placeholders to maintain positional semantics.None: build an ir.Attr with an explicit AttributeType; automatic conversion rejects bare None.graph.remove(node)), because each node tracks its owning graph.ctx.builder.add_initializer_from_scalar/array(...) or ctx.builder.const_i64(...) to create constants. Avoid writing directly to graph.initializers[...].GraphInitializers.add(value) overwrites by name. Our builder layer enforces a stricter policy to preserve IR value connections:
ValueError.Constant nodes; graph initializers are not allowed in ONNX Functions.Example
import numpy as np
w1 = builder.add_initializer_from_array("weight", np.array([1.0], dtype=np.float32))
# Re-adding with identical payload reuses the same Value (no-op):
w2 = builder.add_initializer_from_array("weight", np.array([1.0], dtype=np.float32))
assert w1 is w2
# Re-adding with different payload raises:
builder.add_initializer_from_array("weight", np.array([2.0], dtype=np.float32)) # ValueError
Rationale
ir.Value even though the graph.initializers dict now points to a new one. This keeps optimizer passes, cloning, and structural tests stable and predictable.ir.Value instances with types and shapes populated (consider using ir.val for convenience).graph.initializers.add(...).
graph.opset_imports reflects the versions implied by builder.used_opsets.ir.to_proto(model), ONNX checker runs, or onnx_ir.load round-trips if XY integrates them.Keeping these conventions in one place ensures the “builder” layer stays predictable for Codex agents and humans alike, reducing churn when the upstream library evolves.
poetry run python scripts/check_ir_builder_usage.py --diff (lints only staged files; drop --diff to scan the whole tree).poetry run ruff check . followed by poetry run ruff format --check . (or let the pre-commit hooks fix issues automatically).poetry run pytest -q plus any focused suites you touched (for example tests/primitives/test_jnp.py::Test_linspace).poetry run pytest -q tests/extra_tests/framework/test_ir_builder_contracts.py.