Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

yao-rs

Quantum circuit description and tensor network export in Rust.

What is yao-rs?

yao-rs is a library for describing quantum circuits and exporting them as tensor networks. It provides a type-safe circuit construction API with validation, and converts circuits into einsum representations suitable for contraction order optimization via omeco.

Ported from the Julia library Yao.jl, focused on the circuit description and tensor network layers.

Module Architecture

Core Simulation Tensor Export Utilities Higher-level Visualization
Click a module to expand/collapse its public items. Double-click to open rustdoc.

Why Tensor Network Export?

Tensor networks provide an alternative to full state-vector simulation. Instead of tracking the entire 2^n-dimensional state vector, a circuit is decomposed into a network of small tensors. The contraction order determines computational cost — and can make an exponential difference:

ApproachMemoryScaling
State vectorO(2^n)Exponential in qubits
Tensor networkDepends on orderCan be much better for structured circuits

yao-rs further optimizes by recognizing diagonal gates (Z, S, T, Phase, Rz), which reduce tensor rank in the network.

Key Features

  • Circuit Description: put/control builder API with qudit support
  • Tensor Network Export: circuit_to_einsum with diagonal gate optimization
  • Contraction Optimization: Integration with omeco
  • State-Vector Simulation: Direct apply for verification

Example

#![allow(unused)]
fn main() {
use yao_rs::{Gate, Circuit, State, put, control, apply, circuit_to_einsum};

// Build a Bell circuit
let circuit = Circuit::new(vec![2, 2], vec![
    put(vec![0], Gate::H),
    control(vec![0], vec![1], Gate::X),
]).unwrap();

// Simulate
let state = State::zero_state(&[2, 2]);
let result = apply(&circuit, &state);

// Export as tensor network
let tn = circuit_to_einsum(&circuit);
println!("Tensors: {}, Labels: {}", tn.tensors.len(), tn.size_dict.len());
}

Next Steps

Getting Started

This guide walks you through installing yao-rs and building your first quantum circuit.

Installation

Add yao-rs to your project’s Cargo.toml:

[dependencies]
yao-rs = { git = "https://github.com/QuantumBFS/yao-rs" }

The crate uses Rust edition 2024 and depends on:

  • num-complex for complex number arithmetic
  • ndarray for multi-dimensional arrays
  • omeco for tensor network contraction

Your First Circuit: Bell State

Let’s build a Bell circuit that entangles two qubits. The circuit applies a Hadamard gate on qubit 0, followed by a CNOT gate with qubit 0 as control and qubit 1 as target:

use yao_rs::{Gate, Circuit, State, put, control, apply};

fn main() {
    // Build a Bell circuit: H on qubit 0, then CNOT
    let gates = vec![
        put(vec![0], Gate::H),
        control(vec![0], vec![1], Gate::X),
    ];
    let circuit = Circuit::new(vec![2, 2], gates).unwrap();

    // Apply to |00⟩
    let state = State::zero_state(&[2, 2]);
    let result = apply(&circuit, &state);

    // Print amplitudes
    for i in 0..result.total_dim() {
        let amp = result.data[i];
        if amp.norm() > 1e-10 {
            println!("|{:02b}⟩: {:.4} + {:.4}i", i, amp.re, amp.im);
        }
    }
}

This produces the Bell state (|00> + |11>)/sqrt(2), one of the four maximally entangled two-qubit states. You should see non-zero amplitudes only for the |00> and |11> basis states, each with magnitude 1/sqrt(2).

Exporting as a Tensor Network

yao-rs can export a circuit as a tensor network using Einstein summation notation. This is useful for analyzing circuit structure or contracting the network with custom strategies:

#![allow(unused)]
fn main() {
use yao_rs::circuit_to_einsum;

let tn = circuit_to_einsum(&circuit);
println!("Tensors: {}", tn.tensors.len());
println!("Labels: {:?}", tn.size_dict);
}

