# Solving the H₂ ground-state energy with ArcQML

ArcQML is a quantum machine-learning framework implemented natively in Rust. Its core capabilities include circuit construction, state-vector simulation, observable expectations, automatic differentiation, and parameter optimization. It supports both single and batched quantum states and represents observables using Pauli operators and their linear combinations. Circuit expectations are differentiable tensors that can feed losses and other classical differentiable operations. During backpropagation, gradients traverse the complete graph to circuit parameters. For parameterized circuits, ArcQML uses adjoint differentiation to compute parameter gradients and combines them with classical optimizers such as Adam or SGD to train hybrid quantum–classical algorithms.

These capabilities support variational quantum eigensolvers, quantum neural networks, quantum-state analysis, and small-scale unitary synthesis. Rust provides native compilation, memory safety, and efficient parallelism, allowing ArcQML to combine reliability with strong computational performance.

This tutorial implements a complete variational quantum eigensolver (VQE) in ArcQML to estimate the H₂ ground-state energy for a specified geometry, basis set, and active space. It explains how to map an electronic-structure problem to a qubit Hamiltonian, construct a circuit and prepare states, evaluate energy, obtain gradients, and update parameters. Complete programs are in [`rust/h2_vqe.rs`](../../rust/h2_vqe.rs) and [`python/h2_vqe.py`](../../python/h2_vqe.py).

## 1. Preparing the environment

### 1.1 Installing Rust

