AutoQASM Variable Capture and Assignment

This guide explains how AutoQASM captures Python variable assignments and transforms them into OpenQASM variable declarations and statements. It is intended for developers who want to understand the internals of AutoQASM and may want to contribute to the library.

How AutoQASM Transforms Python Code

AutoQASM uses a modified version of TensorFlow’s AutoGraph library (called “malt”) to transpile Python source code into OpenQASM. When a user decorates a function with @aq.main or @aq.subroutine, AutoQASM parses the function’s AST (abstract syntax tree), rewrites certain nodes, and then executes the rewritten code. During execution, operator functions intercept assignments, control flow, and other statements to build up an internal representation of the quantum program using the oqpy library.

The transformation pipeline has three layers:

  1. AST transformation — rewrites Python assignment nodes to call operator functions.

  2. Operator functions — decide at runtime how to handle each assignment based on the value’s type and the program’s current state.

  3. oqpy program model — the underlying intermediate representation that accumulates variable declarations and statements and ultimately serializes to OpenQASM 3.0.

AST Transformation of Assignments

The AssignTransformer converter visits assignment nodes in the Python AST and rewrites them so that every assignment flows through the assign_stmt operator function.

Regular assignments

A statement like val = some_expression is rewritten to:

val = ag__.assign_stmt("val", some_expression)

The expression on the right-hand side is evaluated first by Python, producing whatever value it produces. Then assign_stmt is called with the target variable name (as a string) and the evaluated result. This means assign_stmt sees the result of the expression, not the expression AST.

Augmented assignments

Augmented assignments (+=, -=, *=, etc.) are desugared into regular assignments before transformation. For example:

val += expr

becomes:

val = ag__.assign_stmt("val", val + expr)

Without this desugaring, Python would evaluate the augmented assignment natively using __iadd__ (or similar), completely bypassing assign_stmt. This is important because assign_stmt is the only place where certain variable promotion decisions are made.

Return statements

Return statements in @aq.main functions are handled by a separate converter that calls assign_for_output to register the return value as an output parameter in the generated OpenQASM. AutoGraph internally transpiles return x into an assignment to a special retval_ variable:

retval_ = x
return retval_

The assign_stmt function has special handling for the retval_ target name to avoid creating unnecessary intermediate variables.

The assign_stmt Operator

assign_stmt(target_name, value) is the central function that handles all variable assignments during program transpilation. It receives the target variable name and the evaluated value, and decides what to do based on the value’s type and the current program state.

The decision logic follows this order:

  1. Already-declared QASM variable: If the target name already exists in the oqpy program and the value is a QASM type (oqpy.base.Var or oqpy.base.OQPyExpression), the existing variable is reused and a QASM assignment statement is generated.

  2. New QASM variable: If the value is an oqpy.base.Var (for example, from aq.FloatVar(0.5) or measure(q)), a new QASM variable is declared with the target name.

  3. QASM expression: If the value is an oqpy.base.OQPyExpression (but not a Var), this may indicate a reassignment where a previously plain Python variable is now being combined with a QASM expression. The deferred promotion mechanism (described later in this document) handles this case.

  4. Plain Python value: If the value is a plain Python int, float, or bool, it is wrapped in a deferred wrapper and returned. No QASM variable is declared at this point. See the section on deferred promotion below for details.

After resolving the target variable, assign_stmt chooses one of three modes for the actual QASM statement:

  • Direct assignment: a = b; — used when the value references an already-declared variable.

  • Root-scope declaration: int[32] a = 10; — used when the target is new and we are at the top level of the function (not inside a loop or conditional).

  • Auto-declared assignment: a = 10; with an implicit int[32] a; at the top — used when inside a control flow block. oqpy automatically hoists the declaration to the root scope.

The Program Conversion Context

