## `eigenkit.core.circuit`

The core `Circuit`, which needs no framework, compiles once and accepts batches.

- The circuit is compiled once, in `__init__`, and every call (`__call__`) reuses the compiled handle.
- `inputs` can be 1-D (one sample) or 2-D (a batch, with the samples along the first axis). The array is always passed with its shape unchanged to `compiled.run` or `compiled.run_torch`, and the backend handles the shape itself. Backends that cannot run a batch at once use `batch_call`.
- The observables can be given in several forms. `None` measures `Z` on every qubit. Otherwise, pass an `Observable`, a list of them, or a `PauliExpr`.

This module holds the NumPy path: forward values computed with NumPy only, and analytic jacobians from the parameter-shift rule or the chain rule. Automatic differentiation in a framework (PyTorch now, JAX later) is handled by a registered *interface* (see `interfaces.py`), so this module imports no framework and stays in the lightweight core. The PyTorch `nn.Module` wrapper, `TorchCircuit`, is in the PyTorch package (`eigenkit.torch`).

### `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. |