The returned tensor network contains the gate tensors and their index labels, along with a size dictionary mapping each index to its dimension.

Running the QFT Example

The repository includes a Quantum Fourier Transform example that you can run directly:

cargo run --example qft

This builds a 4-qubit QFT circuit and applies it to various input states, demonstrating how the QFT maps computational basis states to uniform superpositions with structured phases.

Gates

Gates in yao-rs are represented by the Gate enum, covering standard qubit gates, parameterized rotations, and custom matrices.

#![allow(unused)]
fn main() {
pub enum Gate {
    X, Y, Z, H, S, T, SWAP,
    Phase(f64),
    Rx(f64), Ry(f64), Rz(f64),
    Custom { matrix: Array2<Complex64>, is_diagonal: bool },
}
}

Named Qubit Gates

GateMatrixDiagonal
X[[0, 1], [1, 0]]No
Y[[0, -i], [i, 0]]No
Z[[1, 0], [0, -1]]Yes
H1/sqrt(2) [[1, 1], [1, -1]]No
S[[1, 0], [0, i]]Yes
T[[1, 0], [0, e^(i*pi/4)]]Yes

SWAP Gate

The 2-qubit Gate::SWAP acts on 2 sites. Its 4x4 matrix swaps the |01> and |10> basis states.

Rotation Gates

  • Rx(theta): [[cos(theta/2), -i*sin(theta/2)], [-i*sin(theta/2), cos(theta/2)]]
  • Ry(theta): [[cos(theta/2), -sin(theta/2)], [sin(theta/2), cos(theta/2)]]
  • Rz(theta): [[e^(-i*theta/2), 0], [0, e^(i*theta/2)]] – diagonal

Phase Gate

Gate::Phase(theta) represents diag(1, e^(i*theta)). This is equivalent to Yao.jl’s shift(theta).

Special cases:

  • Phase(pi) = Z
  • Phase(pi/2) = S
  • Phase(pi/4) = T

The Phase gate is diagonal.

Custom Gates

#![allow(unused)]
fn main() {
Gate::Custom {
    matrix: Array2<Complex64>,
    is_diagonal: bool,
}
}

Provide an arbitrary unitary matrix. The is_diagonal flag tells the tensor network exporter to use the diagonal optimization (shared legs instead of separate input/output legs).

The matrix dimension determines how many sites the gate acts on: a d^n x d^n matrix acts on n sites of dimension d.

Diagonal Gate Optimization

In tensor networks, diagonal gates have one shared leg per site instead of separate input and output legs. This reduces the tensor rank and can improve contraction efficiency.

Gates that are diagonal: Z, S, T, Phase(theta), Rz(theta), and any Custom gate with is_diagonal: true.

Getting the Matrix

#![allow(unused)]
fn main() {
let mat = Gate::H.matrix(2);  // d=2 for qubits
}

Named gates require d=2 (will panic otherwise). Custom gates work with any dimension.

Circuits

A Circuit represents a sequence of positioned gates applied to a register of qudits. Each gate in the circuit is wrapped in a PositionedGate that specifies which sites the gate acts on and which sites control its activation.

PositionedGate

#![allow(unused)]
fn main() {
pub struct PositionedGate {
    pub gate: Gate,
    pub target_locs: Vec<usize>,
    pub control_locs: Vec<usize>,
    pub control_configs: Vec<bool>,
}
}
  • gate: The gate to apply.
  • target_locs: Sites the gate matrix acts on (0-indexed).
  • control_locs: Sites that control the gate activation.
  • control_configs: Which state triggers each control (true = |1>).

Builder API

put

Places a gate on target locations with no controls.

#![allow(unused)]
fn main() {
use yao_rs::{put, Gate};

// H gate on qubit 0
let h = put(vec![0], Gate::H);

// SWAP on qubits 1 and 2
let swap = put(vec![1, 2], Gate::SWAP);
}

control

Places a controlled gate. All controls are active-high, triggering on |1>.

