Reading Symbolic IR¶
This notebook is a deep walkthrough of how to read the symbolic IR emitted by the DSL. The objective is not just to print IR, but to turn IR text into a practical debugging tool.
Because this notebook is executed during docs build, we rely on runtime output from print(ir)
rather than embedding a static pre-copied IR dump.
What the IR tells you¶
The textual IR is the closest compact representation of what the compiler will lower. It exposes three things clearly:
iterator domains (
iterate:)predicate logic (
where:and branch-local conditions)emitted updates and amplitudes (
emitblocks)
If a compiled operator behaves unexpectedly, IR is usually the fastest place to locate the mismatch.
import netket as nk
import nkdsl
hi = nk.hilbert.Fock(n_max=2, N=4)
op = (
nkdsl.SymbolicDiscreteJaxOperator(hi, "ir-demo", hermitian=True)
.for_each_site("i")
.named("diagonal_count")
.emit(nkdsl.identity(), matrix_element=nkdsl.site("i").value)
.for_each_pair("i", "j")
.named("hopping")
.where(nkdsl.site("i") > 0)
.emit(nkdsl.shift("i", -1).shift("j", +1), matrix_element=1.0, tag="hop")
.build()
)
∣NK⟩ Tip: 😍 Love? 😡 Hate? these tips? Let us know at https://forms.gle/ej8eDGFRu2mww5mQ9
1) Print the IR¶
Run the next cell and inspect the raw output first. Do this before looking at helper analyses so your own reading skill improves.
ir = op.to_ir()
print(ir)
symbolic.operator @"ir-demo" [dtype=float64, hermitian=true, hilbert_size=4] {
; 2 term(s)
term #0 "diagonal_count" [kbody, n_iter=4, max_conn_size=4] {
iterate: for (i,) in [(0,), (1,), (2,), ... +1 more]
where: true
emit #0:
update: identity
amplitude: x[i]
}
term #1 "hopping" [kbody, n_iter=16, max_conn_size=16] {
iterate: for (i, j) in [(0, 0), (0, 1), (0, 2), ... +13 more]
where: (x[i] > 0)
emit #0 [tag='hop']:
update: x'[i] = (x[i] + -1); x'[j] = (x[j] + 1)
amplitude: 1
}
}
2) General structure of this IR¶
Read the printed IR as a hierarchy with three nested levels:
Operator block This starts at
symbolic.operator ... {and ends at the final}. It declares global metadata such as dtype, Hermitian flag, and Hilbert size.Term blocks Each
term #k "name" [...] { ... }block is one logical contribution to the operator. The header tells you iterator type, number of iterator rows (n_iter), and a static upper bound for branch count (max_conn_size).Emission blocks inside each term Each
emit #m ...:block describes one branch: a rewrite (update) and its matrix element (amplitude). For conditional branches, an additional branch-localwhere:appears in that block.
Inside each term, read in this order every time:
iterate -> where -> each emit block (update + amplitude).
That order mirrors how the DSL is interpreted and lowered, so it is the fastest way to debug.
3) Line-by-line walkthrough of the printed output above¶
Below is the exact interpretation of each printed line in this notebook run.
symbolic.operator @"ir-demo" [dtype=float64, hermitian=true, hilbert_size=4] {Opens the operator block and declares global metadata: symbolic name, scalar dtype, Hermitian declaration, and Hilbert size.; 2 term(s)A summary comment telling you this operator contains exactly two terms.term #0 "diagonal_count" [kbody, n_iter=4, max_conn_size=4] {Opens term 0. This term iterates over 4 rows and has a static branch bound of 4.iterate: for (i,) in [(0,), (1,), (2,), ... +1 more]Iterator domain preview for term 0: one label (i) over all four sites.where: trueTerm-level predicate is unconditional; every iterator row is eligible.emit #0:Term 0 has one emission branch.update: identityThis branch does not rewrite the state.amplitude: x[i]Matrix element for this branch is the occupancy/value at site labeli.}Closes term 0.term #1 "hopping" [kbody, n_iter=16, max_conn_size=16] {Opens term 1. This term iterates over 16 ordered(i, j)rows for a 4-site system.iterate: for (i, j) in [(0, 0), (0, 1), (0, 2), ... +13 more]Iterator domain preview for term 1: pair labels(i, j)over all ordered pairs.where: (x[i] > 0)Term-level activation guard: hopping only applies when source siteiis occupied/positive.emit #0 [tag='hop']:One emission branch for this term, with diagnostic taghop.update: x'[i] = (x[i] + -1); x'[j] = (x[j] + 1)Rewrite rule for hopping: remove one unit ati, add one unit atj.amplitude: 1Constant matrix element for the hop branch.}Closes term 1.}Closes the full operator block.
Practical reading rule: if behavior is wrong, check lines in this order first: iterate, then where, then each emit (update/amplitude).
4) Structured inspection with as_dict()¶
Text is ideal for humans, while structured payloads are better for tooling and assertions.
Use as_dict() when writing diagnostics, tests, or metadata checks.
payload = ir.as_dict()
print("top-level keys:", sorted(payload.keys()))
print("number of terms:", len(payload["terms"]))
print("first term keys:", sorted(payload["terms"][0].keys()))
print("first term name:", payload["terms"][0]["name"])
top-level keys: ['dtype_str', 'hilbert_size', 'is_hermitian', 'metadata', 'mode', 'operator_name', 'terms']
number of terms: 2
first term keys: ['amplitude', 'branch_tag', 'emissions', 'iterator', 'max_conn_size_hint', 'metadata', 'name', 'predicate', 'update_program']
first term name: diagonal_count
5) Free symbols and static fingerprint¶
Two especially useful IR-level diagnostics:
free_symbols: external symbolic parameters required by this operatorstatic_fingerprint(): structural identity hash for cache keys and change detection
op_with_symbol = (
nkdsl.SymbolicDiscreteJaxOperator(hi, "with-symbol")
.for_each_site("i")
.emit(nkdsl.identity(), matrix_element=nkdsl.symbol("J") * nkdsl.site("i").value)
.build()
)
ir_sym = op_with_symbol.to_ir()
print("free symbols:", sorted(ir_sym.free_symbols))
print("fingerprint:", ir_sym.static_fingerprint()[:24], "...")
free symbols: ['J']
fingerprint: d5c4c317a5b3a888d67c7968 ...
6) Read IR for conditional emissions¶
Conditional emissions introduce branch-local predicates.
Printing this IR helps verify that if / elseif / else logic was lowered as intended.
op_cond = (
nkdsl.SymbolicDiscreteJaxOperator(hi, "cond-ir")
.for_each_site("i")
.emit_if(nkdsl.site("i") == 0, nkdsl.write("i", 1), matrix_element=10.0, tag="if")
.emit_elseif(nkdsl.site("i") == 1, nkdsl.write("i", 2), matrix_element=20.0, tag="elseif")
.emit_else(nkdsl.write("i", 0), matrix_element=30.0, tag="else")
.build()
)
print(op_cond.to_ir())
symbolic.operator @"cond-ir" [dtype=float64, hermitian=false, hilbert_size=4] {
; 1 term(s)
term #0 "0" [kbody, n_iter=4, max_conn_size=12] {
iterate: for (i,) in [(0,), (1,), (2,), ... +1 more]
where: true
emit #0 [tag='if']:
where: (x[i] == 0)
update: x'[i] = 1
amplitude: 10
emit #1 [tag='elseif']:
where: (!(x[i] == 0) && (x[i] == 1))
update: x'[i] = 2
amplitude: 20
emit #2 [tag='else']:
where: (!(x[i] == 0) && !(x[i] == 1))
update: x'[i] = 0
amplitude: 30
}
}
Closing advice¶
Treat IR reading as a first-class engineering skill. In day-to-day work, a fast IR check often resolves issues before you need deep runtime debugging.
If behavior looks wrong, ask in this order:
Is the iterator domain correct?
Is predicate composition correct?
Are emission updates/amplitudes correct?
That order aligns with the compiler pipeline and usually finds the issue quickly.