Lesson 4: Putting everything together

In this example we’re going to apply the previously learned concept to run a Hamiltonian simulation:

  1. Defining an ansatz using a pytket symbolic circuit

  2. Provided a workflow to calculate an expected value of a circuit given a Pauli String:

    • Using the symbolic substituiton from Lesson 2

    • Using the tkr-pytket-worker for compilation and tkr-aer-worker for simulation similar to Lesson 3

  3. Construct a workflow using input and output definitions, as well as builtin functionalities as shown in Lesson 1

    • (new) Using higher order constructs like map to parallelize execution

    • (new) Introduce the fold pattern

The ansatz

We start by defining a symbolic circuit as an ansatz which will be the input to our workflow. Were going to use a very simple circuit, which only uses 4 qubits and 3 symbols to match what we build so far. Note that it doesn’t have any measurements yet. We will add them in the next step when calculating the expected value.

from pytket.circuit import Circuit, fresh_symbol


def build_ansatz() -> Circuit:
    a = fresh_symbol("a")
    b = fresh_symbol("b")
    c = fresh_symbol("c")
    circ = Circuit(4)
    circ.CX(0, 1)
    circ.CX(1, 2)
    circ.CX(2, 3)
    circ.Rz(a, 3)
    circ.CX(2, 3)
    circ.CX(1, 2)
    circ.CX(0, 1)
    circ.Rz(b, 0)
    circ.CX(0, 1)
    circ.CX(1, 2)
    circ.CX(2, 3)
    circ.Rz(c, 3)
    circ.CX(2, 3)
    circ.CX(1, 2)
    circ.CX(0, 1)
    return circ

Calculating expected values

We will evaluate this circuit with an observable based on a Pauli string. We want to measure the circuit according the Pauli string after which we can compile and submit it. Using the respective workers we can construct a subgraph. In Tierkreis graphs are first class citizens, which we can reuse in multiple locations. In this example, we later want to map over this procedure with different parameters. Defining the graph which taktes a circuit, a Pauli string and the number of shots.

from typing import NamedTuple, Literal
from tierkreis.builder import Graph
from tierkreis.controller.data.models import TKR

from pytket_worker import (
    append_pauli_measurement_impl,
    optimise_phase_gadgets,
    expectation,
)
from aer_worker import submit_single


class SubmitInputs(NamedTuple):
    circuit: TKR[Literal["pytket._tket.circuit.Circuit"]]
    pauli_string: TKR[Literal["pytket._tket.pauli.QubitPauliString"]]
    n_shots: TKR[int]


def exp_val():
    g = Graph(SubmitInputs, TKR[float])

    circuit = g.inputs.circuit
    pauli_string = g.inputs.pauli_string
    n_shots = g.inputs.n_shots

    measurement_circuit = g.task(append_pauli_measurement_impl(circuit, pauli_string))

    compiled_circuit = g.task(optimise_phase_gadgets(measurement_circuit))

    backend_result = g.task(submit_single(compiled_circuit, n_shots))
    av = g.task(expectation(backend_result))
    return g.finish_with_outputs(av)

Building the workflow

We are going to simulate a Hamiltonian given as a list of Pauli strings and their weights. As additional inputs we take the parameters to our ansazt. We can already build the first step of the graph, using the substitution worker we build before (see Lesson 2).

from my_example_worker import substitute


class SymbolicExecutionInputs(NamedTuple):
    a: TKR[float]
    b: TKR[float]
    c: TKR[float]
    ham: TKR[list[tuple[Literal["pytket._tket.pauli.QubitPauliString"], float]]]
    ansatz: TKR[Literal["pytket._tket.circuit.Circuit"]]


simulation_graph = Graph(SymbolicExecutionInputs, TKR[float])
substituted_circuit = simulation_graph.task(
    substitute(
        simulation_graph.inputs.ansatz,
        simulation_graph.inputs.a,
        simulation_graph.inputs.b,
        simulation_graph.inputs.c,
    )
)

Using map

Since the exp_val() workflow can run independently for each Pauli string we can implement this using a map node. Map nodes execute a subworkflow in parallel for an arbitrary number of different inputs.

