Skip to content

Algorithms API Reference

HLQuantum ships with a library of ready-to-use quantum algorithms. Each module exposes a canonical function name as well as a friendly alias for readability.

Friendly Aliases

Alias (friendly) Canonical function Module
frequency_transform qft(num_qubits, inverse) algorithms.qft
quantum_search grover(num_qubits, target, iters) algorithms.grover
find_hidden_pattern bernstein_vazirani(secret) algorithms.bernstein_vazirani
check_balance deutsch_jozsa(num_qubits, oracle) algorithms.deutsch_jozsa
add_two_bits half_adder() algorithms.arithmetic
add_three_bits full_adder() algorithms.arithmetic
add_numbers ripple_carry_adder(num_bits) algorithms.arithmetic
find_minimum_energy vqe_solve(...) algorithms.vqe
variational_circuit hardware_efficient_ansatz(...) algorithms.vqe
optimize_combinatorial qaoa_solve(...) algorithms.qaoa
learn_distribution gqe_solve(...) algorithms.gqe
compute_gradient parameter_shift_gradient(...) algorithms.grad

Quick Example

from hlquantum import algorithms

# Use friendly aliases
qft_circuit = algorithms.frequency_transform(num_qubits=4)
bv_circuit  = algorithms.find_hidden_pattern("1011")
search      = algorithms.quantum_search(num_qubits=3, target_states=["101"])

# Variational / optimisation
from hlquantum.algorithms import find_minimum_energy, optimize_combinatorial, compute_gradient

# Parameter-shift gradients
grads = compute_gradient(circuit, {"theta": 0.5})

Foundational Algorithms

Quantum Fourier Transform

hlquantum.algorithms.qft ~~~~~~~~~~~~~~~~~~~~~~~~

Quantum Fourier Transform (QFT) implementation.

frequency_transform = qft module-attribute

Alias for :func:qft — transform quantum amplitudes into the frequency domain.

qft(num_qubits, inverse=False)

Generate a Quantum Fourier Transform circuit.

Parameters

num_qubits : int Number of qubits to apply the QFT on. inverse : bool, optional If True, generate the Inverse QFT (IQFT).

Returns

Circuit The QFT or IQFT circuit.

Notes

Controlled-Phase(θ) is decomposed as: Rz(target, θ/2) → CX(control, target) → Rz(target, -θ/2) → CX(control, target) → Rz(control, θ/2)

