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
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:
| Approach | Memory | Scaling |
|---|---|---|
| State vector | O(2^n) | Exponential in qubits |
| Tensor network | Depends on order | Can 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/controlbuilder API with qudit support - Tensor Network Export:
circuit_to_einsumwith diagonal gate optimization - Contraction Optimization: Integration with omeco
- State-Vector Simulation: Direct
applyfor 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 - Install yao-rs and build your first circuit
- Gates - All gate variants and their properties
- Tensor Networks - Understand the einsum export
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-complexfor complex number arithmeticndarrayfor multi-dimensional arraysomecofor 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
| Gate | Matrix | Diagonal |
|---|---|---|
X | [[0, 1], [1, 0]] | No |
Y | [[0, -i], [i, 0]] | No |
Z | [[1, 0], [0, -1]] | Yes |
H | 1/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)= ZPhase(pi/2)= SPhase(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:
- control_configs length must match control_locs length — Each control site needs a configuration.
- All locations must be in range — Every loc in
target_locsandcontrol_locsmust be <dims.len(). - No overlap between target and control — A site cannot be both a target and a control.
- Control sites must be qubits (d=2) — Controlled gates only support qubit control sites.
- Named gate targets must be qubits — Non-Custom gates require target sites with d=2.
- 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 lengthdims[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):
| State | Index |
|---|---|
| |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:
-
Initial labels
0..n-1represent the input state indices for each site (one label per site). -
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.
-
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:
- 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
- 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
| Capability | Yao.jl | yao-rs |
|---|---|---|
Circuit description (put/control) | Yes | Yes |
| Qudit support | Yes | Yes |
| State-vector simulation | Yes (in-place) | Yes (in-place, O(2^n)) |
| GPU simulation | Yes (CuYao) | No |
| Symbolic computation | Yes (YaoSym) | No |
| Automatic differentiation | Yes | No |
| Tensor network export | Yes (YaoToEinsum) | Yes |
| Diagonal gate optimization | No | Yes |
| Contraction order optimization | No | Yes (omeco) |
| Noise channels | Yes | No |
| Measurement / sampling | Yes | Yes |
| Circuit visualization | Yes (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.