Usage#
Your circuit is just a Python function: call gates inside it, then hand it to Script to run. This gives you direct control over gate placement, noise, measurements, and pulse simulation. Higher-level tools such as qml-essentials build on the same interfaces.
The diagram shows how JAQSI supports higher-level Model and Ansaetze interfaces. Gates and observables are Operation objects recorded on a Tape; Script executes the tape and returns measurements.
Pulse simulation adds two pieces to gate execution: PulseParams stores the values used to construct a pulse, and PulseEnvelope defines its shape. PulseInformation coordinates their valid combinations. Most circuits use these through Gates, which selects PulseGates when pulse=True.
Architecture#
For a peek under the hood, here is how each module helps turn a circuit function into a measurement:
operations.pydefines theOperationbase class for gates, observables, and noise channels, along with the Hamiltonian objects used for evolution.gates.pyprovidesGates, the circuit-facing interface. It records a gate, attaches requested noise, and selectsUnitaryGatesorPulseGatesfrom thepulseflag.unitary.pyapplies ideal gates fromgateset.pyand handles gate-angle errors and noise channels.pulses.pyimplements RX, RY, RZ, and CZ as pulses and builds composite gates from them. It also contains pulse envelopes, parameters, and calibration state.gateset.pydefines concrete gate and observable classes. Instantiating one inside a recorded circuit adds it to the tape.paulis.pyprovides symbolic Pauli and Clifford operations, includingPauliWord, without constructing a full matrix for every operation.noise.pydefines Kraus channels. Recording one switches execution from a statevector to a density matrix.tape.pycollects operations in circuit order before simulation starts.script.pyprovidesScript, the execution interface. It records a circuit, selects the simulation mode, handles batching and caching, and draws circuits.simulation.pycontains the statevector, density-matrix, and measurement kernels that run the recorded tape.memory.pyestimates batch memory use and splits batches into chunks when needed.drawing.pyrenders circuit diagrams and pulse schedules.evolution.pyturns static or time-dependent Hamiltonians into gates. It also batches pending pulse solves by pulse shape.__init__.pyre-exports the common interfaces forimport jaqsi.math.pyprovides quantum information functions for states and density matrices.qoc.pyoptimizes pulse parameters against target gates.
A call to Script.execute(...) then runs four stages:
- Record: Run the circuit function once so its operations register on a fresh
Tape. - Prepare: Infer the qubit count and choose statevector or density-matrix simulation from the recorded operations.
- Simulate: Apply gates and channels in circuit order, resolving pulse evolutions when needed.
- Measure: Return a state, probabilities, expectation values, or a density matrix; optionally sample shots.
As the whole pipeline is built on JAX, any execution can be differentiated, JIT-compiled and vectorized.
Usage#
Now let's put those pieces to work in a circuit.
Gate Level#
Gates is the entry point for applying gates.
Calling Gates.<Name>(...) inside a circuit function records the gate on the active tape,
attaches any requested noise, and routes the call to the unitary or the pulse backend.
Write circuits against Gates and the same circuit runs at either level; see
pulse level below.
Import JAQSI and Gates:
Define a circuit and observables:
def circuit():
Gates.H(wires=0)
Gates.CX(wires=[0, 1])
obs = [js.PauliZ(wires=0), js.PauliZ(wires=1)]
Observables are operation objects passed to execute, rather than gates applied inside the circuit. They are available from jaqsi or jaqsi.gateset.
Run the Bell circuit and ask for its probabilities:
Pass circuit parameters through execute(args=...):
import jax.numpy as jnp
n_qubits = 1
def circuit(phi, theta, omega):
Gates.Rot(phi, theta, omega, wires=0)
Gates.Rot(jnp.pi, 1/2*jnp.pi, 1/4*jnp.pi, wires=0)
obs = [js.PauliZ(wires=i) for i in range(n_qubits)]
jss = js.Script(circuit)
jss.execute(type="expval", obs=obs, args=(jnp.pi, 1/2*jnp.pi, 1/4*jnp.pi))
To tune the parameters, use JAX and Optax directly:
import jax
import optax as otx
def cost_fct(params):
phi, theta, omega = params
return jss.execute(type="expval", obs=[js.PauliZ(0)], args=(phi, theta, omega))[0]
params = jax.numpy.array([0.1, 0.2, 0.3])
opt = otx.adam(0.01)
opt_state = opt.init(params)
for epoch in range(1, 101):
grads = jax.grad(cost_fct)(params)
updates, opt_state = opt.update(grads, opt_state, params)
params = otx.apply_updates(params, updates)
if epoch % 10 == 0:
print(f"Epoch: {epoch}, Cost: {cost_fct(params):.4f}")
See the training page for the JIT-compiled version of this loop, fitting data with batched inputs, and optimizing pulse parameters.
Gates.<Name>(...) records the gate and returns nothing. When you need the operation
object itself — to call .dagger() or .power() on it, to build a composite observable,
or to do matrix algebra — take the class from jaqsi.gateset instead:
from jaqsi.gateset import RX, PauliX
def circuit():
RX(0.5, wires=0)
RX(0.5, wires=0).dagger()
PauliX(wires=0).power(2)
obs = [js.PauliZ(0)]
jss = js.Script(circuit)
res = jss.execute(type="expval", obs=obs)
print(res) # we expect to end up in |0⟩ again
Noise is normally requested per gate through Gates, which emits the matching channels
for you (see noise):
noise_params = {"Depolarizing": 0.1}
def circuit():
Gates.H(wires=0, noise_params=noise_params)
Gates.CX(wires=[0, 1], noise_params=noise_params)
jss = js.Script(circuit)
rho = jss.execute(type="density")
purity = jnp.real(jnp.trace(rho @ rho))
print(purity) # Purity should be < 1
Channels are operations too, so they can also be placed by hand from jaqsi.noise when you
want control over exactly where they land.
By default the simulation starts from the all-zero state \(\lvert 0\dots0\rangle\).
To start from an arbitrary statevector instead, pass it via the initial_state
argument of execute:
def circuit():
Gates.RX(0.3, wires=0)
jss = js.Script(circuit)
plus = jnp.array([1.0, 1.0], dtype=complex) / jnp.sqrt(2.0) # |+⟩
res = jss.execute(type="expval", obs=[js.PauliZ(0)], initial_state=plus)
Without in_axes the state must be a single statevector of shape (2**n,).
When batching with in_axes, initial_state may be a single 1D state broadcast across the batch, or a 2D array of shape (B, 2**n) that provides one state per sample.
Pulse Level#
The same circuit interface also works for pulses. This section shows how to switch levels; the pulse guide explores envelopes, Hamiltonians, and quantum optimal control in more detail.
Pulse-level simulation goes through the same entry point: pass pulse=True to any gate and
Gates routes it to the pulse backend instead of the ideal unitary.
Nothing else about the circuit changes.
def circuit(w):
Gates.RX(w, wires=0, pulse=True)
obs = [js.PauliZ(0)]
jss = js.Script(circuit)
res = jss.execute(type="expval", obs=obs, args=(jnp.pi*0.5,))
print(res) # approximately zero
Because the flag is per call, a circuit can mix both levels — here the entangling gate stays ideal while the rotations are lowered to pulses:
def circuit(w):
Gates.RX(w, wires=0, pulse=True)
Gates.RY(w, wires=0, pulse=True)
Gates.CX(wires=[0, 1])
Mixing pulse-level simulation with noisy simulations is possible as well:
noise_params = {"Depolarizing": 0.1}
def circuit(w):
Gates.RX(w, wires=0, pulse=True, noise_params=noise_params)
Gates.RY(w, wires=0, pulse=True, noise_params=noise_params)
Gates.CX(wires=[0, 1], noise_params=noise_params)
jss = js.Script(circuit)
rho = jss.execute(type="density", args=(jnp.pi*0.5,))
purity = jnp.real(jnp.trace(rho @ rho))
print(purity) # Purity should be < 1
Use draw to plot when pulses act on each qubit. Shaded regions show the pulse envelopes and vertical lines mark virtual Z gates. Composite gates are decomposed into their basis gates for the plot; for example, H becomes RZ and RY.
def circuit(w):
Gates.RX(w, wires=0, pulse=True)
Gates.CZ(wires=[0, 1], pulse=True)
Gates.H(wires=1, pulse=True)
Gates.H(wires=1, pulse=True)
jss = js.Script(circuit)
fig, axes = jss.draw(figure="pulse", args=(jnp.pi*0.5,))
Underneath a pulse gate is Hamiltonian evolution over time. You can use that evolution interface directly; this circuit starts with a static Pauli Z Hamiltonian.
def evol_circuit(t):
time_evol = js.Hermitian(matrix=js.PauliZ._matrix, wires=0).evolve()
time_evol(t=t, wires=0)
Execute it with Script:
Here, the circuit starts in \(|0\rangle\), so Z evolution changes only its phase and the X expectation value stays zero. Preparing \(|+\rangle\) first makes the evolution visible in that measurement:
def evol_circuit(t):
Gates.H(wires=0) # prepare |+⟩
time_evol = js.Hermitian(matrix=js.PauliZ._matrix, wires=0).evolve()
time_evol(t=t, wires=0)
Measure the evolved state:
t = 0.3
jss = js.Script(f=evol_circuit)
res = jss.execute(type="expval", obs=[js.PauliX(0)], args=(t,))
The X expectation value is jnp.cos(2 * t). This example combines an ordinary gate with Hamiltonian evolution in one circuit.
The Hamiltonian can also depend on a parameter. Multiply a Hermitian by a coefficient function to describe how its strength changes with the supplied parameters and time:
def coeff(p, t):
return p
def circuit(p, t):
Gates.H(wires=0) # prepare |+⟩
ph = coeff * js.Hermitian(matrix=js.PauliZ._matrix, wires=0, record=False)
ph.evolve()([p], t)
The callable coeff defines the dependence, while [p] supplies its parameter when the circuit runs. Keeping the coefficient function separate lets JAQSI reuse its compiled evolution code.
p = 0.5
jss = js.Script(f=circuit)
res = jss.execute(type="expval", obs=[js.PauliX(0)], args=(p, t))
And because the circuit is differentiable, you can train p with JAX as in the training guide.





