Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: guppy → pytket conversion #407

Merged
merged 6 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions tket2-py/src/circuit/tk2circuit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use hugr::{Hugr, HugrView, Wire};
use serde::Serialize;
use tket2::circuit::CircuitHash;
use tket2::extension::REGISTRY;
use tket2::passes::pytket::lower_to_pytket;
use tket2::passes::CircuitChunks;
use tket2::serialize::TKETDecode;
use tket2::{Circuit, Tk2Op};
Expand Down Expand Up @@ -73,9 +74,8 @@ impl Tk2Circuit {

/// Convert the [`Tk2Circuit`] to a tket1 circuit.
pub fn to_tket1<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
SerialCircuit::encode(&self.circ)
.convert_pyerrs()?
.to_tket1(py)
let circ = lower_to_pytket(&self.circ).convert_pyerrs()?;
SerialCircuit::encode(&circ).convert_pyerrs()?.to_tket1(py)
}

/// Apply a rewrite on the circuit.
Expand Down Expand Up @@ -109,7 +109,9 @@ impl Tk2Circuit {

/// Encode the circuit as a tket1 json string.
pub fn to_tket1_json(&self) -> PyResult<String> {
Ok(serde_json::to_string(&SerialCircuit::encode(&self.circ).convert_pyerrs()?).unwrap())
// Try to simplify tuple pack-unpack pairs, and other operations not supported by pytket.
let circ = lower_to_pytket(&self.circ).convert_pyerrs()?;
Ok(serde_json::to_string(&SerialCircuit::encode(&circ).convert_pyerrs()?).unwrap())
}

/// Decode a tket1 json string to a circuit.
Expand Down
6 changes: 6 additions & 0 deletions tket2-py/src/passes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ create_py_exception!(
"Error from a `PullForward` operation"
);

create_py_exception!(
tket2::passes::pytket::PytketLoweringError,
PyPytketLoweringError,
"Errors that can occur while removing high-level operations from HUGR intended to be encoded as a pytket circuit."
);

#[pyfunction]
fn greedy_depth_reduce<'py>(circ: &Bound<'py, PyAny>) -> PyResult<(Bound<'py, PyAny>, u32)> {
let py = circ.py();
Expand Down
43 changes: 41 additions & 2 deletions tket2-py/test/test_guppy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import no_type_check
import pytket.circuit
from tket2.circuit import Tk2Circuit

import math
Expand All @@ -9,8 +10,46 @@
from guppylang.prelude.builtins import py
from guppylang.prelude.quantum import measure, phased_x, qubit, rz, zz_max

import pytket

def test_load_compiled_module():

def test_load_pure_circuit():
module = GuppyModule("test")
module.load(quantum)

@guppy(module)
@no_type_check
def my_func(
q0: qubit,
q1: qubit,
) -> tuple[qubit, qubit]: # pragma: no cover
q0 = phased_x(q0, py(math.pi / 2), py(-math.pi / 2))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tket1 has angles in fractions of pi, are these angles converted correctly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. That was missing in the encoder/decoder.

Now we translate between radians and half-turns when converting hugrs to pytket circuits.

q0 = rz(q0, py(math.pi))
q1 = phased_x(q1, py(math.pi / 2), py(-math.pi / 2))
q1 = rz(q1, py(math.pi))
q0, q1 = zz_max(q0, q1)
q0 = rz(q0, py(math.pi))
q1 = rz(q1, py(math.pi))
return (q0, q1)

# Compile the module
hugr = module.compile()
json = hugr.to_raw().to_json()

# Load it from the JSON string
circ = Tk2Circuit.from_guppy_json(json, "my_func")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these three lines probably belong in a function, in _circuit.py orsomething

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a couple reasons for keeping this explicit for the moment:

Without CQCL/guppylang#246, we need to keep an explicit reference to the hugr if we want to create multiple circuits from the same module.
Once that's published we'll be able to create functions from the function definition itself;

from_guppy(my_func: guppy.Definition) -> Tk2Circuit

But more importantly, tket2 only has a dev-dependency on guppy.
To avoid cyclic dependencies, we cannot expose from_guppy in the library API.
In the future I imagine guppy may import tket2 and expose to_circuit methods on modules and functions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a utility functions for testing, but we won't be able to expose it in the tket2 api.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, thanks for the explanation

assert circ.num_operations() == 7

# Convert into a pytket Circuit
tk1 = circ.to_tket1()
assert tk1.n_gates == 7
assert tk1.n_qubits == 2

gates = list(tk1)
assert gates[4].op.type == pytket.circuit.OpType.ZZMax


def test_load_hybrid_circuit():
module = GuppyModule("test")
module.load(quantum)

Expand All @@ -19,7 +58,7 @@ def test_load_compiled_module():
def my_func(
q0: qubit,
q1: qubit,
) -> tuple[bool,]:
) -> tuple[bool,]: # pragma: no cover
q0 = phased_x(q0, py(math.pi / 2), py(-math.pi / 2))
q0 = rz(q0, py(math.pi))
q1 = phased_x(q1, py(math.pi / 2), py(-math.pi / 2))
Expand Down
3 changes: 3 additions & 0 deletions tket2/src/passes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ pub use commutation::{apply_greedy_commutation, PullForwardError};
pub mod chunks;
pub use chunks::CircuitChunks;