The ProgramConversionContext holds all state for the program currently being transpiled. Key attributes relevant to variable assignment include:

  • oqpy program stack: A stack of oqpy.Program objects, one per scope level. The bottom of the stack is the root (main) scope; nested scopes are pushed for subroutines and other constructs.

  • Deferred value storage: A dictionary mapping variable names to their deferred wrappers. This is populated when assign_stmt encounters a plain Python value and consumed when the value is later promoted.

  • Variable index counter: An incrementing counter used to generate unique names for auto-created variables (e.g., __bit_0__, __int_1__, __float_2__). Deferred wrappers do not consume a counter slot when created — only explicit aq.FloatVar() / aq.IntVar() calls do.

  • Root scope flag: A boolean indicating whether the transpiler is currently at the top level of a function or inside a control flow block (for, while, if). This affects whether variables are declared inline or auto-hoisted.

Type System

AutoQASM maps Python types to oqpy variable types during transpilation:

Python type

oqpy type

OpenQASM type

bool

oqpy.BoolVar

bool

int

oqpy.IntVar

int[32]

float

oqpy.FloatVar

float[64]

The wrap_value function converts Python values to their oqpy equivalents. It is implemented as a singledispatch function, so it can be extended for additional types. Values that are already oqpy types (Var or OQPyExpression) are returned unchanged.

The map_parameter_type function maps Python types to oqpy variable classes (not instances). It is used when determining what type of QASM variable to create for a given Python value.

Integration with oqpy

AutoQASM builds on the oqpy library for OpenQASM code generation. Understanding a few oqpy concepts is important for working with the assignment pipeline.

Variable registration

oqpy tracks variables in two dictionaries on its Program object:

  • declared_vars: Variables that have been explicitly declared (e.g., int[32] a = 10;).

  • undeclared_vars: Variables that have been referenced in expressions but not yet declared. oqpy auto-declares these at the top of the program when serializing to OpenQASM.

The _add_var method registers a variable. It uses object identity to detect conflicts: if a variable with the same name already exists and is a different Python object, oqpy raises a RuntimeError. This means you must reuse the same Var object throughout the program — creating a second FloatVar(name="val") and using it alongside the first will cause a conflict.

Expression AST generation

When oqpy converts an expression to its AST representation (via to_ast), it walks the expression tree. Each Var node calls _add_var(self) to register itself with the program. This is why object identity matters — if a Var in an expression tree is a different object from the one already registered, oqpy raises a conflict error.

Root-scope declarations

When a deferred variable is promoted inside a loop body, the declaration must appear at the root scope of the program (before the loop), not inside the loop body. AutoQASM achieves this by appending the declaration statement directly to oqpy_program.stack[0].body (the root scope’s statement list) and marking the variable as declared via _mark_var_declared.

Return Value Handling

Return values from @aq.main functions are handled by a dedicated code path:

  1. The return converter rewrites return x to call return_output_from_main (which registers the output parameter) followed by assign_for_output (which generates the QASM assignment).

  2. assign_for_output unwraps any deferred wrappers back to their raw Python values before processing, so the return path behaves identically regardless of whether the value was deferred.

  3. For subroutine returns, assign_stmt handles the special retval_ variable. If the return value is an already-declared variable, it is returned directly without creating an intermediate retval_ variable.

Deferred Promotion of Plain Python Values

When a variable is initialized with a plain Python value (e.g., val = 0.5) and later updated with a QASM expression inside a loop (e.g., val = val + measure(q)), AutoQASM needs to promote the Python value to a declared QASM variable. However, not all plain Python values need promotion — a value used only as a gate parameter (e.g., rx(0, val)) should remain a literal in the generated QASM.

AutoQASM handles this with deferred wrapper classes that subclass Python’s built-in numeric types. These wrappers behave identically to plain numeric values in most contexts but can lazily promote themselves to oqpy variables when they participate in arithmetic with QASM expressions.

Deferred wrapper classes

  • DeferredFloat — subclasses float, promotes to oqpy.FloatVar

  • DeferredInt — subclasses int, promotes to oqpy.IntVar

Both inherit from a shared DeferredVarMixin that provides the lazy promotion logic. The wrappers override arithmetic and comparison operators (__add__, __mul__, __eq__, __lt__, etc.) to detect when the other operand is a QASM expression. When this happens, the wrapper creates an oqpy variable and delegates the operation to it. When the other operand is a plain Python value, the wrapper falls back to normal numeric behavior.

How deferred promotion works

