Source code for qrisp.jasp.program_control.sampling

# ********************************************************************************
# * Copyright (c) 2026 the Qrisp authors
# *
# * This program and the accompanying materials are made available under the
# * terms of the Eclipse Public License 2.0 which is available at
# * http://www.eclipse.org/legal/epl-2.0.
# *
# * This Source Code may also be made available under the following Secondary
# * Licenses when the conditions for such availability set forth in the Eclipse
# * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
# * with the GNU Classpath Exception which is
# * available at https://www.gnu.org/software/classpath/license.html.
# *
# * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
# ********************************************************************************

"""Implements sample, drawing measurement samples from a quantum sampling kernel."""

import jax
import jax.numpy as jnp

from qrisp.jasp.tracing_logic import check_for_tracing_mode, quantum_kernel


@jax.jit
def _backend_shots_marker(val):
    """Identity marker for the shot count.

    Allows ``backend_sampler`` to reliably locate the shot count inside a traced
    ``sampling_eval_function`` Jaxpr.
    """
    return val


# The following function implements the sample feature.

# The basic functionality would be relatively straightforward to implement,
# however there are some complications. The reason for that is that the resulting
# jaxpr should be "readable" by the terminal sampling interpreter.
# Terminal sampling means that instead of performing the simulations "shots"-times
# it is performed once and the shots are then sampled from that distribution.
# Naturally this implies a massive performance increase, which is why a lot
# of effort is spent to realize a smooth implementation.

# The underlying idea to make the feature easily "readable" by the terminal
# sampling interpreter is to structure one iteration of sampling into three
# steps.

# 1. Evaluating the user function, which generates the distribution.
# 2. Sampling from that distribution via the "measure" function.
# 3. Decoding and postprocessing the measurement results.

# For the final two steps we deploy some custom logic to realize the terminal
# sampling behavior. To simplify the automatic processing of these steps,
# we capture each into individual pjit calls.

# The terminal sampling interpreter then identifies each steps via the
# eqn.params["name"] attribute and executes the custom logic.


