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:

  1. iterator domains (iterate:)

  2. predicate logic (where: and branch-local conditions)

  3. emitted updates and amplitudes (emit blocks)

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

2) General structure of this IR

Read the printed IR as a hierarchy with three nested levels:

  1. Operator block This starts at symbolic.operator ... { and ends at the final }. It declares global metadata such as dtype, Hermitian flag, and Hilbert size.

  2. 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).

  3. 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-local where: 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.

  1. 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.   ; 2 term(s) A summary comment telling you this operator contains exactly two terms.

  3.   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.

  4.     iterate: for (i,) in [(0,), (1,), (2,), ... +1 more] Iterator domain preview for term 0: one label (i) over all four sites.

  5.     where:   true Term-level predicate is unconditional; every iterator row is eligible.

  6.     emit #0: Term 0 has one emission branch.

  7.       update:    identity This branch does not rewrite the state.

  8.       amplitude: x[i] Matrix element for this branch is the occupancy/value at site label i.

  9.   } Closes term 0.

  10.   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.

  11.     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.

  12.     where:   (x[i] > 0) Term-level activation guard: hopping only applies when source site i is occupied/positive.

  13.     emit #0 [tag='hop']: One emission branch for this term, with diagnostic tag hop.

  14.       update:    x'[i] = (x[i] + -1); x'[j] = (x[j] + 1) Rewrite rule for hopping: remove one unit at i, add one unit at j.

  15.       amplitude: 1 Constant matrix element for the hop branch.

  16.   } Closes term 1.

  17. } 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 operator

  • static_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:

  1. Is the iterator domain correct?

  2. Is predicate composition correct?

  3. Are emission updates/amplitudes correct?

That order aligns with the compiler pipeline and usually finds the issue quickly.