Source code in hlquantum/algorithms/qft.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def qft(num_qubits: int, inverse: bool = False) -> Circuit:
    """Generate a Quantum Fourier Transform circuit.

    Parameters
    ----------
    num_qubits : int
        Number of qubits to apply the QFT on.
    inverse : bool, optional
        If True, generate the Inverse QFT (IQFT).

    Returns
    -------
    Circuit
        The QFT or IQFT circuit.

    Notes
    -----
    Controlled-Phase(θ) is decomposed as:
        Rz(target, θ/2)  →  CX(control, target)  →  Rz(target, -θ/2)  →
        CX(control, target)  →  Rz(control, θ/2)
    """
    qc = Circuit(num_qubits)

    if inverse:
        # Inverse QFT — reverse of forward QFT
        # 1. Undo swaps
        for i in range(num_qubits // 2):
            qc.swap(i, num_qubits - i - 1)

        # 2. Apply inverse rotations in reverse order
        for i in range(num_qubits):
            for j in range(i):
                angle = -math.pi / (2 ** (i - j))
                # Decompose controlled-phase(-angle) = CP(angle)†
                qc.rz(j, angle / 2)
                qc.cx(i, j)
                qc.rz(j, -angle / 2)
                qc.cx(i, j)
                qc.rz(i, angle / 2)
            qc.h(i)
    else:
        # Standard QFT
        for i in range(num_qubits - 1, -1, -1):
            qc.h(i)
            for j in range(i - 1, -1, -1):
                angle = math.pi / (2 ** (i - j))
                # Decompose CP(angle) into CX + Rz
                qc.rz(j, angle / 2)
                qc.cx(i, j)
                qc.rz(j, -angle / 2)
                qc.cx(i, j)
                qc.rz(i, angle / 2)

        # Swaps
        for i in range(num_qubits // 2):
            qc.swap(i, num_qubits - i - 1)

    return qc

Grover's search algorithm implementation.

grover(num_qubits, target_states, iterations=None)

Generate a Grover's search circuit.

Source code in hlquantum/algorithms/grover.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def grover(num_qubits: int, target_states: List[str], iterations: int = None) -> Circuit:
    """Generate a Grover's search circuit."""
    if iterations is None:
        iterations = max(1, int((math.pi / 4) * math.sqrt((2**num_qubits) / len(target_states))))

    qc = Circuit(num_qubits + 1)
    ancilla = num_qubits
    qc.x(ancilla).h(ancilla)

    for i in range(num_qubits):
        qc.h(i)

    for _ in range(iterations):
        for state in target_states:
            _apply_bitstring_oracle(qc, state, ancilla)

        for i in range(num_qubits):
            qc.h(i)
            qc.x(i)

        _apply_mcz(qc, list(range(num_qubits)))

        for i in range(num_qubits):
            qc.x(i)
            qc.h(i)

    for i in range(num_qubits):
        qc.measure(i)
    return qc

Bernstein-Vazirani

hlquantum.algorithms.bernstein_vazirani ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Bernstein-Vazirani algorithm implementation.

find_hidden_pattern = bernstein_vazirani module-attribute

Alias for :func:bernstein_vazirani — discover a hidden bitstring pattern in one query.

bernstein_vazirani(secret_bitstring)

Generate a Bernstein-Vazirani circuit to find a secret bitstring.

Parameters

secret_bitstring : str The secret bitstring (e.g., "1011") that the algorithm will discover.

Returns

Circuit The Bernstein-Vazirani circuit.

Source code in hlquantum/algorithms/bernstein_vazirani.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def bernstein_vazirani(secret_bitstring: str) -> Circuit:
    """Generate a Bernstein-Vazirani circuit to find a secret bitstring.

    Parameters
    ----------
    secret_bitstring : str
        The secret bitstring (e.g., "1011") that the algorithm will discover.

    Returns
    -------
    Circuit
        The Bernstein-Vazirani circuit.
    """
    n = len(secret_bitstring)
    qc = Circuit(n + 1) # n qubits + 1 ancilla

    # Initialize ancilla to |->
    qc.x(n).h(n)

    # Apply Hadamard to all input qubits
    for i in range(n):
        qc.h(i)

    # Apply Oracle: CX from q_i to ancilla if bit_i is 1
    for i, bit in enumerate(secret_bitstring):
        if bit == '1':
            qc.cx(i, n)

    # Apply Hadamard to input qubits again
    for i in range(n):
        qc.h(i)

    # Measure input qubits
    for i in range(n):
        qc.measure(i)

    return qc

Deutsch-Jozsa

hlquantum.algorithms.deutsch_jozsa ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Deutsch-Jozsa algorithm implementation.

check_balance = deutsch_jozsa module-attribute

Alias for :func:deutsch_jozsa — check whether a function is constant or balanced.

balanced_oracle(qc)

Example of a balanced oracle (CX from q0 to ancilla).

Source code in hlquantum/algorithms/deutsch_jozsa.py
57
58
59
def balanced_oracle(qc: Circuit):
    """Example of a balanced oracle (CX from q0 to ancilla)."""
    qc.cx(0, qc.num_qubits - 1)

constant_oracle(qc)

Example of a constant oracle (does nothing).

Source code in hlquantum/algorithms/deutsch_jozsa.py
52
53
54
def constant_oracle(qc: Circuit):
    """Example of a constant oracle (does nothing)."""
    pass

deutsch_jozsa(num_qubits, oracle)

Generate a Deutsch-Jozsa circuit.

Parameters

num_qubits : int Number of input qubits (n). oracle : Callable[[Circuit], None] A function that applies the oracle to a Circuit of size n+1.

Returns

Circuit The Deutsch-Jozsa circuit.

Source code in hlquantum/algorithms/deutsch_jozsa.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def deutsch_jozsa(num_qubits: int, oracle: Callable[[Circuit], None]) -> Circuit:
    """Generate a Deutsch-Jozsa circuit.

    Parameters
    ----------
    num_qubits : int
        Number of input qubits (n).
    oracle : Callable[[Circuit], None]
        A function that applies the oracle to a Circuit of size n+1.

    Returns
    -------
    Circuit
        The Deutsch-Jozsa circuit.
    """
    qc = Circuit(num_qubits + 1)

    # 1. Initialize ancilla to |->
    qc.x(num_qubits).h(num_qubits)

    # 2. Apply Hadamard to all input qubits
    for i in range(num_qubits):
        qc.h(i)

    # 3. Apply the Oracle
    oracle(qc)

    # 4. Apply Hadamard to input qubits again
    for i in range(num_qubits):
        qc.h(i)

    # 5. Measure input qubits
    for i in range(num_qubits):
        qc.measure(i)

    return qc

Quantum Arithmetic

hlquantum.algorithms.arithmetic ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Quantum arithmetic operations (Adders, etc.).

add_numbers = ripple_carry_adder module-attribute

Alias for :func:ripple_carry_adder — add two n-bit numbers.

add_three_bits = full_adder module-attribute

Alias for :func:full_adder — add two bits with a carry-in.

add_two_bits = half_adder module-attribute

Alias for :func:half_adder — add two single-bit inputs.

full_adder()

Generate a 1-bit full adder.

Qubits: - 0, 1, 2: Input bits (A, B, Cin) - 3: Sum (A ^ B ^ Cin) - 4: Carry out (Cout)

Source code in hlquantum/algorithms/arithmetic.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def full_adder() -> Circuit:
    """Generate a 1-bit full adder.

    Qubits:
    - 0, 1, 2: Input bits (A, B, Cin)
    - 3: Sum (A ^ B ^ Cin)
    - 4: Carry out (Cout)
    """
    qc = Circuit(5)
    # Binary addition: A + B + Cin
    # Sum bits
    qc.cx(0, 3).cx(1, 3).cx(2, 3)

    # Carry out: (A&B) | (B&Cin) | (A&Cin)
    # Simulating with CCX
    qc.ccx(0, 1, 4)
    qc.ccx(1, 2, 4)
    qc.ccx(0, 2, 4)

    return qc

half_adder()

Generate a 2-qubit half adder.

Qubits: - 0, 1: Input bits (A, B) - 2: Sum (A ^ B) - 3: Carry (A & B)

Source code in hlquantum/algorithms/arithmetic.py
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def half_adder() -> Circuit:
    """Generate a 2-qubit half adder.

    Qubits:
    - 0, 1: Input bits (A, B)
    - 2: Sum (A ^ B)
    - 3: Carry (A & B)
    """
    qc = Circuit(4)
    # Sum: XOR gate
    qc.cx(0, 2).cx(1, 2)
    # Carry: AND/Toffoli gate
    qc.ccx(0, 1, 3)
    return qc

ripple_carry_adder(num_bits)

Generate an n-bit ripple-carry adder.

Qubit layout

a[0..n-1] — first operand b[0..n-1] — second operand (sum is written here in-place) c[0..n] — carry chain (c[0]=carry-in, c[n]=carry-out)

Total qubits: 3*num_bits + 1 After execution b[i] holds the i-th bit of (A+B) and c[n] holds the final carry-out.

Source code in hlquantum/algorithms/arithmetic.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def ripple_carry_adder(num_bits: int) -> Circuit:
    """Generate an n-bit ripple-carry adder.

    Qubit layout:
        a[0..n-1]   — first operand
        b[0..n-1]   — second operand (sum is written here in-place)
        c[0..n]     — carry chain (c[0]=carry-in, c[n]=carry-out)

    Total qubits: 3*num_bits + 1
    After execution b[i] holds the i-th bit of (A+B) and c[n] holds the
    final carry-out.
    """
    n = num_bits
    total_qubits = 3 * n + 1  # a[n], b[n], c[n+1]
    qc = Circuit(total_qubits)

    def a(i: int) -> int:
        return i

    def b(i: int) -> int:
        return n + i

    def c(i: int) -> int:
        return 2 * n + i

    # Forward pass — propagate carries
    for i in range(n):
        # Carry: c[i+1] = MAJ(a[i], b[i], c[i])
        # Step 1: XOR partial sums
        qc.cx(a(i), b(i))          # b[i] ^= a[i]
        qc.cx(c(i), b(i))          # b[i] ^= c[i]  →  b[i] = a[i]⊕b[i]⊕c[i] (partial sum)
        # Step 2: Carry generation  c[i+1] = (a[i]·c[i]) ⊕ (b_orig[i]·c[i]) ⊕ (a[i]·b_orig[i])
        qc.ccx(a(i), c(i), c(i + 1))
        qc.cx(a(i), c(i))          # restore helper
        qc.ccx(b(i), c(i), c(i + 1))
        qc.cx(a(i), c(i))          # restore c[i]

    # b already holds the XOR (partial sum) from the forward pass.
    # The full sum bit is simply xor of a, b_orig, c_in which was
    # accumulated into b during the forward pass.  No additional
    # work needed — b[i] already equals Sum[i].\n
    return qc

Variational & Optimisation

VQE (Variational Quantum Eigensolver)

Variational Quantum Eigensolver (VQE) utilities.

hardware_efficient_ansatz(num_qubits, params)

A standard Hardware-Efficient Ansatz (HEA) template.

Source code in hlquantum/algorithms/vqe.py
50
51
52
53
54
55
56
57
58
59
def hardware_efficient_ansatz(num_qubits: int, params: List[float]) -> Circuit:
    """A standard Hardware-Efficient Ansatz (HEA) template."""
    qc = Circuit(num_qubits)
    for i in range(num_qubits):
        qc.ry(i, params[i] if i < len(params) else params[-1])

    if num_qubits > 1:
        for i in range(num_qubits - 1):
            qc.cx(i, i + 1)
    return qc

vqe_solve(ansatz, initial_params, optimizer=None, shots=1000, **kwargs)

Solve an optimization problem using VQE.

Source code in hlquantum/algorithms/vqe.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def vqe_solve(
    ansatz: Union[Circuit, Callable[[List[float]], Circuit]],
    initial_params: List[float],
    optimizer: Optional[Callable] = None,
    shots: int = 1000,
    **kwargs
) -> Dict[str, Any]:
    """Solve an optimization problem using VQE."""
    try:
        from scipy.optimize import minimize as _minimize
    except ImportError:
        raise ImportError("scipy is required for vqe_solve. Install it with: pip install scipy")

    def objective(params):
        if isinstance(ansatz, Circuit):
            param_map = {p: params[i] for i, p in enumerate(ansatz.parameters)}
            circuit = ansatz.bind_parameters(param_map)
        else:
            circuit = ansatz(params)

        if not any(g.name == "mz" for g in circuit.gates):
            circuit.measure_all()

        result = run(circuit, shots=shots, **kwargs)
        return result.expectation_value()

    if optimizer is None:
        from scipy.optimize import minimize
        res = minimize(objective, initial_params, method='COBYLA')
    else:
        res = optimizer(objective, initial_params)

    return {
        "fun": getattr(res, "fun", res),
        "x": getattr(res, "x", initial_params),
        "raw": res
    }

QAOA (Quantum Approximate Optimisation)

hlquantum.algorithms.qaoa ~~~~~~~~~~~~~~~~~~~~~~~~~

Quantum Approximate Optimization Algorithm (QAOA) implementation.

optimize_combinatorial = qaoa_solve module-attribute

Alias for :func:qaoa_solve — solve combinatorial optimisation problems (e.g. Max-Cut).

qaoa_solve(cost_hamiltonian, p=1, optimizer=None, shots=1000, **kwargs)

Solve an optimization problem using QAOA.

Parameters

cost_hamiltonian : list[dict] Representation of the cost function (e.g., Max-Cut). Each dict should have 'qubits': (i, j) and 'weight': float. p : int Number of QAOA layers (steps). optimizer : Callable, optional A classical optimizer (defaults to scipy.optimize.minimize). shots : int, optional Number of shots for expectation value estimation. **kwargs Forwarded to the runner.

Returns

dict Optimization results including optimal beta/gamma and max-cut value.

Source code in hlquantum/algorithms/qaoa.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def qaoa_solve(
    cost_hamiltonian: List[Dict[str, Any]],
    p: int = 1,
    optimizer: Optional[Callable] = None,
    shots: int = 1000,
    **kwargs
) -> Dict[str, Any]:
    """Solve an optimization problem using QAOA.

    Parameters
    ----------
    cost_hamiltonian : list[dict]
        Representation of the cost function (e.g., Max-Cut).
        Each dict should have 'qubits': (i, j) and 'weight': float.
    p : int
        Number of QAOA layers (steps).
    optimizer : Callable, optional
        A classical optimizer (defaults to scipy.optimize.minimize).
    shots : int, optional
        Number of shots for expectation value estimation.
    **kwargs
        Forwarded to the runner.

    Returns
    -------
    dict
        Optimization results including optimal beta/gamma and max-cut value.
    """
    # 1. Determine number of qubits
    all_qubits = set()
    for term in cost_hamiltonian:
        all_qubits.update(term['qubits'])
    n_qubits = max(all_qubits) + 1 if all_qubits else 0

    # 2. Build the QAOA Ansatz function
    def build_ansatz(params: List[float]) -> Circuit:
        gamma = params[:p]
        beta = params[p:]

        qc = Circuit(n_qubits)

        # Initial state: Hadamards on all qubits
        for i in range(n_qubits):
            qc.h(i)

        for step in range(p):
            # Cost Layer: e^(-i * gamma * H_C)
            # For each term ZZ_ij, we apply: CX(i,j), RZ(j, 2*gamma*w), CX(i,j)
            for term in cost_hamiltonian:
                q1, q2 = term['qubits']
                w = term.get('weight', 1.0)
                qc.cx(q1, q2)
                qc.rz(q2, 2 * gamma[step] * w)
                qc.cx(q1, q2)

            # Mixer Layer: e^(-i * beta * H_X)
            for i in range(n_qubits):
                qc.rx(i, 2 * beta[step])

        return qc

    # 3. Objective function for optimizer
    def objective(params):
        qc = build_ansatz(params)
        qc.measure_all()
        result = run(qc, shots=shots, **kwargs)
        # We minimize the cost (negative for max-cut)
        # Expectation result is a simplification
        return result.expectation_value()

    # 4. Optimization
    initial_params = [1.0] * (2 * p) # Gammas followed by Betas

    try:
        from scipy.optimize import minimize
    except ImportError:
        raise ImportError("scipy is required for qaoa_solve.")

    if optimizer is None:
        res = minimize(objective, initial_params, method='COBYLA')
    else:
        res = optimizer(objective, initial_params)

    return {
        "fun": getattr(res, "fun", res),
        "x": getattr(res, "x", initial_params),
        "raw": res,
        "n_qubits": n_qubits
    }

GQE (Generative Quantum Eigensolver)

hlquantum.algorithms.gqe ~~~~~~~~~~~~~~~~~~~~~~~~

Generative Quantum Eigensolver (GQE) implementation.

learn_distribution = gqe_solve module-attribute

Alias for :func:gqe_solve — learn a target probability distribution using a quantum circuit.

gqe_solve(ansatz, loss_fn, optimizer=None, **kwargs)

Solve an optimization problem using a Generative Quantum Eigensolver approach.

GQE uses generative learning to find the ground state or approximate the probability distribution of a Hamiltonian.

Source code in hlquantum/algorithms/gqe.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def gqe_solve(
    ansatz: Circuit,
    loss_fn: Callable[[Any], float],
    optimizer: Optional[Callable] = None,
    **kwargs
) -> Dict[str, Any]:
    """Solve an optimization problem using a Generative Quantum Eigensolver approach.

    GQE uses generative learning to find the ground state or approximate 
    the probability distribution of a Hamiltonian.
    """

    def objective(params):
        param_map = {p: params[i] for i, p in enumerate(ansatz.parameters)}
        qc = ansatz.bind_parameters(param_map)

        # Forward pass: Generate samples from the quantum state
        if not any(g.name == "mz" for g in qc.gates):
            qc.measure_all()

        result = run(qc, **kwargs)

        # The loss function compares generated distribution to target properties
        return loss_fn(result)

    try:
        from scipy.optimize import minimize
    except ImportError:
        raise ImportError("scipy is required for gqe_solve.")

    # Initialize with random parameters
    n_params = len(ansatz.parameters)
    initial_params = [0.0] * n_params

    if optimizer is None:
        res = minimize(objective, initial_params, method='COBYLA')
    else:
        res = optimizer(objective, initial_params)

    return {
        "fun": getattr(res, "fun", res),
        "x": getattr(res, "x", initial_params),
        "raw": res
    }

Differentiable Programming

Parameter-Shift Gradients

hlquantum.algorithms.grad ~~~~~~~~~~~~~~~~~~~~~~~~~

Gradient calculation for quantum circuits using the Parameter Shift Rule.

compute_gradient = parameter_shift_gradient module-attribute

Alias for :func:parameter_shift_gradient — compute quantum circuit gradients.

parameter_shift_gradient(circuit, parameter_values, shots=1000, **kwargs)

Calculate the gradient of a circuit's expectation value using the Parameter Shift Rule.

The formula is: df/dθ = 0.5 * [f(θ + π/2) - f(θ - π/2)]

Parameters

circuit : Circuit The parameterized circuit. parameter_values : dict The current values for all parameters in the circuit. shots : int Number of shots for each evaluation. **kwargs Additional arguments for the runner.

Returns

dict A mapping from parameter name to its gradient value.

Source code in hlquantum/algorithms/grad.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def parameter_shift_gradient(
    circuit: Circuit,
    parameter_values: Dict[Union[str, Parameter], float],
    shots: int = 1000,
    **kwargs
) -> Dict[str, float]:
    """Calculate the gradient of a circuit's expectation value using the Parameter Shift Rule.

    The formula is: df/dθ = 0.5 * [f(θ + π/2) - f(θ - π/2)]

    Parameters
    ----------
    circuit : Circuit
        The parameterized circuit.
    parameter_values : dict
        The current values for all parameters in the circuit.
    shots : int
        Number of shots for each evaluation.
    **kwargs
        Additional arguments for the runner.

    Returns
    -------
    dict
        A mapping from parameter name to its gradient value.
    """
    grads = {}
    shift = math.pi / 2

    # Get all unique parameters in the circuit
    circuit_params = circuit.parameters
    param_names = [p.name for p in circuit_params]

    # Normalise parameter_values keys to strings
    normalised_values: Dict[str, float] = {}
    for k, v in parameter_values.items():
        key = k.name if isinstance(k, Parameter) else k
        normalised_values[key] = v
    parameter_values = normalised_values  # type: ignore[assignment]

    # Ensure all required values are provided
    missing = [name for name in param_names if name not in parameter_values]
    if missing:
        raise ValueError(f"Missing parameter values for: {', '.join(missing)}")

    for param in circuit_params:
        # 1. Plus shift: θ + π/2
        plus_values = parameter_values.copy()
        plus_values[param.name] = parameter_values[param.name] + shift
        plus_qc = circuit.bind_parameters(plus_values)
        if not any(g.name == "mz" for g in plus_qc.gates):
            plus_qc.measure_all()
        plus_result = run(plus_qc, shots=shots, **kwargs)
        e_plus = plus_result.expectation_value()

        # 2. Minus shift: θ - π/2
        minus_values = parameter_values.copy()
        minus_values[param.name] = parameter_values[param.name] - shift
        minus_qc = circuit.bind_parameters(minus_values)
        if not any(g.name == "mz" for g in minus_qc.gates):
            minus_qc.measure_all()
        minus_result = run(minus_qc, shots=shots, **kwargs)
        e_minus = minus_result.expectation_value()

        # df/dθ = 0.5 * (E_plus - E_minus)
        grads[param.name] = 0.5 * (e_plus - e_minus)

    return grads