#![allow(unused)]
fn main() {
use yao_rs::{control, Gate};

// CNOT: control on qubit 0, X on qubit 1
let cnot = control(vec![0], vec![1], Gate::X);

// Toffoli: controls on qubits 0,1, X on qubit 2
let toffoli = control(vec![0, 1], vec![2], Gate::X);
}

Note: All controls are active-high (trigger on |1>). The control_configs are automatically set to vec![true; ctrl_locs.len()].

Building a Circuit

#![allow(unused)]
fn main() {
use yao_rs::{Circuit, Gate, put, control};

let gates = vec![
    put(vec![0], Gate::H),
    control(vec![0], vec![1], Gate::X),
];
let circuit = Circuit::new(vec![2, 2], gates).unwrap();
}

Circuit::new validates all gates and returns Result<Circuit, CircuitError>.

Validation Rules

The 6 validation rules checked by Circuit::new:

  1. control_configs length must match control_locs length — Each control site needs a configuration.
  2. All locations must be in range — Every loc in target_locs and control_locs must be < dims.len().
  3. No overlap between target and control — A site cannot be both a target and a control.
  4. Control sites must be qubits (d=2) — Controlled gates only support qubit control sites.
  5. Named gate targets must be qubits — Non-Custom gates require target sites with d=2.
  6. Gate matrix size must match target dimensions — The gate’s matrix dimension must equal the product of target site dimensions.

Example of a validation error:

#![allow(unused)]
fn main() {
use yao_rs::{Circuit, Gate, put};

// This fails: location 5 is out of range for a 2-qubit circuit
let result = Circuit::new(vec![2, 2], vec![put(vec![5], Gate::H)]);
assert!(result.is_err());
}

Qudit Support

The dims vector specifies per-site dimensions. For qubits use 2, for qutrits use 3, etc.

#![allow(unused)]
fn main() {
// Mixed qubit-qutrit circuit
let dims = vec![2, 3, 2]; // qubit, qutrit, qubit
}

Custom gates can target non-qubit sites, but named gates (X, Y, Z, H, etc.) require d=2.

States

A State represents a quantum state vector over a register of qudits. Each qudit has a local dimension (2 for qubits, 3 for qutrits, etc.), and the full state vector lives in the tensor product of the individual Hilbert spaces.

State Vector Representation

The State struct has two fields:

  • dims: a vector of local dimensions, one per site. For example, [2, 2, 2] describes three qubits.
  • data: a complex amplitude vector of length dims[0] * dims[1] * ... * dims[n-1].

States are stored as flat vectors in row-major order. The amplitude of a computational basis state |i_0, i_1, ..., i_{n-1}> is found at a single flat index computed from the local indices and dimensions.

Creating States

Zero State

zero_state creates the all-zeros computational basis state |0,0,...,0>, which has amplitude 1 at index 0 and amplitude 0 everywhere else.

#![allow(unused)]
fn main() {
use yao_rs::State;

// 3-qubit zero state |000>
let state = State::zero_state(&[2, 2, 2]);
assert_eq!(state.total_dim(), 8);
assert_eq!(state.data[0].re, 1.0); // |000> amplitude = 1
}

Product State

product_state creates a computational basis state |i_0, i_1, ..., i_{n-1}> where each qudit is in a definite level.

#![allow(unused)]
fn main() {
use yao_rs::State;

// |01> on 2 qubits
let state = State::product_state(&[2, 2], &[0, 1]);
// Index = 0*2 + 1 = 1, so data[1] = 1
assert_eq!(state.data[1].re, 1.0);
}

Row-Major Index Ordering

The flat index for state |i_0, i_1, ..., i_{n-1}> is computed as:

index = i_0 * (d_1 * d_2 * ... * d_{n-1})
      + i_1 * (d_2 * ... * d_{n-1})
      + ...
      + i_{n-1}

Example for 2 qubits (d=2 each):