The recommended Rust installer is `rustup`; see the [official Rust installation page](https://www.rust-lang.org/tools/install). ArcQML uses Rust 2024 edition and requires Rust 1.85 or newer.

#### Windows

Open the official Rust installation page, download and run `RUSTUP-INIT.EXE`, and accept the defaults. Reopen PowerShell and check the versions:

```powershell
rustc --version
cargo --version
```

#### Linux

Run the official installation command in a terminal and accept the defaults:

```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

Reopen the terminal and check the versions:

```bash
rustc --version
cargo --version
```

If `rustc` is older than 1.85, update the stable toolchain with `rustup update stable`.

### 1.2 Entering the framework directory

This tutorial assumes the framework is extracted to the generic directory below. Replace every subsequent `PATH_TO_YOUR_FILES` with your actual location:

```text
PATH_TO_YOUR_FILES/ArcQML
```

Runtime code is linked through precompiled static libraries in `libs`. Select the library matching the operating system and Rust compilation target; Windows and Linux libraries are not interchangeable.

#### Windows

The 64-bit Windows release should contain `libs/x86_64-pc-windows-msvc/arcqml_runtime_private.lib`. In PowerShell, run:

```powershell
Set-Location "PATH_TO_YOUR_FILES\ArcQML"
$env:ARCQML_RUNTIME_LIB_DIR = (Resolve-Path ".\libs\x86_64-pc-windows-msvc").Path
cargo check -p arcqml
```

#### Linux

The 64-bit Linux release should contain `libs/x86_64-unknown-linux-gnu/libarcqml_runtime_private.a`. In a terminal, run:

```bash
cd PATH_TO_YOUR_FILES/ArcQML
export ARCQML_RUNTIME_LIB_DIR="$PWD/libs/x86_64-unknown-linux-gnu"
cargo check -p arcqml
```

After setting the environment variable, keep using the same PowerShell or Linux terminal for subsequent commands. The first build may take time; Cargo reuses compiled dependencies afterward.

### 1.3 (Optional) Preparing a Conda environment for Python

To use the ArcQML Python API, install Anaconda or Miniconda, then create a Python environment matching the `.whl` file in `wheels`. The release package contains:

```text
wheels/arcqml-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
```

In the filename, `cp311` requires Python 3.11, and `manylinux...x86_64` targets 64-bit Linux. Builds for other versions and operating systems will be released as soon as possible.

#### Linux

For the file above, run the following in a terminal:

```bash
cd PATH_TO_YOUR_FILES/ArcQML

# Create the arcqml-example Python 3.11 environment and install pip
conda create --name arcqml-example python=3.11 pip -y

# Activate it; subsequent Python and pip commands run in this environment
conda activate arcqml-example

# Install the ArcQML wheel matching the current platform from local wheels
python -m pip install ./wheels/arcqml-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

# Verify that Python can import ArcQML
python -c "import arcqml; print('ArcQML imported successfully')"
```

The output `ArcQML imported successfully` confirms installation.

## 2. The problem VQE solves

### 2.1 Complete computation workflow

A molecule can occupy states with different energies; the lowest-energy state is its ground state. Ground-state energy is fundamental to predicting molecular stability, chemical reactions, and material properties. As systems grow, the storage and computation needed to find the lowest energy of large molecules directly on a classical computer increase exponentially. A variational quantum eigensolver (VQE) uses a quantum device to prepare parameterized states and measure energy expectations, and a classical computer to update circuit parameters. This hybrid approach avoids the huge cost of storing and diagonalizing the full high-dimensional matrix classically.

VQE is a hybrid quantum–classical algorithm for estimating a quantum-system ground-state energy. For Hamiltonian $H$ with smallest eigenvalue $E_0$, the variational principle gives, for any normalized state $\lvert\psi\rangle$:

$$
\langle\psi\rvert H\lvert\psi\rangle\ge E_0.
$$

With parameters $\boldsymbol{\theta}$, construct a circuit $U(\boldsymbol{\theta})$. Choose an easily prepared reference state $\lvert\phi_{\mathrm{0}}\rangle$ and evolve it into a parameterized state $\lvert\psi(\boldsymbol{\theta})\rangle$:

$$
\lvert\psi(\boldsymbol{\theta})\rangle
=U(\boldsymbol{\theta})\lvert\phi_{\mathrm{0}}\rangle.
$$

In quantum chemistry, the electronic Hamiltonian is usually written in second-quantized form using fermionic creation and annihilation operators. After selecting active electrons and orbitals, a fermion-to-qubit mapping such as Jordan–Wigner transforms it into a qubit Hamiltonian:

$$
H=\sum_j c_jP_j,
$$

Here $c_j$ are real coefficients and $P_j$ are Pauli strings composed of X, Y, Z, and identity operators. The objective is the Hamiltonian energy expectation in the parameterized state:

$$
E(\boldsymbol{\theta})=
\langle\psi(\boldsymbol{\theta})\rvert
H
\lvert\psi(\boldsymbol{\theta})\rangle
=\sum_j c_j
\langle\psi(\boldsymbol{\theta})\rvert
P_j
\lvert\psi(\boldsymbol{\theta})\rangle.
$$

A classical optimizer iteratively updates parameters from measurement results to reduce $E(\boldsymbol{\theta})$. The minimum found is a variational upper bound on $E_0$. If the circuit can represent the ground state and optimization converges sufficiently, the result can approach the true ground-state energy.

### 2.2 Chemistry settings

This example uses the following quantum-chemistry settings:

| Setting | Value |
| --- | --- |
| Molecular geometry | H `(0, 0, -0.35 Å)`, H `(0, 0, 0.35 Å)` |
| Interatomic distance | `0.70 Å` |
| Basis set | STO-3G |
| Active electrons | 2 |
| Active orbitals | 2 |
| Fermionic mapping | Jordan–Wigner |
| Qubits | 4 |
| Hamiltonian terms | 15 |
| Fixed-particle-number subspace dimension | 6 |
| Hartree–Fock energy | `-1.1173490350562805 Ha` |
| Exact ground-state energy | `-1.1361894542078266 Ha` |

## 3. Initial configuration

First import the required ArcQML and standard-library interfaces:

```rust
use arcqml::prelude::*;

// `AppResult<T>` returns T when the function succeeds and a printable error on failure.
// The `?` below propagates errors to the caller.
type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
```

```python
from __future__ import annotations

import json
import math
from pathlib import Path

import arcqml
```

The main numerical settings are declared together as constants:

```rust
const NUM_QUBITS: usize = 4;       // The H₂ mapping requires 4 qubits
const ACTIVE_ELECTRONS: usize = 2; // The Hartree–Fock initial state occupies the first two positions
const LAYERS: usize = 6;           // Repeat the trainable circuit for six layers
const STEPS: usize = 100;          // Update parameters 100 times

const EXACT_GROUND_ENERGY: f64 = -1.136_189_454_207_826_6;    // Exact ground-state energy
const HARTREE_FOCK_ENERGY: f64 = -1.117_349_035_056_280_5;    // Hartree–Fock energy (initial-state energy)

const CHEMICAL_ACCURACY: f64 = 1.6e-3;    // Chemical-accuracy error threshold
```

```python
NUM_QUBITS = 4       # The H₂ mapping requires 4 qubits
ACTIVE_ELECTRONS = 2 # The Hartree–Fock initial state occupies the first two positions
LAYERS = 6           # Repeat the trainable circuit for six layers
STEPS = 100          # Update parameters 100 times

EXACT_GROUND_ENERGY = -1.136_189_454_207_826_6    # Exact ground-state energy
HARTREE_FOCK_ENERGY = -1.117_349_035_056_280_5    # Hartree–Fock energy (initial-state energy)

CHEMICAL_ACCURACY = 1.6e-3    # Chemical-accuracy error threshold
```

The layer and training-step counts are example hyperparameters that can be adjusted in later experiments.

## 4. Constructing the Hamiltonian

The Hamiltonian was generated with the third-party quantum-computing tool PennyLane. After the Jordan–Wigner transform, H₂ has a real-coefficient linear combination of 15 Pauli strings. ArcQML stores these terms in Rust `SparsePauliOp` or Python `PauliSum`. Pauli X, Y, and Z are basic operators for single-qubit transformations or measurements; their weighted sum represents the molecular energy here. Rust reads the JSON at compile time with `include_str!`, while Python reads it at runtime and constructs each term:

```rust
let hamiltonian = SparsePauliOp::from_json(include_str!(
    "../data/h2_hamiltonian.json"
))?;

// Ensure the imported Hamiltonian has 4 qubits and 15 terms
assert_eq!(hamiltonian.num_qubits(), 4);
assert_eq!(hamiltonian.len(), 15);
```

```python
DATA_PATH = Path(__file__).resolve().parents[1] / "data" / "h2_hamiltonian.json"


def load_hamiltonian(path: Path) -> arcqml.PauliSum:
    # The public Python API cannot yet load JSON directly, so build the PauliSum term by term.
    payload = json.loads(path.read_text(encoding="utf-8"))
    hamiltonian = arcqml.PauliSum(num_qubits=payload["num_qubits"])

    for term in payload["terms"]:
        coefficient = term["coefficient"]
        operations = term["paulis"]
        if not operations:
            # Identity operator
            hamiltonian.add_identity(coefficient=coefficient)
            continue
        hamiltonian.add_term(
            paulis="".join(operation["pauli"] for operation in operations),
            qubits=[operation["qubit"] for operation in operations],
            coefficient=coefficient,
        )
    return hamiltonian


hamiltonian = load_hamiltonian(path=DATA_PATH)
# Ensure the imported Hamiltonian has 4 qubits and 15 terms
assert hamiltonian.num_qubits == 4
assert hamiltonian.num_terms == 15
```

ArcQML JSON has top-level `num_qubits` and `terms` fields. Each Pauli operation explicitly records `qubit` and `pauli`. For example, the constant term and `X(q0)X(q1)Y(q2)Y(q3)` term are:

```json
{
  "num_qubits": 4,
  "terms": [
    {
      "coefficient": -0.042078985845795724,
      "paulis": []
    },
    {
      "coefficient": -0.04475014386992153,
      "paulis": [
        { "qubit": 0, "pauli": "X" },
        { "qubit": 1, "pauli": "X" },
        { "qubit": 2, "pauli": "Y" },
        { "qubit": 3, "pauli": "Y" }
      ]
    }
  ]
}
```

An empty `paulis` array denotes the identity operator.

## 5. Preparing the initial quantum state

`Circuit::new(4)` creates a four-qubit circuit. The simulator starts in $\lvert0000\rangle$, with all four positions unoccupied. Under Jordan–Wigner mapping, the two active electrons here occupy the two lowest-numbered positions, so X gates on qubits 0 and 1 flip those bits from 0 to 1:

```rust
let mut circuit = Circuit::new(NUM_QUBITS)?;

// Apply X gates to qubits 0 and 1 in order
for qubit in 0..ACTIVE_ELECTRONS {
    circuit.x(qubit)?;
}
```

```python
circuit = arcqml.Circuit(num_qubits=NUM_QUBITS)

# Apply X gates to qubits 0 and 1 in order
for qubit in range(ACTIVE_ELECTRONS):
    circuit.x(qubit=qubit)
```

ArcQML displays state strings in descending qubit order, $q_3q_2q_1q_0$. Setting qubits 0 and 1 to 1 therefore displays $\lvert0011\rangle$.

## 6. Constructing the variational circuit

### 6.1 Initializing angles

The initialization formula is:

$$
\theta_k=0.04\sin(0.37k),\qquad k=1,2,\ldots,48.
$$

Here `0.04` is the angular amplitude in radians. It restricts every initial rotation angle to $[-0.04,0.04]$, avoiding large single-qubit rotations at the start. The fixed phase increment `0.37` gives neighboring parameters different asymmetric initial values, rather than setting all 48 angles identically. The sine function varies angles smoothly with parameter index and produces both signs.

### 6.2 Building the circuit

The example uses 6 layers of a hardware-efficient parameterized circuit. Each layer applies RY and RZ to every qubit, then a ring of CNOT entanglers:

```rust
let mut parameter_index = 0usize;

for _layer in 0..LAYERS {
    for qubit in 0..NUM_QUBITS {
        // Initialize angles
        let ry = 0.04 * (0.37 * (parameter_index + 1) as f64).sin();
        parameter_index += 1;

        let rz = 0.04 * (0.37 * (parameter_index + 1) as f64).sin();
        parameter_index += 1;

        // The ry and rz functions register their angles as trainable parameters.
        circuit.ry(ry, qubit)?;
        circuit.rz(rz, qubit)?;
    }

    for control in 0..NUM_QUBITS {
        // Ring of CNOT entangling gates.
        let target = (control + 1) % NUM_QUBITS;
        circuit.cnot(control, target)?;
    }
}
```

```python
parameter_index = 0

for _layer in range(LAYERS):
    for qubit in range(NUM_QUBITS):
        # Initialize angles
        ry = 0.04 * math.sin(0.37 * (parameter_index + 1))
        parameter_index += 1

        rz = 0.04 * math.sin(0.37 * (parameter_index + 1))
        parameter_index += 1

        # The ry and rz functions register their angles as trainable parameters.
        circuit.ry(angle=ry, qubit=qubit)
        circuit.rz(angle=rz, qubit=qubit)

    for control in range(NUM_QUBITS):
        # Ring of CNOT entangling gates.
        circuit.cnot(
            control=control,
            target=(control + 1) % NUM_QUBITS,
        )
```

The circuit structure is shown below:

![Circuit structure](../../rust/h2_vqe_circuit.svg)

This hardware-efficient circuit does not strictly preserve particle number. It demonstrates general VQE and ArcQML autograd. Chemistry applications can instead use particle-number-preserving excitation circuits.

## 7. Creating the simulator and optimizer

### 7.1 State-vector simulator

```rust
let simulator = StateVectorSimulator::new(NUM_QUBITS)?;
```

```python
simulator = arcqml.StateVectorSimulator(num_qubits=NUM_QUBITS)
```

The simulator executes the complete circuit from $\lvert0000\rangle$. Hartree–Fock preparation gates are placed first, so every evaluation prepares $\lvert0011\rangle$ before applying the trainable circuit.

Neither Rust `simulator.run(&circuit, &hamiltonian)` nor Python `simulator.run(circuit=circuit, observable=hamiltonian)` permanently changes the stored initial state. One simulator can therefore be reused throughout training.

### 7.2 Adam optimizer

Ordinary gradient descent uses one fixed update scale for all parameters. Adam tracks recent averages of gradients and squared gradients separately for each parameter, adapting its effective update magnitude.

```rust
let mut optimizer = Adam::new(
    0.05,  // Learning rate
    0.9,   // beta1
    0.999, // beta2
    1e-8,  // epsilon
    0.0,   // weight_decay
)?;
```

```python
# Other optional parameters use the same defaults as the Rust example.
optimizer = arcqml.Adam(learning_rate=0.05)
```

These hyperparameters are not suitable for every molecule and circuit. Retune them if training performs poorly.

## 8. Running VQE optimization

Each training iteration has four steps:

1. Evaluate the current energy in a forward pass.
2. Backpropagate from the energy to obtain gradients of all 48 parameters.
3. Update parameters with Adam.
4. Clear the current gradients to prevent accumulation with the next iteration.

```rust
for step in 1..=STEPS {
    // Compute the current energy in the forward pass.
    let energy = simulator.run(&circuit, &hamiltonian)?;

    // Compute gradients for all trainable angles in the backward pass
    energy.backward()?;

    // The optimizer reads gradients and updates parameters
    optimizer.step(circuit.parameters())?;

    // Clear the current gradients.
    optimizer.zero_grad(circuit.parameters());

    // Print the updated energy at step 1 and every 10 steps thereafter.
    if step == 1 || step % 10 == 0 {
        let current = simulator.run(&circuit, &hamiltonian)?.value()?;
        println!("step {step:>3}: energy = {current:.12} Ha");
    }
}
```

```python
for step in range(1, STEPS + 1):
    # Compute the current energy in the forward pass.
    energy_tensor = simulator.run(circuit=circuit, observable=hamiltonian)

    # Compute gradients for all trainable angles in the backward pass
    energy_tensor.backward()

    # The optimizer reads gradients and updates parameters
    optimizer.step(circuit=circuit)

    # Clear the current gradients.
    optimizer.zero_grad(circuit=circuit)

    # Print the updated energy at step 1 and every 10 steps thereafter.
    if step == 1 or step % 10 == 0:
        with arcqml.no_grad():
            current = simulator.run(
                circuit=circuit,
                observable=hamiltonian,
            ).item()
        print(f"step {step:>3}: energy = {current:.12f} Ha")
```

`run` returns a `Tensor`; when `backward()` is called, ArcQML uses the recorded graph to compute gradients. Parameters have no gradients before the first iteration, so no initial `zero_grad` is needed. After the first backward pass, clear gradients every iteration.

## 9. Running the complete example

### 9.1 Rust version

In Section 1.2, you set `ARCQML_RUNTIME_LIB_DIR`; use that same Windows PowerShell or Linux terminal to run:

```text
cargo run --release -p arcqml --example h2_vqe
```

### 9.2 Python version

Install the wheel and activate the Conda environment as in Section 1.3, then run from the ArcQML root:

```text
conda activate arcqml-example
python python/h2_vqe.py
```

### 9.3 Example output

Every 10 steps, the program prints the energy and its absolute error against the exact ground-state energy. It finally checks whether chemical accuracy has been reached:

```text
H2/STO-3G, Jordan-Wigner, 4 qubits
Hamiltonian terms : 15
Trainable params  : 48
Hartree-Fock      : -1.117349035056 Ha
Exact ground      : -1.136189454208 Ha
step   1: energy = -0.401257301811 Ha, |error| = 7.349e-1 Ha
...
step 100: energy = -1.136145412038 Ha, |error| = 4.404e-5 Ha

VQE energy        : -1.136145412038 Ha
Absolute error    : 4.404217e-5 Ha
Chemical accuracy: reached (threshold 1.6e-3 Ha)
```


## 10. Interpreting the results

- Energy should generally converge downward from its initial value and, under the variational principle, should not fall substantially below the exact ground-state energy.
- The Hartree–Fock to exact-energy gap is about `0.0188404 Ha`, mainly due to electron correlation.
- An absolute error below `1.6 × 10⁻³ Ha` is commonly called chemical accuracy.
- If 100 steps are insufficient, increase training steps, tune the learning rate, or add circuit layers. Deeper circuits can also increase optimization plateaus or parameter redundancy.

## 11. Next steps

Try extending the example:

- Replace `Adam` with `Sgd` and compare convergence speed.
- Use particle-number-preserving excitation circuits to restrict search to the 6-dimensional two-electron subspace.
- Save trained circuit weights and restore them in a new process with `load_weights`.
- Vary the H–H bond length to compute a potential-energy curve.
