# Interfaces

`Circuit(...)` returns a different type of circuit depending on its `interface=` argument, which you set explicitly, as in PennyLane. Both interfaces run the **same** `CircuitSpec` on the **same** providers. They differ only in *how you pass the weights* and *how you get gradients*:

- **`"numpy"`**, the default, is the core circuit, which does not depend on any framework. You pass the `weights` at every call, and the gradients come from `circuit.jacobian(...)`. It does not use PyTorch, and `pip install eigenkit` with no extra is enough.
- **`"torch"`** is a `torch.nn.Module` that stores its weights as an `nn.Parameter` and computes gradients through `torch.autograd`. It is part of the optional PyTorch package (the `torch` extra).

|            | `numpy` (default)                                                                       | `torch`                                                        |
| ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Returns    | A `core.circuit.Circuit`.                                                               | A `TorchCircuit`, which is an `nn.Module`.                     |
| Weights    | They are passed at each call: `circuit(weights, x)`.                                    | The circuit stores them as an `nn.Parameter`: `circuit(x)`.    |
| Gradients  | `circuit.jacobian(weights, x)`.                                                         | `loss.backward()`, through autograd.                           |
| Dependency | The core (NumPy and SciPy).                                                             | The `torch` extra.                                             |
| Best for   | Chemistry and optimisation, variational loops, and custom training without a framework. | Hybrid quantum-classical models trained end to end in PyTorch. |