StateIndex
|00>0
|01>1
|10>2
|11>3

Multi-Qudit Examples

The state representation generalizes beyond qubits. Any combination of local dimensions is supported.

#![allow(unused)]
fn main() {
use yao_rs::State;

// Qutrit (d=3): |2>
let state = State::product_state(&[3], &[2]);
assert_eq!(state.data[2].re, 1.0);

// Qubit + qutrit: |1,2>
// Index = 1*3 + 2 = 5
let state = State::product_state(&[2, 3], &[1, 2]);
assert_eq!(state.total_dim(), 6);
assert_eq!(state.data[5].re, 1.0);
}

Applying Circuits

Once you have a state, you can evolve it by applying a circuit with the apply function.

#![allow(unused)]
fn main() {
use yao_rs::{State, Circuit, Gate, put, apply};

let circuit = Circuit::new(vec![2, 2], vec![put(vec![0], Gate::X)]).unwrap();
let state = State::zero_state(&[2, 2]);
let result = apply(&circuit, &state);
// X flips qubit 0: |00> -> |10>
assert_eq!(result.data[2].re, 1.0);
}

The apply function performs O(2^n) state-vector simulation using an instruction-based, in-place backend (it does not construct the full 2^n × 2^n matrix). It returns a new State with preserved norm; to update a state in place, use apply_inplace.

Tensor Networks

What is a Tensor Network?

A tensor network represents a quantum circuit as a collection of tensors connected by shared indices. Each gate becomes a tensor, and wires between gates become shared indices (labels). The circuit’s output state is obtained by contracting (summing over) all internal indices – this is equivalent to an einsum operation.

This representation is useful because:

  • It separates the circuit’s structure (the contraction pattern) from the gate data (the tensor values).
  • Contraction order optimization can dramatically reduce computational cost.
  • Diagonal gates naturally simplify to lower-rank tensors.

The circuit_to_einsum Function

The entry point for tensor network export is circuit_to_einsum, which converts a Circuit into a TensorNetwork:

#![allow(unused)]
fn main() {
use yao_rs::{Circuit, Gate, put, control, circuit_to_einsum};

let circuit = Circuit::new(vec![2, 2], vec![
    put(vec![0], Gate::H),
    control(vec![0], vec![1], Gate::X),
]).unwrap();

let tn = circuit_to_einsum(&circuit);
}

TensorNetwork Struct

The returned TensorNetwork has three fields:

  • code: EinCode<usize> – The einsum contraction code from omeco. Contains input index lists (one per tensor) and the output indices.
  • tensors: Vec<ArrayD<Complex64>> – The tensor data for each gate, stored as n-dimensional complex arrays.
  • size_dict: HashMap<usize, usize> – Maps each label to its dimension (e.g., 2 for qubits).

Label Assignment Algorithm

The algorithm walks through the circuit gates in order, assigning integer labels to tensor legs:

  1. Initial labels 0..n-1 represent the input state indices for each site (one label per site).

  2. For each gate in order:

    • Diagonal gate (no controls): The tensor uses the current labels of its target sites. No new labels are allocated. Current labels are unchanged.
    • Non-diagonal gate (or gate with controls): New output labels are allocated for all involved sites (controls and targets). The tensor’s indices are [new_output_labels..., current_input_labels...]. Current labels are updated to the new output labels.
  3. After all gates: The final current labels become the output indices of the einsum.

Labels that appear in multiple tensors’ index lists are internal (contracted) indices. Labels that only appear once and in the output are external (open) indices.

Diagonal vs Non-Diagonal Gates

The Leg enum describes how each axis of a gate tensor is interpreted:

#![allow(unused)]
fn main() {
pub enum Leg {
    Out(usize),  // Output leg for site at given index
    In(usize),   // Input leg for site at given index
    Diag(usize), // Shared (diagonal) leg for site at given index
}
}

Non-diagonal gates

Shape: (d_0_out, d_1_out, ..., d_0_in, d_1_in, ...)