When assign_stmt encounters a plain Python value, it wraps it in a deferred wrapper and stores it in the program conversion context. The wrapper is returned as the new value of the Python variable.

Because the wrapper subclasses the original numeric type, it behaves as a literal in most contexts:

  • Passed to a gate: rx(0, val) sees val as a plain float and inlines the literal 0.5 in the generated QASM.

  • Used in pure Python arithmetic: val * 2 returns a plain Python float result.

When the wrapper participates in arithmetic with a QASM expression (e.g., val + measure(q)), it lazily creates an oqpy variable and delegates the operation. The resulting OQPyExpression references the named variable.

When assign_stmt is later called for the reassignment (e.g., val = val + measure(q)), it detects the stored deferred value and promotes it to a declared QASM variable at the root program scope.

If the deferred value is never used in a QASM expression (e.g., it is only passed to a gate as a literal), no QASM variable is ever declared.

Two-pass loop tracing

When a deferred value is compared before being updated inside a loop, the comparison runs before promotion has occurred and falls through to plain Python. To handle this, QASM for and while loops use a two-pass tracing strategy:

  1. First pass — the loop body is traced normally. If any deferred values are promoted by an assignment during this pass, the set of promoted names is recorded.

  2. Rollback — the first-pass output is discarded by truncating the oqpy program’s scope bodies back to their pre-trace lengths and restoring the variable index counter and declared-vars dictionary.

  3. Pre-promote — the deferred values that were discovered in the first pass are promoted (declared at root scope) before the second trace begins.

  4. Second pass — the loop body is traced again. Because the values are already promoted, comparisons, gate parameters, and reverse operators all see the QASM variable.

If no deferred values are promoted during the first pass, the output is kept as-is and no second pass occurs. This means variables that are only compared but never updated inside the loop remain plain Python values and are not promoted.

Example

@aq.main(num_qubits=3)
def main():
    val = 0.5
    for q in aq.range(3):
        val = val + measure(q)
    rx(0, val)

Generated QASM:

OPENQASM 3.0;
qubit[3] __qubits__;
float[64] val = 0.5;
for int q in [0:3 - 1] {
    bit __bit_0__;
    __bit_0__ = measure __qubits__[q];
    val = val + __bit_0__;
}
rx(val) __qubits__[0];

Compared with a variable that remains a literal:

@aq.main(num_qubits=1)
def main():
    val = 0.5
    rx(0, val)

Generated QASM:

OPENQASM 3.0;
qubit[1] __qubits__;
rx(0.5) __qubits__[0];

In the second case, val is never promoted — it remains a plain float and is inlined as the literal 0.5.

Common Pitfalls for Contributors

Object identity in oqpy

oqpy uses is (not ==) to check if two variables are the same. Creating two FloatVar(name="val") objects and using both in the same program will raise a RuntimeError. Always reuse the same Var object when referring to the same QASM variable.

Deferred wrappers and isinstance

DeferredFloat is a subclass of float, and DeferredInt is a subclass of int. This means isinstance(val, float) returns True for deferred wrappers. If you need to distinguish deferred wrappers from plain values, check for DeferredVarMixin.

Augmented assignments bypass normal dispatch

Without the visit_AugAssign desugaring in the AST transformer, val += x would be evaluated natively by Python using __iadd__, completely bypassing assign_stmt. Any new assignment-like syntax that Python adds in the future would need similar treatment in the AST transformer.

Variable counter and naming

The variable index counter on ProgramConversionContext is incremented each time a new auto-named variable is created. Deferred wrappers intentionally do not increment this counter when created. If they did, every plain Python value assignment would shift all subsequent auto-generated variable names. The counter is only incremented when an explicit aq.FloatVar(), aq.IntVar(), or similar is created.

Root scope vs. control flow scope

The root scope flag is True at the top level of a function and False inside for/while/if blocks. This affects how variables are declared:

  • At root scope: variables are declared inline (e.g., int[32] a = 10;).

  • Inside control flow: variables are assigned in the current scope and oqpy auto-hoists the declaration (int[32] a;) to the root scope.

When a deferred variable is promoted inside a loop, the declaration is manually appended to the root scope to ensure correct ordering in the generated QASM.