Both accept the same `provider=` argument, in any of its forms. See [Providers](https://docs.kit.eigenorbit.com/user-guide/providers/index.md).

`Circuit` is a function, not a class

The top-level `Circuit(...)` is a **factory function**. It returns one of two concrete types, depending on `interface=`: the NumPy circuit `eigenkit.core.circuit.Circuit`, or the PyTorch circuit `TorchCircuit`, which is an `nn.Module`. So it is not a class: `isinstance(obj, Circuit)`, `issubclass(...)`, subclassing `Circuit`, and using `Circuit` as a type annotation do not work. Check the type against the concrete class, or subclass it, instead (`from eigenkit.core.circuit import Circuit as NumpyCircuit`, or `from eigenkit.torch import TorchCircuit`).

______________________________________________________________________

## The NumPy interface (default)

`Circuit(blocks, observables)` returns the core NumPy circuit. It does **not** store parameters: you pass a 1-D `weights` vector, of length `circuit.spec.n_weights`, and an `inputs` vector at each call.

```python
import numpy as np
from eigenkit import Circuit, Encoding, Variational, all_z

circuit = Circuit(
    blocks=[Encoding(in_features=4, rotation="RX"), Variational(depth=2)],
    observables=all_z(4),
)  # provider="numpy", interface="numpy"

weights = np.zeros(circuit.spec.n_weights)
x = np.array([0.1, 0.2, 0.3, 0.4])
y = circuit(weights, x)  # shape (4,): one expectation per observable
```

### Forward calls and batching

`inputs` can be 1-D, for a single sample, or 2-D, with shape `(batch, n_inputs)`. `weights` stays 1-D, because the weights are shared across the batch. The output has shape `(n_observables,)` or `(batch, n_observables)`. It becomes a scalar, or shape `(batch,)`, when a single observable is measured.

```python
x_batch = np.random.uniform(0, np.pi, size=(32, 4))
circuit(weights, x_batch)  # shape (32, 4)
```

### Gradients with `circuit.jacobian`

The NumPy interface is fully differentiable without PyTorch:

```python
g = circuit.jacobian(weights, x)  # d<O>/d(weights)
gw, gx = circuit.jacobian(weights, x, input_grad=True)  # also d<O>/d(inputs)
```

`jacobian` returns shape `(n_weights,)` for a single observable, and `(n_observables, n_weights)` for several, in the same way as `__call__`. It uses the differentiation of the active backend when there is one, such as the analytic **adjoint** method of the NumPy simulator, or the automatic differentiation of PennyLane. Otherwise, it uses the **parameter-shift** rule, which works on any backend. Pass one 1-D sample. For a batch, loop over the rows. On a hardware backend, it raises an error, like `.backward()`, unless the configuration of the device sets `enable_gradients=True`.

The variational optimisation tools use this gradient, so a loop that finds a ground state takes a few lines and does not need PyTorch. These tools work on a **purely** variational circuit, which has no `inputs`, so the example takes an ansatz in place of the encoding circuit above:

```python
from eigenkit import Variational, Z, minimize_expectation

ansatz = Circuit([Variational(depth=2)], observables=Z(0))  # n_inputs == 0

rng = np.random.default_rng(0)
x0 = rng.uniform(0, 2 * np.pi, size=ansatz.spec.n_weights)
result = minimize_expectation(ansatz, initial_parameters=x0)
print(result.optimal_value)
```

### Other primitives

`zero_state_probability(weights, inputs)`, which gives the fidelity used to invert kernels, and `probabilities(weights, inputs)`, which gives the whole distribution of measurement outcomes, take the same `(weights, inputs)` arguments:

```python
circuit.zero_state_probability(weights, x)
circuit.probabilities(weights, x)  # shape (2**n_qubits,)
```

A circuit with no trainable weights, such as the inversion circuit of a kernel that uses only data, takes an empty weight vector: `inversion.zero_state_probability([], x)`.

### When to use it

The NumPy interface is the right default for the domains that do not use a framework (`chemistry`, `optimization`), for variational loops driven by SciPy, for any custom training where you control the optimiser, and for light installs that should not install PyTorch.

______________________________________________________________________

## The PyTorch interface

Pass `interface="torch"` to get a `torch.nn.Module`. It stores its weights as an `nn.Parameter`, so you call it with the inputs only, and you train it with any PyTorch optimiser. It needs the `torch` extra, and it runs on the built-in NumPy simulator, which is differentiable:

```python
import torch
from eigenkit import Circuit, Encoding, Variational, all_z

layer = Circuit(
    blocks=[Encoding(in_features=4), Variational(depth=2)],
    observables=all_z(4),
    interface="torch",
    init=None,  # optional initial weights, shape (n_weights,)
)

layer.weights  # nn.Parameter of shape (n_weights,)
list(layer.parameters())  # plugs into torch.optim like any module
```

`init` sets the initial weight tensor. When it is not given, the weights are drawn at random with `torch.randn`. Both the top-level `Circuit(...)` factory and a `TorchCircuit(...)` built directly default to `provider="numpy"`.

### What the layer exposes

| Member                             | What it does                                                                                                                                                                                                |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `layer(x)`                         | The forward pass. `x` can be 1-D (one sample) or 2-D, with shape `(batch, n_inputs)`.                                                                                                                       |
| `weights`                          | The trainable `nn.Parameter`, of shape `(n_weights,)`.                                                                                                                                                      |
| `spec`, `n_qubits`, `out_features` | The assembled `CircuitSpec`, and the two sizes derived from it.                                                                                                                                             |
| `draw(decimals=3)`                 | A drawing of the circuit as text, over several lines. It takes no input, because the layer stores its weights.                                                                                              |
| `draw_mpl(**kwargs)`               | The same drawing as a matplotlib `(fig, ax)`. It needs the `viz` extra.                                                                                                                                     |
| `zero_state_probability(x)`        | The probability of the all-zeros outcome, which is the fidelity that the kernels use.                                                                                                                       |
| `submit(x)`                        | Submits the circuit without waiting, and returns a `JobHandle`. It works on Braket, Qiskit Runtime and IQM only.                                                                                            |
| `dry_run_info()`                   | Gives, without submitting anything, the gate counts, the shots and the number of circuits that a call runs. It works on Braket, Qiskit Runtime and IQM, and the other backends raise `NotImplementedError`. |
| `with_provider(provider, ...)`     | Builds the same circuit again on another backend. See below.                                                                                                                                                |

`probabilities()` and `jacobian()` are **not** available on the PyTorch circuit. The whole distribution of measurement outcomes is a primitive of the NumPy interface, and the gradients come from `loss.backward()` instead of a call to a jacobian. `submit()` and `dry_run_info()` are explained in [QPU and Cloud](https://docs.kit.eigenorbit.com/user-guide/qpu/index.md).

### Forward calls and batching

```python
y = layer(torch.randn(8, 4))  # (8, 4): batch of 8, one value per observable
```

A 1-D input is a single sample, and a 2-D input is a batch. The weights are shared across the batch, exactly as in the NumPy interface.

### Gradients with `loss.backward()`

The gradients flow through `torch.autograd`:

```python
x = torch.randn(8, 4)
target = torch.zeros(8, 4)
opt = torch.optim.Adam(layer.parameters(), lr=0.05)
loss = (layer(x) - target).pow(2).mean()
loss.backward()  # weights.grad is populated
opt.step()
```

Backends with their own automatic differentiation, such as PennyLane, differentiate through their own engine. For the other backends, the forward pass is wrapped in a `torch.autograd.Function`, whose backward pass uses the same parameter-shift or adjoint jacobian as the NumPy interface. Backends without gradients (`gradient_method="none"`, for example quantum hardware and Qiskit Runtime) raise a clear error on `.backward()`. See [QPU and Cloud](https://docs.kit.eigenorbit.com/user-guide/qpu/index.md) for the pattern of training on a simulator and then running on the device, and for `enable_gradients=True`, which lets a hardware configuration use the parameter-shift rule on the device.

Training also works on the default **NumPy** provider, without PennyLane. The analytic adjoint method of the NumPy backend differentiates a whole batch in one sweep, so `Circuit(..., interface="torch")`, with the NumPy provider by default, trains hybrid models efficiently. Switch to `provider="pennylane"` if you prefer its simulator. Both process batches natively: the forward and backward passes of a `(B, …)` batch run in one call, not in a Python loop over the samples.

### Moving a circuit to another provider with `with_provider`

`with_provider` builds the **same** `CircuitSpec` and observables again on a different backend, without listing the blocks again:

```python
fast = layer.with_provider("qiskit")  # clones weights
fresh = layer.with_provider("qiskit", copy_weights=False)  # re-initialises
exact = layer.with_provider(
    "qiskit",
    init=torch.zeros(layer.spec.n_weights, dtype=torch.float64),
)
```

The weights are cloned by default. `copy_weights=False` draws new random weights, and `init=` sets them explicitly (it takes precedence over `copy_weights`). The new layer takes its data type from `init`, so pass `dtype=torch.float64` to keep the double precision that a layer built from blocks starts with. A plain `torch.zeros(n)` is `float32`, and it would halve the precision without any warning.

### Composing and saving

Because it is a plain `nn.Module`, a PyTorch circuit can be used inside `nn.Sequential` and next to the ready-made layers (see [Layers](https://docs.kit.eigenorbit.com/user-guide/layers/index.md)). The scikit-learn compatible estimators (`HQNC`, `HQNR` and others) are built on it. Its state can be saved and restored with the usual PyTorch methods (`state_dict()` and `load_state_dict()`), and fitted estimators are saved as `.eigenkit` bundles. See [Model persistence](https://docs.kit.eigenorbit.com/user-guide/model-persistence/index.md).

### When to use it

Use the PyTorch interface when the quantum circuit is one layer in a larger PyTorch model, or whenever you want to train end to end with automatic differentiation and the optimisers and tools of PyTorch.

______________________________________________________________________

## Same circuit, both interfaces

The interface is a choice made *when the code runs*, over one definition of the circuit. The blocks, the observables and the numerical results of the provider are the same:

```python
import numpy as np, torch
from eigenkit import Circuit, Encoding, Variational, all_z

blocks = [Encoding(in_features=2), Variational(depth=1)]

np_circuit = Circuit(blocks, all_z(2))  # numpy
tc = Circuit(blocks, all_z(2), interface="torch")  # torch

w = tc.weights.detach().numpy()
x = np.array([0.3, -0.2])
np.allclose(np_circuit(w, x), tc(torch.tensor(x)).detach().numpy())  # True
```

## API reference

The factory function that chooses the type from `interface=`, and the two concrete types that it returns.

### `eigenkit.circuit.Circuit(blocks, observables=None, provider='numpy', interface='numpy', init=None)`

Build a circuit and compile it for a backend.

This is the entry point for the rest of the library: you give it blocks, and it returns a circuit that you can call. The type of the circuit depends on `interface`. The NumPy interface returns a core `Circuit`, and the PyTorch interface returns a `TorchCircuit`.

Parameters:

| Name          | Type             | Description                                                                                                                                                                                                                                                                           | Default                                                                                                                                                                                                       |
| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `blocks`      | `_BlocksOrSpec`  | The circuit. It can be a list of blocks to assemble, a CircuitSpec that is already assembled, or a CircuitBuilder. A builder is built when Circuit is called, so gates added to it afterwards are not part of this circuit. A builder can also take the place of a block in the list. | *required*                                                                                                                                                                                                    |
| `observables` | `ObservableLike` | What to measure. None measures Z on every qubit and returns one value per qubit. A single Observable or PauliExpr returns a single value, and a list of them returns one value for each. Default: None.                                                                               | `None`                                                                                                                                                                                                        |
| `provider`    | `ProviderSpec`   | The backend that runs the circuit. It can be a registered name ("numpy", "pennylane", "qiskit" and others), a configuration instance such as PennyLaneConfig(...), or a backend object. Default: "numpy".                                                                             | `'numpy'`                                                                                                                                                                                                     |
| `interface`   | `_Interface`     | "numpy" returns a circuit that takes the weights at each call and does not depend on any framework. "torch" returns a TorchCircuit, which is a PyTorch nn.Module that stores the weights. Default: "numpy".                                                                           | `'numpy'`                                                                                                                                                                                                     |
| `init`        | \`Tensor         | None\`                                                                                                                                                                                                                                                                                | The initial weights of the PyTorch circuit, of shape (n_weights,). They are random when init is not given. The NumPy interface stores no weights, so passing init with it raises a ValueError. Default: None. |

Returns:

| Name      | Type      | Description    |
| --------- | --------- | -------------- |
| `circuit` | \`Circuit | TorchCircuit\` |

Raises:

| Type         | Description                                                                                 |
| ------------ | ------------------------------------------------------------------------------------------- |
| `ValueError` | If interface is neither "numpy" nor "torch", or if init is passed with the NumPy interface. |

Example

```python
import numpy as np
from eigenkit import Circuit, Encoding, Variational, all_z

circuit = Circuit(
    blocks=[Encoding(in_features=4), Variational(depth=2)],
    observables=all_z(4),
)
weights = np.zeros(circuit.spec.n_weights)
values = circuit(weights, np.random.uniform(0, np.pi, size=4))
```

### `eigenkit.core.circuit.Circuit`

A compiled circuit, ready to evaluate.

It holds a `CircuitSpec`, the observables to measure and a compiled backend handle. The circuit is compiled once, when the instance is created, and every call reuses the compiled handle. The instance does not store weights: you pass the `weights` at each call.

This is the NumPy interface. The top-level `Circuit(...)` function of the package returns this class for `interface="numpy"` and a `TorchCircuit` for `interface="torch"`.

Parameters:

| Name          | Type             | Description                                                                                                                                                                                                                                                                                      | Default    |
| ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| `spec`        | `CircuitSpec`    | The circuit to run, usually built with assemble(blocks).                                                                                                                                                                                                                                         | *required* |
| `observables` | `ObservableLike` | What to measure. None measures Z on every qubit and returns one value per qubit. A single Observable or PauliExpr returns a single value, and a list of them returns one value for each. Default: None.                                                                                          | `None`     |
| `provider`    | `ProviderSpec`   | The backend that runs the circuit. It can be a registered name ("numpy", "pennylane" and others), a configuration instance, or a provider object. Default: "numpy".                                                                                                                              | `'numpy'`  |
| `interface`   | `Interface`      | How a call is evaluated. With "numpy", a call runs the NumPy path and returns NumPy values. With "torch", each call is passed to the registered PyTorch interface, and TorchCircuit builds its core circuit in this way. The top-level Circuit function always passes "numpy". Default: "numpy". | `'numpy'`  |

Example

```python
import numpy as np
from eigenkit import Circuit, Encoding, Variational, all_z

circuit = Circuit(
    blocks=[Encoding(in_features=2), Variational(depth=1)],
    observables=all_z(2),
)

# The circuit stores no weights, so you pass them at every call.
weights = np.zeros(circuit.spec.n_weights)
values = circuit(weights, np.array([0.1, 0.2]))

# Gradients, without PyTorch.
grad = circuit.jacobian(weights, np.array([0.1, 0.2]))
```

#### `draw(*, decimals=3)`

Return a text drawing of the circuit, over several lines.

The drawing does not depend on the provider: it is built from the `CircuitSpec`, not from the compiled backend object. See `eigenkit.core.draw` for the details of the layout.

Parameters:

| Name       | Type  | Description                                                                  | Default |
| ---------- | ----- | ---------------------------------------------------------------------------- | ------- |
| `decimals` | `int` | The number of significant digits shown for a numeric gate angle. Default: 3. | `3`     |

Returns:

| Type  | Description                                             |
| ----- | ------------------------------------------------------- |
| `str` | The drawing, as a single string that contains newlines. |

#### `draw_mpl(**kwargs)`

Draw the circuit as a matplotlib figure, and return `(fig, ax)`.

Gates are drawn as labelled boxes and controls as filled dots, and each measured wire (from `self.observables`) ends with a measurement symbol. As with `draw()`, the picture is built from the `CircuitSpec` and the observables, not from the compiled backend, so it does not depend on the provider.

Pass `style="eigen"` for the brand colours (each gate is coloured by its kind: rotations, `H` and block gates, and measurements), or `style="roles"` to colour the outline of each gate by its role (encoding, trainable or fixed). The default is black on white. Each style has a `"-dark"` variant (for example `"eigen-dark"`) for dark backgrounds, and `"eigen-universal"` is a single image that works on both.

matplotlib is an optional dependency. Install it with `eigenkit[viz]`.

Parameters:

| Name       | Type  | Description                                                                                               | Default |
| ---------- | ----- | --------------------------------------------------------------------------------------------------------- | ------- |
| `**kwargs` | `Any` | Arguments passed to draw_mpl(): decimals, show_params, show_measurements, fontsize, style, legend and ax. | `{}`    |

Returns:

| Name     | Type    | Description                    |
| -------- | ------- | ------------------------------ |
| `fig_ax` | `tuple` | The matplotlib (fig, ax) pair. |

#### `dry_run_info()`

Describe the prepared circuit in a dictionary, without submitting it.

It is available only on backends whose `CompiledCircuit` provides a `dry_run_info` method. Currently, these are Braket (on its local simulator as well as on cloud devices), Qiskit Runtime and IQM. It is useful to check the size of the circuit, the gate counts after transpilation, the number of shots and the number of circuits that each call runs, before you submit to a cloud simulator or a quantum processing unit (QPU). It does not estimate a price.

#### `jacobian(weights, inputs=None, *, input_grad=False)`

Return the gradient of the expectation values with respect to the weights.

It returns `d<O>/d(weights)`, of shape `(n_weights,)` for a single observable or `(n_observables, n_weights)` for a list of observables, like the output of `__call__()`. Pass a single 1-D `inputs` sample. For a batch, call it once for each row.

The NumPy interface can compute every gradient without PyTorch. This method uses the same sequence of gradient methods as the variational optimisation tools and the PyTorch interface. It uses the backend's own differentiation when the backend has one (the analytic adjoint method of the NumPy simulator, the differentiation of PennyLane, and so on), and otherwise the parameter-shift rule, which works on any backend.

With `input_grad=True`, it returns `(weight_jacobian, input_jacobian)`, where `input_jacobian` is `d<O>/d(inputs)`, with the same leading shape.

Parameters:

| Name         | Type      | Description                                                                   | Default                                                                                                                                                                                                                                  |
| ------------ | --------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `weights`    | `ndarray` | The weight vector, of shape (n_weights,).                                     | *required*                                                                                                                                                                                                                               |
| `inputs`     | \`ndarray | None\`                                                                        | One 1-D sample, of shape (n_inputs,). This method takes a single sample, so for a batch, call it once for each row. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |
| `input_grad` | `bool`    | If True, also return the jacobian with respect to the inputs. Default: False. | `False`                                                                                                                                                                                                                                  |

Returns:

| Name       | Type      | Description               |
| ---------- | --------- | ------------------------- |
| `jacobian` | \`ndarray | tuple[ndarray, ndarray]\` |

Raises:

| Type           | Description                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `RuntimeError` | On a hardware backend whose configuration leaves enable_gradients off (gradient_method="none"). |

#### `probabilities(weights, inputs=None)`

Return the measurement distribution over all `2**n_qubits` basis states.

The basis states are ordered with qubit `0` as the most significant bit. The distribution is used to decode samples. For example, the quantum approximate optimisation algorithm (QAOA) reads the most probable bitstrings from the optimised state.

Parameters:

| Name      | Type      | Description                               | Default                                                                                                                              |
| --------- | --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `weights` | `ndarray` | The weight vector, of shape (n_weights,). | *required*                                                                                                                           |
| `inputs`  | \`ndarray | None\`                                    | One 1-D sample. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Type      | Description                                                       |
| --------- | ----------------------------------------------------------------- |
| `ndarray` | The probabilities of all basis states, of shape (2\*\*n_qubits,). |

Raises:

| Type                  | Description                                                                                                  |
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `NotImplementedError` | On a backend that has neither a probabilities() method nor a state vector to compute the probabilities from. |

#### `resources(**kwargs)`

Report the resources that the circuit uses once transpiled for its target.

It reports the native gate counts, the number of two-qubit gates and the two-qubit depth, the physical qubits chosen by the layout, the number of two-qubit gates added by routing (compared with the same circuit on the native gates with all-to-all connectivity), and the success probability predicted by the error rates of the target. Nothing is submitted.

Parameters:

| Name       | Type  | Description                                                                                                   | Default |
| ---------- | ----- | ------------------------------------------------------------------------------------------------------------- | ------- |
| `**kwargs` | `Any` | Arguments passed to the backend, for example measured=False on Qiskit Runtime for the bare estimator circuit. | `{}`    |

Returns:

| Type   | Description                                                       |
| ------ | ----------------------------------------------------------------- |
| `dict` | A dictionary. See QiskitRuntimeCompiled.resources() for its keys. |

Raises:

| Type                  | Description                                                                                                                                                          |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NotImplementedError` | On backends that do not transpile for a device, which includes every simulator. To inspect a device locally, use Qiskit Runtime or IQM with a fake backend_instance. |

#### `sample(weights, inputs=None)`

Return the raw measurement histogram, which maps each bitstring to a count.

It is what a sampling backend measured, before any averaging. These are the counts behind `probabilities()`, and it is the only form that stays usable when the circuit has too many qubits for a dense distribution. In each bitstring, qubit `0` is the most significant bit, so `"100"` means that qubit `0` was measured in state `1`.

Only sampling backends can return a histogram, because it needs a finite number of shots. Exact statevector simulators raise an error instead of making up a histogram. Use `probabilities()` with those.

Parameters:

| Name      | Type      | Description                               | Default                                                                                                                              |
| --------- | --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `weights` | `ndarray` | The weight vector, of shape (n_weights,). | *required*                                                                                                                           |
| `inputs`  | \`ndarray | None\`                                    | One 1-D sample. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Name     | Type             | Description                                                                                                    |
| -------- | ---------------- | -------------------------------------------------------------------------------------------------------------- |
| `counts` | `dict[str, int]` | The number of shots for each measured bitstring. The counts add up to the number of shots set for the backend. |

Raises:

| Type                  | Description                                                                                        |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| `NotImplementedError` | On backends that do not sample. This includes every exact simulator, which has no shots to report. |

#### `statevector(weights, inputs=None, initial_state=None)`

Return the full complex statevector after the circuit runs.

It is a flat array of length `2**n_qubits`, ordered with qubit `0` as the most significant bit. A 2-D `inputs` batch returns an array of shape `(B, 2**n_qubits)`. With `initial_state`, a normalised flat statevector in the same order, the evolution starts from that state instead of `|0…0⟩`. Reading a state out and passing it back in this way is the basic operation behind the state carried over in quantum reservoirs and behind amplitude encoding.

Parameters:

| Name            | Type      | Description                               | Default                                                                                                                                            |
| --------------- | --------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `weights`       | `ndarray` | The weight vector, of shape (n_weights,). | *required*                                                                                                                                         |
| `inputs`        | \`ndarray | None\`                                    | A 1-D sample, or a 2-D batch. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |
| `initial_state` | \`ndarray | None\`                                    | The state to start the evolution from, instead of                                                                                                  |

Returns:

| Type      | Description                                                                    |
| --------- | ------------------------------------------------------------------------------ |
| `ndarray` | The statevector, of shape (2\*\*n_qubits,), or (B, 2\*\*n_qubits) for a batch. |

Raises:

| Type                  | Description                                                                                                                 |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `NotImplementedError` | On backends without access to the statevector, which is the case for most quantum processing unit (QPU) and cloud backends. |

#### `submit(weights, inputs=None)`

Submit a job without waiting for it, and return a `JobHandle`.

`handle.result()` returns the expectation value of each observable.

Parameters:

| Name      | Type      | Description                               | Default                                                                                                                              |
| --------- | --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `weights` | `ndarray` | The weight vector, of shape (n_weights,). | *required*                                                                                                                           |
| `inputs`  | \`ndarray | None\`                                    | One 1-D sample. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Name     | Type        | Description                     |
| -------- | ----------- | ------------------------------- |
| `handle` | `JobHandle` | A handle for the submitted job. |

Raises:

| Type                  | Description                                                                                                          |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `NotImplementedError` | If the CompiledCircuit of the backend does not provide submit. Currently, Braket, Qiskit Runtime and IQM provide it. |

#### `transpiled_circuit(**kwargs)`

Return the `qiskit.QuantumCircuit` that a hardware backend submits.

The circuit is transpiled for the target of the backend. Use it to draw the circuit (`.draw("mpl")`) or to inspect it.

Raises:

| Type                  | Description                                     |
| --------------------- | ----------------------------------------------- |
| `NotImplementedError` | On backends that do not transpile for a device. |

#### `zero_state_probabilities(weights, inputs)`

Compute `zero_state_probability()` for every row of a batch of inputs.

`inputs` has shape `(B, n_inputs)`, and the result is an array of shape `(B,)` of all-zeros probabilities. Backends that provide a batched `zero_state_probabilities` (such as the NumPy simulator) compute the whole batch at once. The other backends call the single-sample method once for each row. This is what lets QuantumKernel build a fidelity matrix without a Python loop over pairs of samples.

Parameters:

| Name      | Type      | Description                               | Default    |
| --------- | --------- | ----------------------------------------- | ---------- |
| `weights` | `ndarray` | The weight vector, of shape (n_weights,). | *required* |
| `inputs`  | `ndarray` | A batch, of shape (B, n_inputs).          | *required* |

Returns:

| Type      | Description                                                        |
| --------- | ------------------------------------------------------------------ |
| `ndarray` | One all-zeros probability for each row, as an array of shape (B,). |

#### `zero_state_probability(weights, inputs=None)`

Return the probability of measuring the all-zeros state after the circuit.

It is useful for quantities based on fidelity: quantum kernels built from inversion circuits, swap tests and the Bures distance. Each backend computes it in its own way. PennyLane returns `qml.probs(...)[0]`, the Qiskit and NumPy simulators return the squared amplitude of the all-zeros basis state, Braket uses its own probability result, and IQM and Qiskit Runtime estimate it from shots. This method does not go through the `Observable` path, because the value is not the expectation value of a Pauli operator.

Parameters:

| Name      | Type      | Description                               | Default                                                                                                                              |
| --------- | --------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `weights` | `ndarray` | The weight vector, of shape (n_weights,). | *required*                                                                                                                           |
| `inputs`  | \`ndarray | None\`                                    | One 1-D sample. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Type    | Description                         |
| ------- | ----------------------------------- |
| `float` | The probability, a float in [0, 1]. |

Raises:

| Type                  | Description                                                    |
| --------------------- | -------------------------------------------------------------- |
| `NotImplementedError` | If the active backend does not provide zero_state_probability. |

### `eigenkit.torch.circuit.TorchCircuit`

Bases: `Module`

A quantum layer that stores trainable weights and runs on any registered backend.

It runs through the PyTorch interface, so that PyTorch can compute the gradients of its output.

Parameters:

| Name          | Type             | Description                                                                                                                                                                                                                                    | Default                                                                                            |
| ------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `blocks`      | `_BlocksOrSpec`  | The circuit. It can be a list of blocks to assemble, a CircuitSpec that is already assembled, or a CircuitBuilder. A builder is built when the layer is created, so gates added to it afterwards are not part of this layer.                   | *required*                                                                                         |
| `observables` | `ObservableLike` | What to measure. None measures Z on every qubit and returns one value per qubit. A single Observable or PauliExpr returns a single value, and a list of them returns one value for each. Default: None.                                        | `None`                                                                                             |
| `provider`    | `ProviderSpec`   | The backend that runs the circuit. It can be a registered name, a configuration instance, or a provider object. Any backend works. A backend that computes gradients itself avoids the fallback to the parameter-shift rule. Default: "numpy". | `'numpy'`                                                                                          |
| `init`        | \`Tensor         | None\`                                                                                                                                                                                                                                         | The initial weights, of shape (n_weights,). They are random when init is not given. Default: None. |

#### `n_qubits`

The number of qubits of the circuit in `spec`.

#### `out_features`

The number of observables, or `1` for a single observable.

#### `provider`

The backend that this circuit was compiled for.

It is the same as Circuit.provider, which turns the `provider=` argument (a registered name, a configuration or a backend object) into one `Provider`. The registered name is `provider.name`. To run the same circuit on another backend, use `with_provider()`, which returns a new instance and leaves this one unchanged.

#### `spec`

The circuit that this layer runs.

It is the CircuitSpec assembled from `blocks`, or the one that was passed in already assembled.

#### `draw(*, decimals=3)`

Return a text drawing of the circuit, over several lines.

The drawing does not depend on the provider: it is built from the `CircuitSpec`, so it is the same whatever the active backend is. Print the returned string directly.

Example

```python
from eigenkit import Circuit, Encoding, Variational

blocks = [Encoding(in_features=2), Variational(depth=1)]
circuit = Circuit(blocks=blocks, interface="torch")
print(circuit.draw())
# q_0: ──RY(x_0)──RY(w_0)──●──
#                          │
# q_1: ──RY(x_1)──RY(w_1)──X──
```

Parameters:

| Name       | Type  | Description                                                                  | Default |
| ---------- | ----- | ---------------------------------------------------------------------------- | ------- |
| `decimals` | `int` | The number of significant digits shown for a numeric gate angle. Default: 3. | `3`     |

Returns:

| Type  | Description                                             |
| ----- | ------------------------------------------------------- |
| `str` | The drawing, as a single string that contains newlines. |

#### `draw_mpl(**kwargs)`

Draw the circuit as a matplotlib figure, and return `(fig, ax)`.

The picture does not depend on the provider, because it is built from the `CircuitSpec` and the observables. matplotlib is an optional dependency. Install it with `eigenkit[viz]`. The keyword arguments are passed to draw_mpl().

#### `dry_run_info()`

Describe the prepared circuit in a dictionary, without submitting it.

It is useful to check the size of the circuit, the gate counts after transpilation, the number of shots and the number of circuits that each call runs, before you submit to a cloud simulator or a quantum processing unit (QPU). It does not estimate a price. It is available only on the backends that provide it. Currently, these are Braket (on its local simulator as well as on cloud devices), Qiskit Runtime and IQM. Other backends raise a `NotImplementedError`.

#### `forward(x=None)`

Run the layer.

Parameters:

| Name | Type     | Description                                     | Default |
| ---- | -------- | ----------------------------------------------- | ------- |
| `x`  | `Tensor` | The input batch, of shape (batch, in_features). | `None`  |

Returns:

| Type     | Description                             |
| -------- | --------------------------------------- |
| `Tensor` | The output of the layer for that batch. |

#### `reset_parameters()`

Draw new circuit weights from the current PyTorch random number generator.

Every `nn.Module` that PyTorch provides defines this method. The estimators call `model.apply(reset_parameters)` after setting the seed, so that `random_state` controls the whole initialisation. Without this method, the estimators would skip the layer: two fits with different values of `random_state` would give different classical weights but exactly the same quantum weights, and a user who restarts with new seeds to escape a barren plateau would change only the part of the model that is not the circuit.

#### `sample(x=None)`

Return the raw measurement histogram for the current weights and input.

The histogram maps each bitstring to its number of shots. In each bitstring, qubit `0` is the most significant bit.

It is the second step of the workflow in which you train on a simulator and then run on hardware: you train the layer, and then you read what the device measured. It takes one sample, and its result is not differentiable, because the counts are integers that PyTorch does not record for automatic differentiation.

Parameters:

| Name | Type     | Description | Default                                                                                                                                                                 |
| ---- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `x`  | \`Tensor | None\`      | The input features of one sample, as a 1-D tensor. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Name     | Type             | Description                                      |
| -------- | ---------------- | ------------------------------------------------ |
| `counts` | `dict[str, int]` | The number of shots for each measured bitstring. |

Raises:

| Type                  | Description                     |
| --------------------- | ------------------------------- |
| `NotImplementedError` | On backends that do not sample. |
| `ValueError`          | If x is 2-D.                    |

#### `submit(x=None)`

Submit a job without waiting for it, and return a `JobHandle`.

Only the backends that provide submission support it (Braket, Qiskit Runtime and IQM).

Parameters:

| Name | Type     | Description | Default                                                                                                                                    |
| ---- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `x`  | \`Tensor | None\`      | One 1-D input sample. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Name     | Type        | Description                     |
| -------- | ----------- | ------------------------------- |
| `handle` | `JobHandle` | A handle for the submitted job. |

#### `with_provider(provider, *, copy_weights=True, init=None)`

Return a new `TorchCircuit` for another `provider`.

The new circuit has the same `spec` and the same `observables` argument as this instance, and it is compiled for the given `provider`.

`provider` accepts the same forms as in `__init__()`: a registered name (a `str`), a configuration object with a `make_backend()` method (for example `PennyLaneConfig`), or a backend instance.

The gradients follow the `gradient_method` of the new backend. The forward values should match across simulators up to numerical tolerance, but not exactly.

Parameters:

| Name           | Type           | Description                                                                                                   | Default                                                                                                                       |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `provider`     | `ProviderSpec` | The backend for the new circuit.                                                                              | *required*                                                                                                                    |
| `copy_weights` | `bool`         | If True, the new circuit starts from a copy of self.weights. It is ignored when init is given. Default: True. | `True`                                                                                                                        |
| `init`         | \`Tensor       | None\`                                                                                                        | An optional weight tensor of shape (spec.n_weights,). When it is given, it takes precedence over copy_weights. Default: None. |

Returns:

| Type             | Description           |
| ---------------- | --------------------- |
| `'TorchCircuit'` | The new TorchCircuit. |

#### `zero_state_probability(x=None)`

Return the probability of measuring the all-zeros state.

It uses the current weights and the given input. It is useful for fidelity kernels and swap tests.

It takes one sample, and its result is not differentiable. The core reads the amplitude through the backend's access to the state and returns a plain `float`, so PyTorch does not record it for automatic differentiation. A batch raises an error, because a single number cannot hold a batch axis. For a batch, call this method once for each row, or use `Circuit.zero_state_probabilities`.

Parameters:

| Name | Type     | Description | Default                                                                                                                                                                 |
| ---- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `x`  | \`Tensor | None\`      | The input features of one sample, as a 1-D tensor. None works only for a circuit that takes no inputs, and it raises a ValueError for any other circuit. Default: None. |

Returns:

| Type    | Description                                   |
| ------- | --------------------------------------------- |
| `float` | The all-zeros probability, a float in [0, 1]. |

Raises:

| Type         | Description  |
| ------------ | ------------ |
| `ValueError` | If x is 2-D. |