The tensor has separate input and output legs for each involved site. This represents a general linear map. The legs are ordered as [Out(0), Out(1), ..., In(0), In(1), ...].

Diagonal gates (no controls)

Shape: (d_0, d_1, ...)

The tensor has one shared leg per target site (the diagonal elements only). Since the gate is diagonal, the input and output share the same index – no new label is needed. The legs are all [Diag(0), Diag(1), ...].

Why this matters: Fewer legs means simpler contraction and potentially better contraction orders. A single-qubit diagonal gate is a rank-1 tensor (a vector) instead of a rank-2 tensor (a matrix).

Example: Bell Circuit

Tracing through a Bell circuit (H followed by CNOT on 2 qubits):

Sites: 0, 1        (both dimension 2)
Initial labels: [0, 1]

Gate 1: H on site 0 (non-diagonal)
  → Allocate new label 2 for site 0
  → Tensor indices: [2, 0] (out, in)
  → Current labels: [2, 1]

Gate 2: CNOT = control(0, 1, X) (non-diagonal, has controls)
  → All locs = [0, 1] (control 0, target 1)
  → Allocate new labels 3, 4 for sites 0, 1
  → Tensor indices: [3, 4, 2, 1] (out_0, out_1, in_0, in_1)
  → Current labels: [3, 4]

Output labels: [3, 4]
EinCode: [[2, 0], [3, 4, 2, 1]] → [3, 4]

The internal indices (2) connect H’s output to CNOT’s input on site 0. Index 1 connects the initial state of site 1 to CNOT’s input on site 1. The output indices [3, 4] are the open legs of the final state.

Using omeco for Contraction

The EinCode from omeco can be used to find optimal contraction orders:

#![allow(unused)]
fn main() {
use yao_rs::{Circuit, Gate, put, control, circuit_to_einsum};

let circuit = Circuit::new(vec![2, 2], vec![
    put(vec![0], Gate::H),
    control(vec![0], vec![1], Gate::X),
]).unwrap();

// The TensorNetwork's code field is an EinCode
let tn = circuit_to_einsum(&circuit);
println!("EinCode: {:?}", tn.code);
println!("Size dict: {:?}", tn.size_dict);
}

The EinCode encodes the full contraction pattern. You can use omeco’s optimization methods to find efficient contraction orders before performing the actual tensor contraction.

Quantum Fourier Transform

This page walks through the QFT example found in examples/qft.rs, explaining the algorithm, the circuit construction, and the expected output.

The QFT Algorithm

The Quantum Fourier Transform maps computational basis states to the frequency domain. For an n-qubit state |j>:

QFT|j> = (1/sqrt(2^n)) sum_k e^(2 pi i j k / 2^n) |k>

The circuit implementation consists of:

  1. For each qubit i (from 0 to n-1):
    • Apply H gate to qubit i
    • Apply controlled Phase(2 pi / 2^(j+1)) gates from qubit i+j controlling qubit i, for j = 1, 2, …, n-i-1
  2. Reverse qubit order with SWAP gates

Building the Circuit

The full circuit builder function:

#![allow(unused)]
fn main() {
use std::f64::consts::PI;
use yao_rs::{Gate, Circuit, State, put, control, apply, circuit_to_einsum};
use yao_rs::circuit::PositionedGate;

fn qft_circuit(n: usize) -> Circuit {
    let mut gates: Vec<PositionedGate> = Vec::new();

    for i in 0..n {
        gates.push(put(vec![i], Gate::H));
        for j in 1..(n - i) {
            let theta = 2.0 * PI / (1 << (j + 1)) as f64;
            gates.push(control(vec![i + j], vec![i], Gate::Phase(theta)));
        }
    }

    for i in 0..(n / 2) {
        gates.push(PositionedGate::new(
            Gate::SWAP,
            vec![i, n - 1 - i],
            vec![],
            vec![],
        ));
    }

    Circuit::new(vec![2; n], gates).unwrap()
}
}