The node itself expects Graph instance for the workflow and a list of input parameters to map over. In our case, the exp_val() workflow expects SubmitInputs where the field circuit and n_shots are fixed. So it is sufficient to map over the pauli strings We need to prepare the inputs accordingly, in this scenario we build a list of SubmitInputs each with Pauli string using a lambda

from tierkreis.builtins import unzip
from tierkreis.controller.data.models import TKR

pauli_strings_list, parameters_list = simulation_graph.task(
    unzip(simulation_graph.inputs.ham)
)
input_circuits = simulation_graph.map(
    lambda x: SubmitInputs(substituted_circuit, x, simulation_graph.const(100)),
    pauli_strings_list,
)

which we than can then supply to the map node:

exp_values = simulation_graph.map(exp_val(), input_circuits)

Using the fold pattern

To estimate the energy, we can take a weighted sum of the expectation values. For this we want to compute a reduction of \(f:\mathbb{R}^2\times\mathbb{R} \rightarrow \mathbb{R} \quad ((x,y), z) \rightarrow x*y+z\) which we implement as a fold function. This is a very common pattern; In Tierkreis the function \(f\) will be implemented as a workflow using the functionality in tierkreis.graphs.fold.

Defining \(f\) as compute_terms() using builtins:

from tierkreis.builtins import add, times, untuple
from tierkreis.graphs.fold import FoldFunctionInput

ComputeTermsInputs = FoldFunctionInput[
    tuple[float, float], float
]  # (value: (x,y) , accum: z) -> new_accum


def compute_terms():
    g = Graph(ComputeTermsInputs, TKR[float])

    res_0, res_1 = g.task(untuple(g.inputs.value))
    prod = g.task(times(res_0, res_1))
    sum = g.task(add(g.inputs.accum, prod))

    return g.finish_with_outputs(sum)

Preparing the inputs for the fold graph we are going to use tuples (x=exp_val, y=weight) and defining the start value \( z=0 \):

from tierkreis.builtins import tkr_zip
from tierkreis.graphs.fold import FoldGraphInputs, fold_graph

tuple_values = simulation_graph.task(tkr_zip(exp_values, parameters_list))
fold_inputs = FoldGraphInputs(simulation_graph.const(0.0), tuple_values)

applying the fold operation yields the final output

computed = simulation_graph.eval(fold_graph(compute_terms()), fold_inputs)
workflow = simulation_graph.finish_with_outputs(computed)

Running the workflow

As before we now have to set up tierkreis storage and executors.

from uuid import UUID

from tierkreis.storage import FileStorage
from tierkreis.executor import ShellExecutor

storage = FileStorage(UUID(int=102), name="hamiltonian")
example_executor = ShellExecutor(None, workflow_dir=storage.workflow_dir)

and provide the inputs

from pytket._tket.unit_id import Qubit
from pytket.pauli import Pauli, QubitPauliString

qubits = [Qubit(0), Qubit(1), Qubit(2), Qubit(3)]
hamiltonian = [
    (QubitPauliString(qubits, [Pauli.X, Pauli.Y, Pauli.X, Pauli.I]).to_list(), 0.1),
    (QubitPauliString(qubits, [Pauli.Y, Pauli.Z, Pauli.X, Pauli.Z]).to_list(), 0.5),
    (QubitPauliString(qubits, [Pauli.X, Pauli.Y, Pauli.Z, Pauli.I]).to_list(), 0.3),
    (QubitPauliString(qubits, [Pauli.Z, Pauli.Y, Pauli.X, Pauli.Y]).to_list(), 0.6),
]
inputs = {
    "ansatz": build_ansatz().to_dict(),
    "a": 0.2,
    "b": 0.55,
    "c": 0.75,
    "ham": hamiltonian,
}

before we can run the simulation:

from tierkreis.controller import run_graph
from tierkreis.storage import read_outputs


storage.clean_graph_files()
run_graph(
    storage,
    example_executor,
    workflow,
    inputs,
    polling_interval_seconds=0.1,
)
output = read_outputs(workflow, storage)
print(output)
-0.07200000000000006