pub mod pytket;
pub use pytket::lower_to_pytket;

pub mod tuple_unpack;
pub use tuple_unpack::find_tuple_unpack_rewrites;
39 changes: 39 additions & 0 deletions tket2/src/passes/pytket.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! This module contains routines needed for normalizing a circuit
//! into a form that can be encoded as a pytket legacy circuit.
//!
//! This is a best-effort attempt, and may not always succeed.

use itertools::Itertools;

use crate::serialize::pytket::OpConvertError;
use crate::Circuit;

use super::find_tuple_unpack_rewrites;

/// Try to lower a circuit to a form that can be encoded as a pytket legacy circuit.
pub fn lower_to_pytket(circ: &Circuit) -> Result<Circuit, PytketLoweringError> {
let mut circ = circ
.extract_dfg()
.map_err(|_| PytketLoweringError::NonLocalOperations)?;

// Remove sequences of tuple pack-unpack operations,
// typically generated by guppy.
let rewrites = find_tuple_unpack_rewrites(&circ).collect_vec();
for rewrite in rewrites {
rewrite.apply(&mut circ).unwrap();
}

Ok(circ)
}

/// Errors that can occur during the lowering process.
#[derive(Clone, PartialEq, Debug, thiserror::Error)]
pub enum PytketLoweringError {
/// An error occurred during the conversion of an operation.
#[error("operation conversion error: {0}")]
OpConversionError(#[from] OpConvertError),
/// The circuit is not fully-contained in a region.
/// Function calls are not supported.
#[error("Non-local operations found. Function calls are not supported.")]
NonLocalOperations,
}
61 changes: 51 additions & 10 deletions tket2/src/serialize/pytket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ use crate::circuit::Circuit;
use self::decoder::JsonDecoder;
use self::encoder::JsonEncoder;

pub use crate::passes::pytket::lower_to_pytket;

/// Prefix used for storing metadata in the hugr nodes.
pub const METADATA_PREFIX: &str = "TKET1_JSON";
/// The global phase specified as metadata.
Expand Down Expand Up @@ -92,7 +94,7 @@ impl TKETDecode for SerialCircuit {
}

/// Error type for conversion between `Op` and `OpType`.
#[derive(Debug, Error)]
#[derive(Clone, PartialEq, Debug, Error)]
pub enum OpConvertError {
/// The serialized operation is not supported.
#[error("Unsupported serialized pytket operation: {0:?}")]
Expand Down Expand Up @@ -123,20 +125,41 @@ pub fn load_tk1_json_str(json: &str) -> Result<Circuit, TK1ConvertError> {
}

/// Save a circuit to file in TK1 JSON format.
///
/// You may need to normalize the circuit using [`lower_to_pytket`] before saving.
///
/// # Errors
///
/// Returns an error if the circuit is not flat or if it contains operations not
/// supported by pytket.
pub fn save_tk1_json_file(circ: &Circuit, path: impl AsRef<Path>) -> Result<(), TK1ConvertError> {
let file = fs::File::create(path)?;
let writer = io::BufWriter::new(file);
save_tk1_json_writer(circ, writer)
}

/// Save a circuit in TK1 JSON format to a writer.
///
/// You may need to normalize the circuit using [`lower_to_pytket`] before saving.
///
/// # Errors
///
/// Returns an error if the circuit is not flat or if it contains operations not
/// supported by pytket.
pub fn save_tk1_json_writer(circ: &Circuit, w: impl io::Write) -> Result<(), TK1ConvertError> {
let serial_circ = SerialCircuit::encode(circ)?;
serde_json::to_writer(w, &serial_circ)?;
Ok(())
}

/// Save a circuit in TK1 JSON format to a String.
///
/// You may need to normalize the circuit using [`lower_to_pytket`] before saving.
///
/// # Errors
///
/// Returns an error if the circuit is not flat or if it contains operations not
/// supported by pytket.
pub fn save_tk1_json_str(circ: &Circuit) -> Result<String, TK1ConvertError> {
let mut buf = io::BufWriter::new(Vec::new());
save_tk1_json_writer(circ, &mut buf)?;
Expand Down Expand Up @@ -167,22 +190,40 @@ pub enum TK1ConvertError {
FileLoadError(#[from] io::Error),
}

#[inline]
fn parse_val(n: &str) -> Option<f64> {
n.parse::<f64>().ok()
}
/// Try to interpret a TKET1 parameter as a constant value.
///
/// Angle parameters in TKET1 are encoded as a number of half-turns,
/// whereas HUGR uses radians.
#[inline]
fn try_param_to_constant(param: &str) -> Option<Value> {
if let Some(f) = parse_val(param) {
Some(ConstF64::new(f).into())
fn parse_val(n: &str) -> Option<f64> {
n.parse::<f64>().ok()
}

let half_turns = if let Some(f) = parse_val(param) {
f
} else if param.split('/').count() == 2 {
// TODO: Use the rational types from `Hugr::extensions::rotation`
let (n, d) = param.split_once('/').unwrap();
let n = parse_val(n)?;
let d = parse_val(d)?;
Some(ConstF64::new(n / d).into())
n / d
} else {
None
}
return None;
};

let radians = half_turns * std::f64::consts::PI;
Some(ConstF64::new(radians).into())
}

/// Convert a HUGR angle constant to a TKET1 parameter.
///
/// Angle parameters in TKET1 are encoded as a number of half-turns,
/// whereas HUGR uses radians.
#[inline]
fn try_constant_to_param(val: &Value) -> Option<String> {
let const_float = val.get_custom_value::<ConstF64>()?;
let radians: f64 = **const_float;
let half_turns = radians / std::f64::consts::PI;
Some(half_turns.to_string())
}
13 changes: 6 additions & 7 deletions tket2/src/serialize/pytket/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use std::collections::HashMap;

use hugr::extension::prelude::QB_T;
use hugr::ops::{NamedOp, OpType};
use hugr::std_extensions::arithmetic::float_types::ConstF64;
use hugr::{HugrView, Wire};
use itertools::{Either, Itertools};
use tket_json_rs::circuit_json::{self, Permutation, Register, SerialCircuit};
Expand All @@ -18,8 +17,8 @@ use crate::Tk2Op;

use super::op::JsonOp;
use super::{
OpConvertError, METADATA_B_REGISTERS, METADATA_IMPLICIT_PERM, METADATA_PHASE,
METADATA_Q_REGISTERS,
try_constant_to_param, OpConvertError, METADATA_B_REGISTERS, METADATA_IMPLICIT_PERM,
METADATA_PHASE, METADATA_Q_REGISTERS,
};

/// The state of an in-progress [`SerialCircuit`] being built from a [`Circuit`].
Expand Down Expand Up @@ -198,10 +197,10 @@ impl JsonEncoder {
let param = match optype {
OpType::Const(const_op) => {
// New constant, register it if it can be interpreted as a parameter.
let Some(const_float) = const_op.value().get_custom_value::<ConstF64>() else {
return false;
};
const_float.to_string()
match try_constant_to_param(const_op.value()) {
Some(param) => param,
None => return false,
}
}
OpType::LoadConstant(_op_type) => {
// Re-use the parameter from the input.
Expand Down
4 changes: 2 additions & 2 deletions tket2/src/serialize/pytket/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ fn circ_add_angles_constants() -> Circuit {

let qb = h.input_wires().next().unwrap();

let point2 = h.add_load_value(ConstF64::new(0.2));
let point3 = h.add_load_value(ConstF64::new(0.3));
let point2 = h.add_load_value(ConstF64::new(0.2 * std::f64::consts::PI));
let point3 = h.add_load_value(ConstF64::new(0.3 * std::f64::consts::PI));
Comment on lines +111 to +112
Copy link
Collaborator Author

@aborgna-q aborgna-q Jun 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised this didn't cause precision errors.
The parameter still gets encoded as "0.2 + 0.3" in the test below.

let point5 = h
.add_dataflow_op(Tk2Op::AngleAdd, [point2, point3])
.unwrap()
Expand Down