Let’s break this down step by step.

Phase Rotations

#![allow(unused)]
fn main() {
for i in 0..n {
    gates.push(put(vec![i], Gate::H));
    for j in 1..(n - i) {
        let theta = 2.0 * PI / (1 << (j + 1)) as f64;
        gates.push(control(vec![i + j], vec![i], Gate::Phase(theta)));
    }
}
}

For n=4 qubits, this generates:

  • Qubit 0: H, then Phase(pi/2) controlled by qubit 1, Phase(pi/4) controlled by qubit 2, Phase(pi/8) controlled by qubit 3
  • Qubit 1: H, then Phase(pi/2) controlled by qubit 2, Phase(pi/4) controlled by qubit 3
  • Qubit 2: H, then Phase(pi/2) controlled by qubit 3
  • Qubit 3: H

The put helper places a single-qubit gate on the specified qubit, while the control helper creates a controlled gate with specified control qubits, target qubits, and the gate to apply.

Bit Reversal

#![allow(unused)]
fn main() {
for i in 0..(n / 2) {
    gates.push(PositionedGate::new(
        Gate::SWAP, vec![i, n - 1 - i], vec![], vec![],
    ));
}
}

Swaps qubit 0 with qubit 3 and qubit 1 with qubit 2 to match the standard QFT convention. The QFT algorithm naturally produces output in bit-reversed order, so these SWAP gates correct the ordering.

Running the Example

cargo run --example qft

The main function constructs a 4-qubit QFT circuit, applies it to an input state, and prints the resulting amplitudes along with the tensor network structure:

fn main() {
    let n = 4;
    let circuit = qft_circuit(n);
    let state = State::zero_state(&vec![2; n]);
    let result = apply(&circuit, &state);
    let tn = circuit_to_einsum(&circuit);
    // ... (prints results)
}

Output shows the amplitudes for both input states and the tensor network structure (number of tensors and labels).

Expected Output

QFT|0000>

Applying QFT to the all-zeros state produces a uniform superposition:

All 16 amplitudes = 1/sqrt(16) = 0.25

This is because QFT|0> = (1/sqrt(N)) sum_k |k>.

QFT|0001>

Produces amplitudes with phase progression e^(2 pi i k / 16). All amplitudes have magnitude 1/sqrt(16), but with varying phases.

Tensor Network Structure

For 4-qubit QFT:

  • The circuit has 4 H gates + 6 controlled-Phase gates + 2 SWAP gates = 12 gates total, yielding 12 tensors
  • The controlled-Phase gates are non-diagonal (due to controls), so they allocate new labels
  • The H and SWAP gates are also non-diagonal

The circuit_to_einsum function converts the circuit into an einsum-based tensor network representation, which can be useful for understanding the contraction structure and for alternative simulation strategies.

Comparison with Yao.jl

yao-rs is a focused port of Yao.jl, covering the circuit description, state-vector simulation, measurement, and tensor network layers.

Feature Comparison

CapabilityYao.jlyao-rs
Circuit description (put/control)YesYes
Qudit supportYesYes
State-vector simulationYes (in-place)Yes (in-place, O(2^n))
GPU simulationYes (CuYao)No
Symbolic computationYes (YaoSym)No
Automatic differentiationYesNo
Tensor network exportYes (YaoToEinsum)Yes
Diagonal gate optimizationNoYes
Contraction order optimizationNoYes (omeco)
Noise channelsYesNo
Measurement / samplingYesYes
Circuit visualizationYes (YaoPlots)No

Summary

yao-rs provides efficient O(2^n) state-vector simulation with measurement support, plus circuit-to-tensor-network conversion with optimizations (diagonal gates, contraction order via omeco) that are not available in Yao.jl’s YaoToEinsum.

Yao.jl is a full quantum computing framework with GPU simulation, symbolic computation, automatic differentiation, noise channels, and visualization.