[docs] def sample(sampling_kernel=None, shots=0, post_processor=None): r"""Takes samples from a quantum computation specified by a sampling kernel. The ``sample`` function allows to take samples from a quantum computation specified by a *sampling kernel* — a Python function that receives only classical arguments and returns arbitrary values. Any :ref:`QuantumVariables <QuantumVariable>` in the return are automatically measured and decoded. The samples are returned in the form of a `Jax Array <https://jax.readthedocs.io/en/latest/_autosummary/jax.Array.html>`_ which is shaped according to the ``shots`` parameter. Because of this, shots can only be a **static integer** (no dynamic values!). If you want to sample with a dynamic shot amount, look into :ref:`expectation_value`. Sample calls can be efficiently simulated via terminal sampling by setting the corresponding keyword within :func:`~qrisp.jasp.jaspify` to ``True``. .. note:: **Terminal sampling** (``terminal_sampling=True`` inside :func:`~qrisp.jasp.jaspify`) does **not** support sampling kernels that return classical values alongside quantum variables, or kernels that return *only* classical values. Use ``terminal_sampling=False`` for those cases. Even when the kernel returns only :ref:`QuantumVariables <QuantumVariable>`, terminal sampling relies on the quantum state being **independent** of mid-circuit measurement outcomes. If a classical measurement result influences the quantum circuit (e.g. via :func:`control <qrisp.control>`), terminal sampling may produce an invalid distribution because it simulates the quantum part only once. See :func:`~qrisp.jasp.terminal_sampling` for details and an example. Parameters ---------- sampling_kernel : callable A sampling kernel — a function receiving only classical arguments and returning one or more :ref:`QuantumVariables <QuantumVariable>`, classical measurement results, or a mixture of both. The function must **not** receive quantum arguments because a quantum value would need to be copied for each sampling iteration, which is prohibited by the no-cloning theorem. shots : int The amounts of samples to take. post_processor : callable, optional A function to apply to the samples directly after measuring. By default no post processing is applied. Raises ------ Exception Tried to sample with dynamic shots value (static integer required) Exception Tried to sample from sampling kernel taking a quantum value Exception Tried to use terminal sampling with a kernel that returns classical values (use ``terminal_sampling=False`` instead) Returns ------- callable A classical, Jax traceable function returning a jax array containing the measurement results of each shot. Examples -------- We prepare the state .. math:: \ket{\psi} = \frac{1}{\sqrt{2}} \left(\ket{0}\ket{0}\ket{\text{True}} + \ket{k}\ket{k}\ket{\text{True}})\right) :: from qrisp import * from qrisp.jasp import * def sampling_kernel(k): a = QuantumFloat(4) b = QuantumFloat(4) qbl = QuantumBool() h(qbl) with control(qbl[0]): a[:] = k cx(a, b) return a, b And subsequently sample from the QuantumFloats: :: @jaspify def main(k): sampling_function = sample(sampling_kernel, shots = 10) return sampling_function(k) print(main(3)) # Yields e.g. # [[3. 3.] # [0. 0.] # [0. 0.] # [3. 3.] # [0. 0.] # [0. 0.] # [3. 3.] # [3. 3.] # [0. 0.] # [0. 0.]] # # Each row is either [0, 0] or [3, 3] with 50% probability each. # The exact order of rows is random and varies between runs. To demonstrate the post processing feature, we write a simple post processing function: :: def post_processor(x, y): return 2*x + y//2 @jaspify def main(k): sampling_function = sample(sampling_kernel, shots = 10, post_processor = post_processor) return sampling_function(k) print(main(4)) # Yields e.g. # [10. 10. 0. 0. 0. 0. 0. 0. 10. 10.] # # Each entry is either 0 or 10 with 50% probability each. The exact # order of entries is random and varies between runs. **Sampling kernels returning classical values** A sampling kernel may also return classical values from mid-circuit measurements alongside (or instead of) quantum variables: :: def mixed_kernel(): qf = QuantumFloat(4) h(qf[0]) h(qf[1]) mes = measure(qf[1]) # classical measurement result return qf, mes # mixed: quantum + classical @jaspify def main(): return sample(mixed_kernel, shots=20)() print(main()) # Yields e.g.: # [[0. 0.] # [0. 0.] # [1. 0.] # ...] .. note:: The above example uses ``@jaspify`` (which defaults to ``terminal_sampling=False``). Using ``@jaspify(terminal_sampling=True)`` with a mixed-returns kernel will raise an error. """ from qrisp.core import QuantumVariable, measure from qrisp.jasp import qache if isinstance(sampling_kernel, int): shots = sampling_kernel sampling_kernel = None if sampling_kernel is None: return lambda x: sample(x, shots, post_processor=post_processor) if post_processor is None: def identity(*args): if len(args) == 1: return args[0] return args post_processor = identity if isinstance(shots, jax.core.Tracer): raise Exception("Tried to sample with dynamic shots value (static integer required)") elif not isinstance(shots, int): raise Exception(f"Tried to sample with shots value of non-integer type {type(shots)}") # Qache the user function @qache def user_func(*args): return sampling_kernel(*args) # This function evaluates the sampling process @jax.jit def sampling_eval_function(*args, tracerized_shots=0): for arg in args: if isinstance(arg, QuantumVariable): raise Exception("Tried to sample from state preparation function taking a quantum value") # Marker: allows backend_sampler to locate the shot count in the # traced Jaxpr without fragile position-based extraction. _backend_shots_marker(tracerized_shots) # We now construct a loop to collect the samples by # inserting the postprocessed measurement result into an array. # The following function is the loop body, which is kernelized. @quantum_kernel def sampling_body_func(i, args): acc = args[0] # Evaluate the user function result_tuple = user_func(*args[1:]) if not isinstance(result_tuple, tuple): result_tuple = (result_tuple,) # Build a per-position mask: QuantumVariable -> True, classical -> False. is_quantum = [isinstance(x, QuantumVariable) for x in result_tuple] # Separate quantum and classical returns. qv_tuple = tuple(x for x, is_q in zip(result_tuple, is_quantum) if is_q) classical_tuple = tuple(x for x, is_q in zip(result_tuple, is_quantum) if not is_q) if qv_tuple: # ---------------------------------------------------------- # Stage 2: measure quantum registers only. # ---------------------------------------------------------- @qache def sampling_helper_1(*args): res_list = [measure(reg) for reg in args] return tuple(res_list) measurement_ints = sampling_helper_1(*[qv.reg for qv in qv_tuple]) # ---------------------------------------------------------- # Stage 3: decode quantum, interleave with classical values, # apply post-processing. Classical values are passed as # explicit arguments (before measurement ints). When present # the helper is named sampling_helper_2_mixed so that the # terminal-sampling guard in jaspification.py can detect it. # ---------------------------------------------------------- if classical_tuple: def sampling_helper_2_mixed(*args): n_classical = len(classical_tuple) classical_vals = args[:n_classical] meas_ints = args[n_classical:] decoded_q = [qv.jdecoder(meas_int) for qv, meas_int in zip(qv_tuple, meas_ints)] q_iter = iter(decoded_q) c_iter = iter(classical_vals) full = [next(q_iter) if is_q else next(c_iter) for is_q in is_quantum] result = post_processor(*full) if isinstance(result, tuple): return_amount.append(len(result)) if len(acc.shape) == 1: raise _MultiReturnDetected() return result sampling_helper_2 = jax.jit(sampling_helper_2_mixed) else: def sampling_helper_2(*meas_ints): decoded_q = [qv.jdecoder(meas_int) for qv, meas_int in zip(qv_tuple, meas_ints)] result = post_processor(*decoded_q) if isinstance(result, tuple): return_amount.append(len(result)) if len(acc.shape) == 1: raise _MultiReturnDetected() return result sampling_helper_2 = jax.jit(sampling_helper_2) decoded_values = sampling_helper_2(*classical_tuple, *measurement_ints) else: # ---------------------------------------------------------- # No quantum returns — pure classical. No measurement or # decoding needed; just apply post-processing directly. # ---------------------------------------------------------- result = post_processor(*classical_tuple) if isinstance(result, tuple): return_amount.append(len(result)) if len(acc.shape) == 1: raise _MultiReturnDetected() decoded_values = result # Insert into the accumulating array acc = acc.at[i].set(decoded_values) return (acc, *args[1:]) # This list captures the amount of return values. The strategy here is # to initially assume only one QuantumVariable is returned, which is then # added to the expectation value accumulator. If more than one is returned, # the amount is saved in this list and an exception is raised, which # subsequently causes another call but this time with the correct accumulator # dimension. return_amount = [] try: loop_res = jax.lax.fori_loop(0, tracerized_shots, sampling_body_func, (jnp.zeros(shots), *args)) return loop_res[0] except _MultiReturnDetected: loop_res = jax.lax.fori_loop( 0, tracerized_shots, sampling_body_func, (jnp.zeros((shots, return_amount[0])), *args), ) return loop_res[0] from qrisp.jasp import terminal_sampling def return_function(*args): if check_for_tracing_mode(): # Under tracing the sampling loop writes one row per shot, so a # non-positive count has no meaningful result and would otherwise # fail obscurely while indexing the zero-length accumulator. # Outside tracing mode shots=0 is legitimate -- it selects the exact # probabilities via terminal_sampling -- so the branch below is # deliberately left unguarded. if shots < 1: raise ValueError( f"Tried to sample with a shots value of {shots} inside a traced function " "(at least one shot is required). A shot count of 0 returns exact " "probabilities, which is only available outside of tracing mode." ) return sampling_eval_function(*args, tracerized_shots=shots) else: return terminal_sampling(sampling_kernel, shots)(*args) return return_function
class _MultiReturnDetected(Exception): """Internal signal raised when the post-processor returns multiple values (a tuple) instead of a single scalar. This triggers a retry of the sampling loop with a multi-dimensional accumulator of the correct shape. """