diff --git a/.gitignore b/.gitignore
index 76441ea0b..a6f13f16a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,6 @@ build.ninja
node_modules
MODULE.bazel.lock
.ninja_lock
+crumble_js
+bundle.js
+.ipynb_checkpoints
\ No newline at end of file
diff --git a/glue/crumble/draw/config.js b/glue/crumble/draw/config.js
index 25c6c739b..d0b731d88 100644
--- a/glue/crumble/draw/config.js
+++ b/glue/crumble/draw/config.js
@@ -2,5 +2,6 @@ const pitch = 50;
const rad = 10;
const OFFSET_X = -pitch + Math.floor(pitch / 4) + 0.5;
const OFFSET_Y = -pitch + Math.floor(pitch / 4) + 0.5;
+let curveConnectors = true;
-export {pitch, rad, OFFSET_X, OFFSET_Y};
+export {pitch, rad, OFFSET_X, OFFSET_Y, curveConnectors};
diff --git a/glue/crumble/gates/gate_draw_util.js b/glue/crumble/gates/gate_draw_util.js
index 54bb30bcc..8d2081b01 100644
--- a/glue/crumble/gates/gate_draw_util.js
+++ b/glue/crumble/gates/gate_draw_util.js
@@ -1,4 +1,4 @@
-import {pitch, rad} from "../draw/config.js"
+import { curveConnectors, pitch, rad } from "../draw/config.js";
/**
* @param {!CanvasRenderingContext2D} ctx
@@ -210,7 +210,7 @@ function stroke_connector_to(ctx, x1, y1, x2, y2) {
ctx.beginPath();
ctx.moveTo(x1, y1);
- if (d < pitch * 1.1) {
+ if (!curveConnectors || d < pitch * 1.1) {
ctx.lineTo(x2, y2);
} else {
ctx.bezierCurveTo(x1 + ux + px, y1 + uy + py, x2 - ux + px, y2 - uy + py, x2, y2);
diff --git a/glue/crumpy/README.md b/glue/crumpy/README.md
new file mode 100644
index 000000000..e2f87f7a9
--- /dev/null
+++ b/glue/crumpy/README.md
@@ -0,0 +1,190 @@
+
+
+# CrumPy
+
+Visualize quantum circuits with error propagation in a Jupyter widget.
+
+

+
+---
+
+This package builds off of the existing circuit visualizations of
+[Crumble](https://algassert.com/crumble), a stabilizer circuit editor and
+sub-project of the open-source stabilizer circuit project
+[Stim](https://github.com/quantumlib/Stim).
+
+
+
+---
+
+## Installation
+
+**Requires:** Python 3.11+
+
+```console
+pip install crumpy
+```
+
+## Usage
+
+CrumPy provides a convenient Jupyter widget that makes use of Crumble's ability
+to generate quantum circuit timeline visualizations with Pauli propagation from
+[Stim circuit specifications](https://github.com/quantumlib/Stim/blob/main/doc/file_format_stim_circuit.md#the-stim-circuit-file-format-stim).
+
+### Using `CircuitWidget`
+
+`CircuitWidget` takes a
+[Stim circuit specification](https://github.com/quantumlib/Stim/blob/main/doc/file_format_stim_circuit.md#the-stim-circuit-file-format-stim)
+in the form of a Python `str`. To convert from the official Stim package's
+`stim.Circuit`, use `str(stim_circuit)`. If coming from another circuit type, it
+is recommended to first convert to a `stim.Circuit` (e.g., with Stim's
+[`stimcirq`](https://github.com/quantumlib/Stim/tree/main/glue/cirq) package),
+then to `str`. Note that not all circuits may be convertible to Stim.
+
+```python
+from crumpy import CircuitWidget
+
+your_circuit = """
+H 0
+CNOT 0 1
+"""
+
+circuit = CircuitWidget(stim=your_circuit)
+circuit
+```
+
+
+
+### Propagating Paulis
+
+A useful feature of Crumble (and CrumPy) is the ability to propagate Paulis
+through a quantum circuit. Propagation is done automatically based on the
+specified circuit and Pauli markers. From the
+[Crumble docs](https://github.com/quantumlib/Stim/tree/main/glue/crumble#readme):
+
+> Propagating Paulis is done by placing markers to indicate where to add terms.
+> Each marker has a type (X, Y, or Z) and an index (0-9) indicating which
+> indexed Pauli product the marker is modifying.
+
+Define a Pauli X, Y, or Z marker with the `#!pragma MARK_` instruction. Note
+that for compatibility with Stim, the '`#!pragma `' is included as `MARK_` is
+not considered a standard Stim instruction:
+
+```python
+z_error_on_qubit_2 = "#!pragma MARKZ(0) 2"
+```
+
+#### Legend
+
+
+
+#### Example
+
+```python
+circuit_with_error = """
+#!pragma MARKX(0) 0
+TICK
+CNOT 0 1
+TICK
+H 0
+"""
+
+CircuitWidget(stim=circuit_with_error)
+```
+
+
+
+Notice how the single specified Pauli X marker propagates both through the
+control and across the connector of the CNOT, and gets transformed into a Pauli
+Z error when it encounters an H gate.
+
+## Local Development
+
+See the [contribution guidelines](.github/CONTRIBUTING.md) for a quick start
+guide and Python best practices.
+
+### Additional requirements
+
+[npm/Node.js](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
+(for bundling JavaScript); versions 11+/22+ recommended
+
+### Project Layout
+
+CrumPy makes use of the widget creation tool
+[AnyWidget](https://anywidget.dev/). With AnyWidget, Python classes are able to
+take JavaScript and display custom widgets in Jupyter environments. In order to
+visualize Crumble within CrumPy, some modifications in the Crumble source code
+were needed. CrumPy therefore includes both JavaScript and Python subparts:
+
+```text
+glue/
+├── crumpy/ # Modified Crumble code
+│ ├── src/
+│ │ ├── crumpy/
+│ │ │ └── __init__.py # Python code for CircuitWidget
+│ │ ├── js/
+│ │ │ ├── draw/ # Modified JS source code
+│ │ │ ├── main.js # Main circuit visualization/setup
+│ │ │ ├── bundle.js # Bundled JavaScript will appear here
+│ │ │ └── package.json # Bundling setup and scripts
+│ └── ...
+│ ├── tests/ # Python tests
+│ ├── simple_example.ipynb # Example notebook
+│ └── getting_started.ipynb # Getting started notebook
+```
+
+`glue/crumpy` contains the main Python package code.
+
+`glue/crumpy/src/crumpy/__init__.py` contains the main class of the `crumpy` package,
+`CircuitWidget`.
+
+`glue/crumpy/src/js/` contains the modified Crumble circuit visualization code that
+will be rendered in the `CircuitWidget` widget. In the build step, we copy the Crumble
+source code and modify it with the `.js` files in this folder.
+
+`glue/crumpy/src/js/main.js` contains the main circuit visualization and setup logic
+in the form of the `render` function
+[used by AnyWidget](https://anywidget.dev/en/afm/#anywidget-front-end-module-afm).
+
+### Bundling
+
+To create a Jupyter widget, AnyWidget takes in JavaScript as an ECMAScript
+Module (ESM). **Any changes made to the JavaScript code that backs the circuit
+visualization will require re-bundling** into one optimized ESM file.
+
+To bundle the JavaScript:
+
+1. Navigate to `glue/crumpy/src/js`
+2. If you haven't yet, run `npm install` (this will install the
+ [esbuild](https://esbuild.github.io/) bundler)
+3. Run `npm run build` (or `npm run watch` for watch mode)
+
+A new bundle should appear at `src/js/bundle.js`.
+
+**Note**: If you are working in a Jupyter notebook and re-bundle the JavaScript,
+you may need to restart the notebook kernel and rerun any widget-displaying code
+for the changes to take effect.
+
+### Install from source
+
+After creating the bundle, you can install the package from source:
+
+1. Navigate to `glue/crumpy`
+2. Run `pip install -e .` in your favorite Python environment
+
+
+
+
+[pypi-link]: https://pypi.org/project/crumpy/
+[pypi-platforms]: https://img.shields.io/pypi/pyversions/crumpy
+[pypi-version]: https://img.shields.io/pypi/v/crumpy
+
+
+
+## Attribution
+
+This package was created as part of [Sam Zappa](https://github.com/zzzappy)'s
+internship at [Riverlane](https://github.com/riverlane) during the Summer
+of 2025. Thanks to [Hyeok Kim](https://github.com/see-mike-out),
+[Leilani Battle](https://github.com/leibatt), [Abe Asfaw](https://github.com/aasfaw)
+and [Guen Prawiroatmodjo](https://github.com/guenp) for guidance and useful discussions.
diff --git a/glue/crumpy/getting_started.ipynb b/glue/crumpy/getting_started.ipynb
new file mode 100644
index 000000000..9e792c63d
--- /dev/null
+++ b/glue/crumpy/getting_started.ipynb
@@ -0,0 +1,551 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0b6e4d5e-f8ab-4946-b950-65f909893b10",
+ "metadata": {},
+ "source": [
+ "# Getting Started with CrumPy"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "514ab571-4824-4b1f-8c05-5c1b341403e2",
+ "metadata": {},
+ "source": [
+ "To start, install the `crumpy` package:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7bd1bebe-37cb-400f-88f9-0db8b2934c15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%pip install crumpy"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3368a263-ff20-4cea-a758-e8310dbe98a5",
+ "metadata": {},
+ "source": [
+ "### Required Imports"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b6ef6cbe-0611-489c-915f-f3fc42514dd5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from crumpy import CircuitWidget\n",
+ "\n",
+ "import stim\n",
+ "import ipywidgets\n",
+ "import cirq\n",
+ "import qiskit"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "947f9453-24b5-4a7f-9d18-2553730bc42c",
+ "metadata": {},
+ "source": [
+ "## Background: Crumble"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "935f5eb6-68f8-4db8-82e3-fe1fa44e055e",
+ "metadata": {},
+ "source": [
+ "The goal of CrumPy's `CircuitWidget` is to utilize the visualization features of Crumble ([website](https://algassert.com/crumble), [docs](https://github.com/quantumlib/Stim/blob/main/glue/crumble/README.md)), a stabilizer circuit editor and sub-project of the open-source stabilizer circuit project [Stim](https://github.com/quantumlib/Stim), to provide a convenient way to display quantum circuit timelines with Pauli propagation in a Jupyter notebook. In particular, CrumPy takes advantage of Crumble's existing ability to generate circuit timeline visualizations from [Stim circuit specifications](https://github.com/quantumlib/Stim/blob/main/doc/file_format_stim_circuit.md#the-stim-circuit-file-format-stim), which also includes support for Pauli propagation markers.\n",
+ "\n",
+ "If you aren't familiar with Crumble, try experimenting with building a circuit from scratch, adding Paulis, or check out some of Crumble's example circuits.\n",
+ "\n",
+ "The logic behind Crumble's Stim import textbox (shown on click of the **Show Import/Export** button in Crumble) provides the basis of CrumPy's circuit importing abilities. Try playing around with importing/exporting circuits to get familiar with the notation and instructions supported by Crumble and CrumPy."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "099667d8-35a0-4ec1-80b9-76f9b5bf7755",
+ "metadata": {},
+ "source": [
+ "### Crumble ↔ CrumPy"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "166a21e3-ebd9-4ce2-8381-cf2cc56b2551",
+ "metadata": {},
+ "source": [
+ "CrumPy uses the circuit compilation logic from Crumble, ensuring that circuits in Crumble and CrumPy are compatible with one another. Note that this means CrumPy supports Crumble-specific instructions not present in standard Stim circuit specification, like `MARKX` and `POLYGON`. This makes the transfer of a Crumble circuit to CrumPy and vice versa fairly straightforward:\n",
+ "\n",
+ "If you've already built a circuit in Crumble that you wish to display using CrumPy:\n",
+ "\n",
+ "- (in Crumble) Click **Show Import/Export → Export to Stim Circuit**\n",
+ "- Copy the Stim output from the textarea\n",
+ "- (in Python/notebook) Paste the output into the content of a Python string\n",
+ "- Use your circuit string with `CircuitWidget`!\n",
+ "\n",
+ "Going from CrumPy to Crumble:\n",
+ "\n",
+ "- (in Python/notebook) Copy your circuit string (tip: try copying the output of `print(your_circuit.stim)`)\n",
+ "- (in Crumble) Click **Show Import/Export**\n",
+ "- Paste your circuit into the textarea\n",
+ "- Click **Import from Stim Circuit** to see your circuit!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "174429da",
+ "metadata": {},
+ "source": [
+ "## Using `CircuitWidget`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2acc809d-734e-477c-9f11-8915ca70419e",
+ "metadata": {},
+ "source": [
+ "At the core of `crumpy` is the `CircuitWidget`. To get started with `CircuitWidget`, import it from the `crumpy` package:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9327d695",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from crumpy import CircuitWidget"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d208dec4",
+ "metadata": {},
+ "source": [
+ "### Basic Usage"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d0d90c8d",
+ "metadata": {},
+ "source": [
+ "`CircuitWidget` takes a [Stim circuit specification](https://github.com/quantumlib/Stim/blob/main/doc/file_format_stim_circuit.md#the-stim-circuit-file-format-stim) in the form of a Python string:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "67b624dc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "stim_circuit = \"\"\"\n",
+ "H 0\n",
+ "CNOT 0 2\n",
+ "SWAP 0 1\n",
+ "M 1\n",
+ "\"\"\"\n",
+ "\n",
+ "widg = CircuitWidget(stim=stim_circuit)\n",
+ "widg"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "39dc6881-aaf6-4f69-be3c-6341c7a334d4",
+ "metadata": {},
+ "source": [
+ "It's also possible to update the circuit of an existing `CircuitWidget`. Running the cell below will update the output of the previous cell to reflect the new circuit:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10b0d536-de9d-4612-9303-5cef57834b63",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new_circuit = \"\"\"\n",
+ "CX 0 2\n",
+ "TICK\n",
+ "CY 1 2\n",
+ "TICK\n",
+ "CZ 0 1\n",
+ "\"\"\"\n",
+ "\n",
+ "widg.stim = new_circuit"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "decdab71-dfae-4eed-b4f4-7124ab66bd32",
+ "metadata": {},
+ "source": [
+ "`CircuitWidget` also has configuration options that change the appearance of the circuit. Notice again how the above output is updated:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9d45348e-464a-408e-8145-6c0302920570",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "widg.curveConnectors = False\n",
+ "widg.indentCircuitLines = False\n",
+ "\n",
+ "# Can also specify within CircuitWidget constructor, e.g.:\n",
+ "# CircuitWidget(stim=..., curveConnectors=False)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1752292c-621d-4b47-bddb-208cd1659766",
+ "metadata": {},
+ "source": [
+ "### Propagating Paulis"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c8e499f0-82a4-44a8-804d-c6f9c0ebfb09",
+ "metadata": {},
+ "source": [
+ "A useful feature of Crumble (and CrumPy) is the ability to propagate Paulis through a quantum circuit. From the [Crumble docs](https://github.com/quantumlib/Stim/tree/main/glue/crumble#readme):\n",
+ "\n",
+ "> Propagating Paulis is done by placing markers to indicate where to add terms.\n",
+ "> Each marker has a type (X, Y, or Z) and an index (0-9) indicating which\n",
+ "> indexed Pauli product the marker is modifying.\n",
+ "\n",
+ "Define a Pauli X, Y, or Z marker with the `#!pragma MARK_` instruction:\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "966e029a-8f8d-4ebe-a8aa-4c6bdc6235e0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "x_error_on_qubit_3 = \"#!pragma MARKX(0) 3\"\n",
+ "z_error_on_qubit_2 = \"MARKZ(0) 2\" # Valid for use with CrumPy, but not with stim.Circuit - see below"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "850b70b0-aaec-4686-b545-be839437cee1",
+ "metadata": {},
+ "source": [
+ "#### Legend\n",
+ "\n",
+ "
\n",
+ "\n",
+ "#### Example: `MARKX` Propagation"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8687d89f-0d39-4d75-928d-3a8d97d95560",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "circuit_with_mark = \"\"\"\n",
+ "#!pragma MARKX(0) 0\n",
+ "TICK\n",
+ "CNOT 0 1\n",
+ "TICK\n",
+ "H 0\n",
+ "\"\"\"\n",
+ "\n",
+ "CircuitWidget(stim=circuit_with_mark)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28643b60-9849-4953-9fd5-b3d43492c186",
+ "metadata": {},
+ "source": [
+ "Notice how the red Pauli X marker defined on qubit 0 propagates through the CNOT, resulting in X markers on both qubits. Then, when the X marker on qubit 0 encounters the H gate, it is converted into a blue Pauli Z marker. This propagation is done automatically based on the provided circuit and `MARKX`."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "69b94d4a-4cbb-4ef6-9309-c224524f8da2",
+ "metadata": {},
+ "source": [
+ "#### Note: `stim.Circuit` Compatibility"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "249d48d0-2e2f-444f-8657-8eb982f7e3d4",
+ "metadata": {},
+ "source": [
+ "Some instructions used in Crumble (and therefore CrumPy) are not considered standard Stim notation. This includes `MARK_`, `POLYGON`, and `ERR` instructions.\n",
+ "\n",
+ "For example, consider the circuit specification below which omits the '`#!pragma `' preceding its `MARKZ` instruction. Attempting to create a `stim.Circuit` will cause an error:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2057492f-74f1-4d5d-99fd-70a7a6a3f76c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "no_pragma = \"\"\"\n",
+ "MARKZ(0) 0\n",
+ "Y 1\n",
+ "TICK\n",
+ "CZ 0 1\n",
+ "\"\"\"\n",
+ "\n",
+ "try:\n",
+ " stim.Circuit(no_pragma)\n",
+ "except ValueError as e:\n",
+ " print(e)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "99c24004-d730-4973-87f2-a0abbfb3343b",
+ "metadata": {},
+ "source": [
+ "Including the '`#!pragma `' avoids this issue. The `MARKZ` instruction will not show up in a `stim.Circuit` (the '`#`' in '`#!pragma `' comments out the line):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ba1f46f9-41d1-47e0-9985-2914820ef998",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with_pragma = \"\"\"\n",
+ "#!pragma MARKZ(0) 0\n",
+ "Y 1\n",
+ "TICK\n",
+ "CZ 0 1\n",
+ "\"\"\"\n",
+ "\n",
+ "ok_circuit = stim.Circuit(with_pragma)\n",
+ "ok_circuit"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3693989e-45de-419a-9450-fd112a5166bb",
+ "metadata": {},
+ "source": [
+ "Even though there is no `MARKZ`, we now have a valid `stim.Circuit` which can easily be used with `CircuitWidget`:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "caa00306-bb17-4d7d-8f6b-d5cfaa117bad",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "CircuitWidget(stim=str(ok_circuit))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d1793d4c",
+ "metadata": {},
+ "source": [
+ "### Composing with Other Widgets"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "738b5d9f-db4e-401e-be58-f2810c7b97ac",
+ "metadata": {},
+ "source": [
+ "The ability to programmatically update a `CircuitWidget`'s `stim` attribute (also its config options) provides an opportunity for more advanced interaction with the circuit visualization. In particular, we can utilize existing widgets from the `ipywidgets` package to create more interactive experiences.\n",
+ "\n",
+ "The `traitlets` package used behind the scenes of both `CircuitWidget` and the widgets of `ipywidgets` allows for the composition of multiple widgets through functions like `link` and `dlink`. See the `ipywidgets` [documentation](https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html#linking-widgets) for more advanced uses."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d3f8883a-12d8-4594-aec3-e7b98873ebe2",
+ "metadata": {},
+ "source": [
+ "#### Example: `IntSlider` and `dlink`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "92016efe-eb20-4947-bf40-44c59e42416a",
+ "metadata": {},
+ "source": [
+ "In this example, an `IntSlider` widget is used in conjunction with a `CircuitWidget` to update the displayed circuit based on the slider's value. The `ipywidget.dlink` function is used to **d**irectionally **link** the slider's `value` to the circuit's `stim` (i.e. updates to the slider's `value` will update the circuit's `stim`). Here we can use the slider to see how a Pauli X marker acts when faced with consecutive H gates:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "df1b4aee",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def sliderToCircuit(slider_val):\n",
+ " base_circuit = \"\"\"\n",
+ " #!pragma MARKX(0) 0\n",
+ " TICK\n",
+ " CX 0 1\n",
+ " \"\"\"\n",
+ " return base_circuit + \"H 0\\n\" * slider_val\n",
+ "\n",
+ "circuit = CircuitWidget()\n",
+ "slider = ipywidgets.IntSlider(description=\"# of H gates\", value=1, min=0, max=10)\n",
+ "\n",
+ "ipywidgets.dlink((slider, \"value\"), (circuit, \"stim\"), sliderToCircuit)\n",
+ "\n",
+ "display(slider, circuit)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ed2c23e0",
+ "metadata": {},
+ "source": [
+ "### Converting Directly from a Circuit (`stim`, `cirq`, `qiskit`)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ed4ffb4e",
+ "metadata": {},
+ "source": [
+ "CrumPy provides helper methods for creating a `CircuitWidget` directly from 3 popular quantum circuit packages: `stim`, `cirq`, and `qiskit`.\n",
+ "\n",
+ "Even if `stim` isn't your quantum circuit package of choice, it may still be possible to utilize CrumPy. Circuits from packages like `cirq` or `qiskit` may be convertible to a `stim` circuit. For circuit languages that don't have an existing transpiler that converts directly to Stim, it may also be possible to convert to [OpenQASM](https://en.wikipedia.org/wiki/OpenQASM), which can be [converted to Cirq](https://quantumai.google/cirq/build/interop#importing_from_openqasm) and finally [converted to Stim](https://github.com/quantumlib/Stim/tree/main/glue/cirq#readme).\n",
+ "\n",
+ "Note that it may not always be possible to convert a circuit between formats (notably, `stim` has a [limited gate set](https://github.com/quantumlib/Stim/tree/main?tab=readme-ov-file#what-is-stim:~:text=There%20is%20no%20support%20for%20non%2DClifford%20operations%2C%20such%20as%20T%20gates%20and%20Toffoli%20gates.%20Only%20stabilizer%20operations%20are%20supported.)).\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b4dd3937",
+ "metadata": {},
+ "source": [
+ "#### Examples"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "30d3991d",
+ "metadata": {},
+ "source": [
+ "`CircuitWidget.from_stim`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25852a22",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "stim_circuit = stim.Circuit(\"\"\"\n",
+ "H 0\n",
+ "CX 0 1\n",
+ "M 0 1\n",
+ "\"\"\")\n",
+ "\n",
+ "CircuitWidget.from_stim(stim_circuit)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d2ee059f",
+ "metadata": {},
+ "source": [
+ "`CircuitWidget.from_cirq`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0649f636",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "q0 = cirq.LineQubit(0)\n",
+ "q1 = cirq.LineQubit(1)\n",
+ "cirq_circuit = cirq.Circuit(cirq.H(q0), cirq.CNOT(q0, q1), cirq.measure(q0, q1))\n",
+ "\n",
+ "CircuitWidget.from_cirq(cirq_circuit)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ff13af42",
+ "metadata": {},
+ "source": [
+ "`CircuitWidget.from_qiskit`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8bf6aa0b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "qiskit_circuit = qiskit.QuantumCircuit(2, 2)\n",
+ "qiskit_circuit.h(0)\n",
+ "qiskit_circuit.cx(0, 1)\n",
+ "qiskit_circuit.measure([0, 1], [0, 1])\n",
+ "\n",
+ "CircuitWidget.from_qiskit(qiskit_circuit)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b3b2651f",
+ "metadata": {},
+ "source": [
+ "While it's not possible to directly add Pauli markers or other Crumble-specific instructions when creating `CircuitWidget`s with these methods, it is possible to read/write the resulting `CircuitWidget`'s `.stim` attribute to get the final converted Stim circuit (in string form), which can then be modified."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/glue/crumpy/pyproject.toml b/glue/crumpy/pyproject.toml
new file mode 100644
index 000000000..ca3345229
--- /dev/null
+++ b/glue/crumpy/pyproject.toml
@@ -0,0 +1,177 @@
+[build-system]
+requires = ["poetry-core>=2.0", "poetry-dynamic-versioning"]
+build-backend = "poetry_dynamic_versioning.backend"
+
+
+[project]
+name = "crumpy"
+authors = [
+ { name = "Sam Zappa (Riverlane)", email = "deltakit@riverlane.com" },
+ { name = "Guen Prawiroatmodjo (Riverlane)", email = "deltakit@riverlane.com" },
+]
+description = "A python library for visualizing Crumble circuits in Jupyter"
+readme = "README.md"
+requires-python = ">=3.11"
+classifiers = [
+ "Development Status :: 1 - Planning",
+ "Intended Audience :: Science/Research",
+ "Intended Audience :: Developers",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Scientific/Engineering",
+ "Typing :: Typed",
+]
+dynamic = ["version"]
+dependencies = [
+ "anywidget>=0.9.18",
+ "cirq>=1.3.0",
+ "ipywidgets>=8.1.7",
+ "ply>=3.11",
+ "qiskit>=2.1.2",
+ "stim>=1.15.0",
+ "stimcirq>=1.15.0",
+ "traitlets>=5.14.3",
+]
+
+[project.urls]
+Homepage = "https://github.com/quantumlib/Stim"
+"Bug Tracker" = "https://github.com/quantumlib/Stim/issues"
+Discussions = "https://github.com/quantumlib/Stim/discussions"
+Changelog = "https://github.com/quantumlib/Stim/releases"
+
+
+[dependency-groups]
+test = [
+ "pytest >=6",
+ "pytest-cov >=3",
+]
+dev = [
+ { include-group = "test" },
+]
+docs = [
+ "sphinx>=7.0",
+ "myst_parser>=0.13",
+ "sphinx_copybutton",
+ "sphinx_autodoc_typehints",
+ "furo>=2023.08.17",
+ "nbsphinx>=0.9.6",
+ "ipykernel>=6.30.1",
+ "stim>=1.15.0",
+ "pandoc>=2.4",
+ "cirq>=1.6.1",
+ "stimcirq>=1.15.0",
+]
+[tool.poetry]
+packages = [{ include = "crumpy", from = "src" }]
+include = [
+ { path = "src/js/bundle.js", format = ["sdist", "wheel"] },
+]
+version = "0.0.0"
+
+[tool.poetry.requires-plugins]
+poetry-dynamic-versioning = { version = ">=1.0.0", extras = ["plugin"] }
+
+[tool.poetry-dynamic-versioning]
+enable = true
+substitution.files = ["src/crumpy/__init__.py"]
+
+[tool.poetry.group.test.dependencies]
+pytest = ">= 6"
+pytest-cov = ">= 3"
+
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"]
+xfail_strict = true
+filterwarnings = [
+ "error",
+]
+log_cli_level = "INFO"
+testpaths = [
+ "tests",
+]
+
+
+[tool.coverage]
+run.source = ["crumpy"]
+report.exclude_also = [
+ '\.\.\.',
+ 'if typing.TYPE_CHECKING:',
+]
+
+[tool.mypy]
+files = ["src", "tests"]
+python_version = "3.11"
+warn_unused_configs = true
+strict = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
+warn_unreachable = true
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[[tool.mypy.overrides]]
+module = "crumpy.*"
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+
+[tool.ruff]
+target-version = "py39"
+exclude = ["*.ipynb"]
+
+[tool.ruff.lint]
+extend-select = [
+ "ARG", # flake8-unused-arguments
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "EM", # flake8-errmsg
+ "EXE", # flake8-executable
+ "G", # flake8-logging-format
+ "I", # isort
+ "ICN", # flake8-import-conventions
+ "NPY", # NumPy specific rules
+ "PD", # pandas-vet
+ "PGH", # pygrep-hooks
+ "PIE", # flake8-pie
+ "PL", # pylint
+ "PT", # flake8-pytest-style
+ "PTH", # flake8-use-pathlib
+ "RET", # flake8-return
+ "RUF", # Ruff-specific
+ "SIM", # flake8-simplify
+ "T20", # flake8-print
+ "UP", # pyupgrade
+ "YTT", # flake8-2020
+]
+ignore = [
+ "PLR09", # Too many <...>
+ "PLR2004", # Magic value used in comparison
+]
+isort.required-imports = ["from __future__ import annotations"]
+# Uncomment if using a _compat.typing backport
+# typing-modules = ["crumpy._compat.typing"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["T20"]
+"noxfile.py" = ["T20"]
+
+
+[tool.pylint]
+py-version = "3.11"
+ignore-paths = [".*/_version.py"]
+reports.output-format = "colorized"
+similarities.ignore-imports = "yes"
+messages_control.disable = [
+ "design",
+ "fixme",
+ "line-too-long",
+ "missing-module-docstring",
+ "missing-function-docstring",
+ "wrong-import-position",
+]
diff --git a/glue/crumpy/simple_example.ipynb b/glue/crumpy/simple_example.ipynb
new file mode 100644
index 000000000..f766628a7
--- /dev/null
+++ b/glue/crumpy/simple_example.ipynb
@@ -0,0 +1,119 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "6eb5b8eb-0dbe-4517-8935-30ced98c7eb5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2025-09-25T00:30:26.471400Z",
+ "iopub.status.busy": "2025-09-25T00:30:26.471260Z",
+ "iopub.status.idle": "2025-09-25T00:30:27.728413Z",
+ "shell.execute_reply": "2025-09-25T00:30:27.728194Z",
+ "shell.execute_reply.started": "2025-09-25T00:30:26.471386Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "8791a3107353463b8790a9001b2a083b",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ "CircuitWidget(stim='\\nH 0\\nCNOT 0 1\\n')"
+ ]
+ },
+ "execution_count": 1,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from crumpy import CircuitWidget\n",
+ "\n",
+ "your_circuit = \"\"\"\n",
+ "H 0\n",
+ "CNOT 0 1\n",
+ "\"\"\"\n",
+ "\n",
+ "circuit = CircuitWidget(stim=your_circuit)\n",
+ "circuit"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "f3ceb663-3dbf-44dd-aecb-a18bd0198ba7",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2025-09-25T00:30:27.728812Z",
+ "iopub.status.busy": "2025-09-25T00:30:27.728677Z",
+ "iopub.status.idle": "2025-09-25T00:30:27.732068Z",
+ "shell.execute_reply": "2025-09-25T00:30:27.731827Z",
+ "shell.execute_reply.started": "2025-09-25T00:30:27.728805Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "7677ee648e74420788ace92e7962e38a",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ "CircuitWidget(indentCircuitLines=False, stim='\\n#!pragma MARKX(0) 0\\nTICK\\nCNOT 0 1\\nTICK\\nH 0\\n')"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "circuit_with_error = \"\"\"\n",
+ "#!pragma MARKX(0) 0\n",
+ "TICK\n",
+ "CNOT 0 1\n",
+ "TICK\n",
+ "H 0\n",
+ "\"\"\"\n",
+ "\n",
+ "w = CircuitWidget(stim=circuit_with_error)\n",
+ "w.indentCircuitLines = False\n",
+ "w"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5f106c05-0a51-410f-a3cc-d1c7af3277b7",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/glue/crumpy/src/crumpy/__init__.py b/glue/crumpy/src/crumpy/__init__.py
new file mode 100644
index 000000000..fd84d4572
--- /dev/null
+++ b/glue/crumpy/src/crumpy/__init__.py
@@ -0,0 +1,115 @@
+"""
+Copyright (c) 2025 Riverlane. All rights reserved.
+
+crumpy: A python library for visualizing Crumble circuits in Jupyter
+"""
+
+# mypy: ignore-errors
+# pylint: disable=abstract-method
+
+from __future__ import annotations
+
+import pathlib
+from importlib.metadata import version
+
+import anywidget
+import cirq
+import qiskit
+import qiskit.qasm3
+import stim
+import stimcirq
+import traitlets
+from cirq.contrib.qasm_import import circuit_from_qasm
+
+__version__ = version(__name__)
+
+__all__ = ["__version__"]
+
+bundler_output_dir = pathlib.Path(__file__).parent.parent / "js"
+
+
+class CircuitWidget(anywidget.AnyWidget):
+ """A Jupyter widget for displaying Crumble circuit diagrams.
+
+ Attributes
+ ----------
+ stim : str
+ Stim circuit to be drawn.
+ indentCircuitLines : bool
+ If circuit lines for subsequent qubits in the same row get indented when drawn.
+ Defaults to True (matching Crumble).
+ curveConnectors : bool
+ If connectors (e.g., the line between control and target of a CNOT) can be drawn curved.
+ Defaults to True (matching Crumble).
+ showAnnotationRegions : bool
+ If detector region and observable marks are shown.
+ Defaults to True (matching Crumble).
+ """
+
+ _esm = bundler_output_dir / "bundle.js"
+ stim = traitlets.Unicode(default_value="", help="Stim circuit to be drawn").tag(
+ sync=True
+ )
+ indentCircuitLines = traitlets.Bool(True).tag(sync=True)
+ curveConnectors = traitlets.Bool(True).tag(sync=True)
+ showAnnotationRegions = traitlets.Bool(True).tag(sync=True)
+
+ @staticmethod
+ def from_cirq(cirq_circuit: cirq.Circuit):
+ """Create a `CircuitWidget` from a `cirq.Circuit`.
+
+ `cirq_circuit` will be transpiled to a `stim.Circuit` before use with `CircuitWidget`.
+ `cirq_circuit` must be transpilable to a `stim.Circuit`.
+
+ Parameters
+ ----------
+ cirq_circuit : cirq.Circuit
+ cirq circuit to visualize
+
+ Raises
+ ------
+ TypeError
+ If `cirq_circuit` is unable to be converted to a `stim.Circuit`
+ """
+ try:
+ stim_circuit = stimcirq.cirq_circuit_to_stim_circuit(cirq_circuit)
+ return CircuitWidget(stim=str(stim_circuit))
+ except TypeError as ex:
+ msg = "Unable to translate cirq circuit to stim."
+ raise TypeError(msg) from ex
+
+ @staticmethod
+ def from_qiskit(qiskit_circuit: qiskit.QuantumCircuit):
+ """Create a `CircuitWidget` from a `qiskit.QuantumCircuit`.
+
+ `qiskit_circuit` will be transpiled to a `stim.Circuit` before use with `CircuitWidget`.
+ `qiskit_circuit` must be transpilable to a `stim.Circuit`.
+
+ Parameters
+ ----------
+ qiskit_circuit : qiskit.QuantumCircuit
+ qiskit circuit to visualize
+
+ Raises
+ ------
+ TypeError
+ If `qiskit_circuit` is unable to be converted to a `stim.Circuit`
+ """
+ try:
+ qasm_circuit = qiskit.qasm3.dumps(qiskit_circuit)
+ cirq_circuit = circuit_from_qasm(qasm_circuit)
+ return CircuitWidget.from_cirq(cirq_circuit)
+ except TypeError as ex:
+ msg = "Unable to translate qiskit circuit to stim."
+ raise TypeError(msg) from ex
+
+ @staticmethod
+ def from_stim(stim_circuit: stim.Circuit):
+ """Create a `CircuitWidget` from a `stim.Circuit`.
+
+ Parameters
+ ----------
+ stim_circuit : stim.Circuit
+ stim circuit to visualize
+ """
+ return CircuitWidget(stim=str(stim_circuit))
diff --git a/glue/crumpy/src/crumpy/py.typed b/glue/crumpy/src/crumpy/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/glue/crumpy/src/js/draw/config.js b/glue/crumpy/src/js/draw/config.js
new file mode 100644
index 000000000..7676b6baa
--- /dev/null
+++ b/glue/crumpy/src/js/draw/config.js
@@ -0,0 +1,63 @@
+/**
+ * Copyright 2023 Craig Gidney
+ * Copyright 2025 Riverlane
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Modifications:
+ * - Refactored for CrumPy
+ */
+
+const pitch = 50;
+const rad = 10;
+const OFFSET_X = -pitch + Math.floor(pitch / 4) + 0.5;
+const OFFSET_Y = -pitch + Math.floor(pitch / 4) + 0.5;
+let indentCircuitLines = true;
+let drawLinksToTimelineViewer = false;
+let curveConnectors = true;
+let showAnnotationRegions = true;
+
+const setIndentCircuitLines = (newBool) => {
+ if (typeof newBool !== "boolean") {
+ throw new TypeError(`Expected a boolean, but got ${typeof newBool}`);
+ }
+ indentCircuitLines = newBool;
+};
+
+const setCurveConnectors = (newBool) => {
+ if (typeof newBool !== "boolean") {
+ throw new TypeError(`Expected a boolean, but got ${typeof newBool}`);
+ }
+ curveConnectors = newBool;
+};
+
+const setShowAnnotationRegions = (newBool) => {
+ if (typeof newBool !== "boolean") {
+ throw new TypeError(`Expected a boolean, but got ${typeof newBool}`);
+ }
+ showAnnotationRegions = newBool;
+};
+
+export {
+ pitch,
+ rad,
+ OFFSET_X,
+ OFFSET_Y,
+ indentCircuitLines,
+ curveConnectors,
+ showAnnotationRegions,
+ setIndentCircuitLines,
+ drawLinksToTimelineViewer,
+ setCurveConnectors,
+ setShowAnnotationRegions,
+};
diff --git a/glue/crumpy/src/js/draw/main_draw.js b/glue/crumpy/src/js/draw/main_draw.js
new file mode 100644
index 000000000..cd2d48e85
--- /dev/null
+++ b/glue/crumpy/src/js/draw/main_draw.js
@@ -0,0 +1,273 @@
+/**
+ * Copyright 2023 Craig Gidney
+ * Copyright 2025 Riverlane
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Modifications:
+ * - Refactored for CrumPy
+ */
+
+import {pitch, rad, showAnnotationRegions, OFFSET_X, OFFSET_Y} from "./config.js"
+import {marker_placement} from "../gates/gateset_markers.js";
+import {drawTimeline} from "./timeline_viewer.js";
+import {PropagatedPauliFrames} from "../circuit/propagated_pauli_frames.js";
+import {stroke_connector_to} from "../gates/gate_draw_util.js"
+import {beginPathPolygon} from './draw_util.js';
+
+/**
+ * @param {!number|undefined} x
+ * @param {!number|undefined} y
+ * @return {![undefined, undefined]|![!number, !number]}
+ */
+function xyToPos(x, y) {
+ if (x === undefined || y === undefined) {
+ return [undefined, undefined];
+ }
+ let focusX = x / pitch;
+ let focusY = y / pitch;
+ let roundedX = Math.floor(focusX * 2 + 0.5) / 2;
+ let roundedY = Math.floor(focusY * 2 + 0.5) / 2;
+ let centerX = roundedX*pitch;
+ let centerY = roundedY*pitch;
+ if (Math.abs(centerX - x) <= rad && Math.abs(centerY - y) <= rad && roundedX % 1 === roundedY % 1) {
+ return [roundedX, roundedY];
+ }
+ return [undefined, undefined];
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} snap
+ * @param {!function(q: !int): ![!number, !number]} qubitCoordsFunc
+ * @param {!PropagatedPauliFrames} propagatedMarkers
+ * @param {!int} mi
+ */
+function drawCrossMarkers(ctx, snap, qubitCoordsFunc, propagatedMarkers, mi) {
+ let crossings = propagatedMarkers.atLayer(snap.curLayer).crossings;
+ if (crossings !== undefined) {
+ for (let {q1, q2, color} of crossings) {
+ let [x1, y1] = qubitCoordsFunc(q1);
+ let [x2, y2] = qubitCoordsFunc(q2);
+ if (color === 'X') {
+ ctx.strokeStyle = 'red';
+ } else if (color === 'Y') {
+ ctx.strokeStyle = 'green';
+ } else if (color === 'Z') {
+ ctx.strokeStyle = 'blue';
+ } else {
+ ctx.strokeStyle = 'purple'
+ }
+ ctx.lineWidth = 8;
+ stroke_connector_to(ctx, x1, y1, x2, y2);
+ ctx.lineWidth = 1;
+ }
+ }
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} snap
+ * @param {!function(q: !int): ![!number, !number]} qubitCoordsFunc
+ * @param {!Map} propagatedMarkerLayers
+ */
+function drawMarkers(ctx, snap, qubitCoordsFunc, propagatedMarkerLayers) {
+ let obsCount = new Map();
+ let detCount = new Map();
+ for (let [mi, p] of propagatedMarkerLayers.entries()) {
+ drawSingleMarker(ctx, snap, qubitCoordsFunc, p, mi, obsCount, detCount);
+ }
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} snap
+ * @param {!function(q: !int): ![!number, !number]} qubitCoordsFunc
+ * @param {!PropagatedPauliFrames} propagatedMarkers
+ * @param {!int} mi
+ * @param {!Map} hitCount
+ */
+function drawSingleMarker(ctx, snap, qubitCoordsFunc, propagatedMarkers, mi, hitCount) {
+ let basesQubitMap = propagatedMarkers.atLayer(snap.curLayer + 0.5).bases;
+
+ // Convert qubit indices to draw coordinates.
+ let basisCoords = [];
+ for (let [q, b] of basesQubitMap.entries()) {
+ basisCoords.push([b, qubitCoordsFunc(q)]);
+ }
+
+ // Draw a polygon for the marker set.
+ if (mi >= 0 && basisCoords.length > 0) {
+ if (basisCoords.every(e => e[0] === 'X')) {
+ ctx.fillStyle = 'red';
+ } else if (basisCoords.every(e => e[0] === 'Y')) {
+ ctx.fillStyle = 'green';
+ } else if (basisCoords.every(e => e[0] === 'Z')) {
+ ctx.fillStyle = 'blue';
+ } else {
+ ctx.fillStyle = 'black';
+ }
+ ctx.strokeStyle = ctx.fillStyle;
+ let coords = basisCoords.map(e => e[1]);
+ let cx = 0;
+ let cy = 0;
+ for (let [x, y] of coords) {
+ cx += x;
+ cy += y;
+ }
+ cx /= coords.length;
+ cy /= coords.length;
+ coords.sort((a, b) => {
+ let [ax, ay] = a;
+ let [bx, by] = b;
+ let av = Math.atan2(ay - cy, ax - cx);
+ let bv = Math.atan2(by - cy, bx - cx);
+ if (ax === cx && ay === cy) {
+ av = -100;
+ }
+ if (bx === cx && by === cy) {
+ bv = -100;
+ }
+ return av - bv;
+ });
+ beginPathPolygon(ctx, coords);
+ ctx.globalAlpha *= 0.25;
+ ctx.fill();
+ ctx.globalAlpha *= 4;
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ }
+
+ // Draw individual qubit markers.
+ for (let [b, [x, y]] of basisCoords) {
+ let {dx, dy, wx, wy} = marker_placement(mi, `${x}:${y}`, hitCount);
+ if (b === 'X') {
+ ctx.fillStyle = 'red'
+ } else if (b === 'Y') {
+ ctx.fillStyle = 'green'
+ } else if (b === 'Z') {
+ ctx.fillStyle = 'blue'
+ } else {
+ throw new Error('Not a pauli: ' + b);
+ }
+ ctx.fillRect(x - dx, y - dy, wx, wy);
+ }
+
+ // Show error highlights.
+ let errorsQubitSet = propagatedMarkers.atLayer(snap.curLayer).errors;
+ for (let q of errorsQubitSet) {
+ let [x, y] = qubitCoordsFunc(q);
+ let {dx, dy, wx, wy} = marker_placement(mi, `${x}:${y}`, hitCount);
+ if (mi < 0) {
+ ctx.lineWidth = 2;
+ } else {
+ ctx.lineWidth = 8;
+ }
+ ctx.strokeStyle = 'magenta'
+ ctx.strokeRect(x - dx, y - dy, wx, wy);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'black'
+ ctx.fillRect(x - dx, y - dy, wx, wy);
+ }
+}
+
+let _defensive_draw_enabled = true;
+
+/**
+ * @param {!boolean} val
+ */
+function setDefensiveDrawEnabled(val) {
+ _defensive_draw_enabled = val;
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!function} body
+ */
+function defensiveDraw(ctx, body) {
+ ctx.save();
+ try {
+ if (_defensive_draw_enabled) {
+ body();
+ } else {
+ try {
+ body();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ } finally {
+ ctx.restore();
+ }
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} snap
+ */
+function draw(ctx, snap) {
+ let circuit = snap.circuit;
+
+ let numPropagatedLayers = 0;
+ for (let layer of circuit.layers) {
+ for (let op of layer.markers) {
+ let gate = op.gate;
+ if (gate.name === "MARKX" || gate.name === "MARKY" || gate.name === "MARKZ") {
+ numPropagatedLayers = Math.max(numPropagatedLayers, op.args[0] + 1);
+ }
+ }
+ }
+
+ let c2dCoordTransform = (x, y) => [x*pitch - OFFSET_X, y*pitch - OFFSET_Y];
+ let qubitDrawCoords = q => {
+ let x = circuit.qubitCoordData[2 * q];
+ let y = circuit.qubitCoordData[2 * q + 1];
+ return c2dCoordTransform(x, y);
+ };
+ let propagatedMarkerLayers = /** @type {!Map} */ new Map();
+ for (let mi = 0; mi < numPropagatedLayers; mi++) {
+ propagatedMarkerLayers.set(mi, PropagatedPauliFrames.fromCircuit(circuit, mi));
+ }
+ let {dets: dets, obs: obs} = circuit.collectDetectorsAndObservables(false);
+ let batch_input = [];
+ for (let mi = 0; mi < dets.length; mi++) {
+ batch_input.push(dets[mi].mids);
+ }
+ for (let mi of obs.keys()) {
+ batch_input.push(obs.get(mi));
+ }
+ let batch_output = PropagatedPauliFrames.batchFromMeasurements(circuit, batch_input);
+ let batch_index = 0;
+
+ if (showAnnotationRegions) {
+ for (let mi = 0; mi < dets.length; mi++) {
+ propagatedMarkerLayers.set(~mi, batch_output[batch_index++]);
+ }
+ for (let mi of obs.keys()) {
+ propagatedMarkerLayers.set(~mi ^ (1 << 30), batch_output[batch_index++]);
+ }
+ }
+
+ drawTimeline(
+ ctx,
+ snap,
+ propagatedMarkerLayers,
+ qubitDrawCoords,
+ circuit.layers.length,
+ );
+
+ ctx.save();
+}
+
+export {xyToPos, draw, setDefensiveDrawEnabled, OFFSET_X, OFFSET_Y}
diff --git a/glue/crumpy/src/js/draw/timeline_viewer.js b/glue/crumpy/src/js/draw/timeline_viewer.js
new file mode 100644
index 000000000..712ccd91d
--- /dev/null
+++ b/glue/crumpy/src/js/draw/timeline_viewer.js
@@ -0,0 +1,282 @@
+/**
+ * Copyright 2023 Craig Gidney
+ * Copyright 2025 Riverlane
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Modifications:
+ * - Refactored for CrumPy
+ */
+
+import { indentCircuitLines, drawLinksToTimelineViewer, OFFSET_Y, rad } from "./config.js";
+import { stroke_connector_to } from "../gates/gate_draw_util.js";
+import { marker_placement } from "../gates/gateset_markers.js";
+
+let TIMELINE_PITCH = 32;
+let PADDING_VERTICAL = rad;
+let MAX_CANVAS_WIDTH = 4096;
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} ds
+ * @param {!function(!int, !number): ![!number, !number]} qubitTimeCoordFunc
+ * @param {!PropagatedPauliFrames} propagatedMarkers
+ * @param {!int} mi
+ * @param {!int} min_t
+ * @param {!int} max_t
+ * @param {!number} x_pitch
+ * @param {!Map} hitCounts
+ */
+function drawTimelineMarkers(ctx, ds, qubitTimeCoordFunc, propagatedMarkers, mi, min_t, max_t, x_pitch, hitCounts) {
+ for (let t = min_t - 1; t <= max_t; t++) {
+ if (!hitCounts.has(t)) {
+ hitCounts.set(t, new Map());
+ }
+ let hitCount = hitCounts.get(t);
+ let p1 = propagatedMarkers.atLayer(t + 0.5);
+ let p0 = propagatedMarkers.atLayer(t);
+ for (let [q, b] of p1.bases.entries()) {
+ let {dx, dy, wx, wy} = marker_placement(mi, q, hitCount);
+ if (mi >= 0 && mi < 4) {
+ dx = 0;
+ wx = x_pitch;
+ wy = 5;
+ if (mi === 0) {
+ dy = 10;
+ } else if (mi === 1) {
+ dy = 5;
+ } else if (mi === 2) {
+ dy = 0;
+ } else if (mi === 3) {
+ dy = -5;
+ }
+ } else {
+ dx -= x_pitch / 2;
+ }
+ let [x, y] = qubitTimeCoordFunc(q, t);
+ if (x === undefined || y === undefined) {
+ continue;
+ }
+ if (b === 'X') {
+ ctx.fillStyle = 'red'
+ } else if (b === 'Y') {
+ ctx.fillStyle = 'green'
+ } else if (b === 'Z') {
+ ctx.fillStyle = 'blue'
+ } else {
+ throw new Error('Not a pauli: ' + b);
+ }
+ ctx.fillRect(x - dx, y - dy, wx, wy);
+ }
+ for (let q of p0.errors) {
+ let {dx, dy, wx, wy} = marker_placement(mi, q, hitCount);
+ dx -= x_pitch / 2;
+
+ let [x, y] = qubitTimeCoordFunc(q, t - 0.5);
+ if (x === undefined || y === undefined) {
+ continue;
+ }
+ ctx.strokeStyle = 'magenta';
+ ctx.lineWidth = 8;
+ ctx.strokeRect(x - dx, y - dy, wx, wy);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'black';
+ ctx.fillRect(x - dx, y - dy, wx, wy);
+ }
+ for (let {q1, q2, color} of p0.crossings) {
+ let [x1, y1] = qubitTimeCoordFunc(q1, t);
+ let [x2, y2] = qubitTimeCoordFunc(q2, t);
+ if (color === 'X') {
+ ctx.strokeStyle = 'red';
+ } else if (color === 'Y') {
+ ctx.strokeStyle = 'green';
+ } else if (color === 'Z') {
+ ctx.strokeStyle = 'blue';
+ } else {
+ ctx.strokeStyle = 'purple'
+ }
+ ctx.lineWidth = 8;
+ stroke_connector_to(ctx, x1, y1, x2, y2);
+ ctx.lineWidth = 1;
+ }
+ }
+}
+
+/**
+ * @param {!CanvasRenderingContext2D} ctx
+ * @param {!StateSnapshot} snap
+ * @param {!Map} propagatedMarkerLayers
+ * @param {!function(!int): ![!number, !number]} timesliceQubitCoordsFunc
+ * @param {!int} numLayers
+ */
+function drawTimeline(ctx, snap, propagatedMarkerLayers, timesliceQubitCoordsFunc, numLayers) {
+ let w = MAX_CANVAS_WIDTH;
+
+ let qubits = snap.timelineQubits();
+ qubits.sort((a, b) => {
+ let [x1, y1] = timesliceQubitCoordsFunc(a);
+ let [x2, y2] = timesliceQubitCoordsFunc(b);
+ if (y1 !== y2) {
+ return y1 - y2;
+ }
+ return x1 - x2;
+ });
+
+ let base_y2xy = new Map();
+ let prev_y = undefined;
+ let cur_x = 0;
+ let cur_y = PADDING_VERTICAL + rad;
+ let max_run = 0;
+ let cur_run = 0;
+ for (let q of qubits) {
+ let [x, y] = timesliceQubitCoordsFunc(q);
+ if (prev_y !== y) {
+ cur_x = w / 2;
+ max_run = Math.max(max_run, cur_run);
+ cur_run = 0;
+ if (prev_y !== undefined) {
+ // first qubit's y is at initial cur_y value
+ cur_y += TIMELINE_PITCH * 0.25;
+ }
+ prev_y = y;
+ } else {
+ if (indentCircuitLines) {
+ cur_x += rad * 0.25; // slight x offset between qubits in a row
+ }
+ cur_run++;
+ }
+ base_y2xy.set(`${x},${y}`, [Math.round(cur_x) + 0.5, Math.round(cur_y) + 0.5]);
+ cur_y += TIMELINE_PITCH;
+ }
+
+ let x_pitch = TIMELINE_PITCH + Math.ceil(rad*max_run*0.25);
+ let num_cols_half = Math.floor(w / 2 / x_pitch);
+ let min_t_free = snap.curLayer - num_cols_half + 1;
+ let min_t_clamp = Math.max(0, Math.min(min_t_free, numLayers - num_cols_half*2 + 1));
+ let max_t = Math.min(min_t_clamp + num_cols_half*2 + 2, numLayers);
+ let t2t = t => {
+ let dt = t - snap.curLayer;
+ dt -= min_t_clamp - min_t_free;
+ return dt*x_pitch;
+ }
+ let coordTransform_t = ([x, y, t]) => {
+ let key = `${x},${y}`;
+ if (!base_y2xy.has(key)) {
+ return [undefined, undefined];
+ }
+ let [xb, yb] = base_y2xy.get(key);
+ return [xb + t2t(t), yb];
+ };
+ let qubitTimeCoords = (q, t) => {
+ let [x, y] = timesliceQubitCoordsFunc(q);
+ return coordTransform_t([x, y, t]);
+ };
+
+ ctx.save();
+
+ // Using coords function, see if any qubit labels would get cut off
+ let maxLabelWidth = 0;
+ let topLeftX = qubitTimeCoords(qubits[0], min_t_clamp - 1)[0];
+ for (let q of qubits) {
+ let [x, y] = qubitTimeCoords(q, min_t_clamp - 1);
+ let qx = snap.circuit.qubitCoordData[q * 2];
+ let qy = snap.circuit.qubitCoordData[q * 2 + 1];
+ let label = `${qx},${qy}:`;
+ let labelWidth = ctx.measureText(label).width;
+ let labelWidthFromTop = labelWidth - (x - topLeftX);
+ maxLabelWidth = Math.max(maxLabelWidth, labelWidthFromTop);
+ }
+ let textOverflowLen = Math.max(0, maxLabelWidth - topLeftX);
+
+ // Adjust coords function to ensure all qubit labels fit on canvas (+ small pad)
+ let labelShiftedQTC = (q, t) => {
+ let [x, y] = qubitTimeCoords(q, t);
+ return [x + Math.ceil(textOverflowLen) + 3, y];
+ };
+
+ // Resize canvas to fit circuit
+ let timelineHeight =
+ labelShiftedQTC(qubits.at(-1), max_t + 1)[1] + rad + PADDING_VERTICAL; // y of lowest qubit line + padding
+ let timelineWidth = Math.max(
+ ...qubits.map((q) => labelShiftedQTC(q, max_t + 1)[0]),
+ ); // max x of any qubit line's endpoint
+ ctx.canvas.width = Math.floor(timelineWidth);
+ ctx.canvas.height = Math.floor(timelineHeight);
+
+ try {
+ ctx.fillStyle = 'white';
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+
+ // Draw colored indicators showing Pauli propagation.
+ let hitCounts = new Map();
+ for (let [mi, p] of propagatedMarkerLayers.entries()) {
+ drawTimelineMarkers(ctx, snap, labelShiftedQTC, p, mi, min_t_clamp, max_t, x_pitch, hitCounts);
+ }
+
+ // Draw wire lines.
+ ctx.strokeStyle = 'black';
+ ctx.fillStyle = 'black';
+ for (let q of qubits) {
+ let [x0, y0] = labelShiftedQTC(q, min_t_clamp - 1);
+ let [x1, y1] = labelShiftedQTC(q, max_t + 1);
+ ctx.beginPath();
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x1, y1);
+ ctx.stroke();
+ }
+
+ // Draw wire labels.
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ for (let q of qubits) {
+ let [x, y] = labelShiftedQTC(q, min_t_clamp - 1);
+ let qx = snap.circuit.qubitCoordData[q * 2];
+ let qy = snap.circuit.qubitCoordData[q * 2 + 1];
+ let label = `${qx},${qy}:`;
+ ctx.fillText(label, x, y);
+ }
+
+ // Draw layers of gates.
+ for (let time = min_t_clamp; time <= max_t; time++) {
+ let qubitsCoordsFuncForLayer = (q) => labelShiftedQTC(q, time);
+ let layer = snap.circuit.layers[time];
+ if (layer === undefined) {
+ continue;
+ }
+ for (let op of layer.iter_gates_and_markers()) {
+ op.id_draw(qubitsCoordsFuncForLayer, ctx);
+ }
+ }
+ if (drawLinksToTimelineViewer) {
+ // Draw links to timeslice viewer.
+ ctx.globalAlpha = 0.5;
+ for (let q of qubits) {
+ let [x0, y0] = qubitTimeCoords(q, min_t_clamp - 1);
+ let [x1, y1] = timesliceQubitCoordsFunc(q);
+ if (snap.curMouseX > ctx.canvas.width / 2 && snap.curMouseY >= y0 + OFFSET_Y - TIMELINE_PITCH * 0.55 && snap.curMouseY <= y0 + TIMELINE_PITCH * 0.55 + OFFSET_Y) {
+ ctx.beginPath();
+ ctx.moveTo(x0, y0);
+ ctx.lineTo(x1, y1);
+ ctx.stroke();
+ ctx.fillStyle = 'black';
+ ctx.fillRect(x1 - 20, y1 - 20, 40, 40);
+ ctx.fillRect(ctx.canvas.width / 2, y0 - TIMELINE_PITCH / 3, ctx.canvas.width / 2, TIMELINE_PITCH * 2 / 3);
+ }
+ }
+ }
+ } finally {
+ ctx.restore();
+ }
+}
+
+export { drawTimeline };
diff --git a/glue/crumpy/src/js/main.js b/glue/crumpy/src/js/main.js
new file mode 100644
index 000000000..d9d4dd42f
--- /dev/null
+++ b/glue/crumpy/src/js/main.js
@@ -0,0 +1,105 @@
+/**
+ * Copyright 2023 Craig Gidney
+ * Copyright 2025 Riverlane
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Modifications:
+ * - Refactored for CrumPy
+ */
+
+import { Circuit } from "./circuit/circuit.js";
+import { draw } from "./draw/main_draw.js";
+import { EditorState } from "./editor/editor_state.js";
+import {
+ setIndentCircuitLines,
+ setCurveConnectors,
+ setShowAnnotationRegions,
+} from "./draw/config.js";
+
+const CANVAS_W = 600;
+const CANVAS_H = 300;
+
+function initCanvas(el) {
+ let scrollWrap = document.createElement("div");
+ scrollWrap.setAttribute("style", "overflow-x: auto; overflow-y: hidden;");
+
+ let canvas = document.createElement("canvas");
+ canvas.id = "cvn";
+ canvas.setAttribute("style", `margin: 0; padding: 0;`);
+ canvas.tabIndex = 0;
+ canvas.width = CANVAS_W;
+ canvas.height = CANVAS_H;
+
+ scrollWrap.appendChild(canvas);
+ el.appendChild(scrollWrap);
+
+ return canvas;
+}
+
+function render({ model, el }) {
+ const traitlets = {
+ getStim: () => model.get("stim"),
+ getIndentCircuitLines: () => model.get("indentCircuitLines"),
+ getCurveConnectors: () => model.get("curveConnectors"),
+ getShowAnnotationRegions: () => model.get("showAnnotationRegions"),
+ };
+
+ const canvas = initCanvas(el);
+
+ let editorState = /** @type {!EditorState} */ new EditorState(canvas);
+
+ const exportCurrentState = () =>
+ editorState
+ .copyOfCurCircuit()
+ .toStimCircuit()
+ .replaceAll("\nPOLYGON", "\n#!pragma POLYGON")
+ .replaceAll("\nERR", "\n#!pragma ERR")
+ .replaceAll("\nMARK", "\n#!pragma MARK");
+ function commitStimCircuit(stim_str) {
+ let circuit = Circuit.fromStimCircuit(stim_str);
+ editorState.commit(circuit);
+ }
+
+ // Changes to circuit on the Python/notebook side update the JS
+ model.on("change:stim", () => commitStimCircuit(traitlets.getStim()));
+ model.on("change:indentCircuitLines", () => {
+ setIndentCircuitLines(traitlets.getIndentCircuitLines());
+ editorState.force_redraw();
+ });
+ model.on("change:curveConnectors", () => {
+ setCurveConnectors(traitlets.getCurveConnectors());
+ editorState.force_redraw();
+ });
+ model.on("change:showAnnotationRegions", () => {
+ setShowAnnotationRegions(traitlets.getShowAnnotationRegions());
+ editorState.force_redraw();
+ });
+
+ // Listeners on editor state that trigger redraws
+ editorState.rev.changes().subscribe(() => {
+ editorState.obs_val_draw_state.set(editorState.toSnapshot(undefined));
+ });
+ editorState.obs_val_draw_state.observable().subscribe((ds) =>
+ requestAnimationFrame(() => {
+ draw(editorState.canvas.getContext("2d"), ds);
+ }),
+ );
+
+ // Configure initial settings and stim
+ setIndentCircuitLines(traitlets.getIndentCircuitLines());
+ setCurveConnectors(traitlets.getCurveConnectors());
+ setShowAnnotationRegions(traitlets.getShowAnnotationRegions());
+ commitStimCircuit(traitlets.getStim());
+}
+export default { render };
diff --git a/glue/crumpy/src/js/package-lock.json b/glue/crumpy/src/js/package-lock.json
new file mode 100644
index 000000000..3ca99961a
--- /dev/null
+++ b/glue/crumpy/src/js/package-lock.json
@@ -0,0 +1,497 @@
+{
+ "name": "crumpy",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "crumpy",
+ "devDependencies": {
+ "esbuild": "0.25.8"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
+ "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
+ "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
+ "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
+ "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
+ "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
+ "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
+ "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
+ "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
+ "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
+ "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
+ "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
+ "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
+ "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
+ "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
+ "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
+ "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
+ "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
+ "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
+ "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
+ "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
+ "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
+ "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
+ "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.8",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
+ "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.8",
+ "@esbuild/android-arm": "0.25.8",
+ "@esbuild/android-arm64": "0.25.8",
+ "@esbuild/android-x64": "0.25.8",
+ "@esbuild/darwin-arm64": "0.25.8",
+ "@esbuild/darwin-x64": "0.25.8",
+ "@esbuild/freebsd-arm64": "0.25.8",
+ "@esbuild/freebsd-x64": "0.25.8",
+ "@esbuild/linux-arm": "0.25.8",
+ "@esbuild/linux-arm64": "0.25.8",
+ "@esbuild/linux-ia32": "0.25.8",
+ "@esbuild/linux-loong64": "0.25.8",
+ "@esbuild/linux-mips64el": "0.25.8",
+ "@esbuild/linux-ppc64": "0.25.8",
+ "@esbuild/linux-riscv64": "0.25.8",
+ "@esbuild/linux-s390x": "0.25.8",
+ "@esbuild/linux-x64": "0.25.8",
+ "@esbuild/netbsd-arm64": "0.25.8",
+ "@esbuild/netbsd-x64": "0.25.8",
+ "@esbuild/openbsd-arm64": "0.25.8",
+ "@esbuild/openbsd-x64": "0.25.8",
+ "@esbuild/openharmony-arm64": "0.25.8",
+ "@esbuild/sunos-x64": "0.25.8",
+ "@esbuild/win32-arm64": "0.25.8",
+ "@esbuild/win32-ia32": "0.25.8",
+ "@esbuild/win32-x64": "0.25.8"
+ }
+ }
+ }
+}
diff --git a/glue/crumpy/src/js/package.json b/glue/crumpy/src/js/package.json
new file mode 100644
index 000000000..fb0f03b34
--- /dev/null
+++ b/glue/crumpy/src/js/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "crumpy",
+ "type": "module",
+ "main": "js/main.js",
+ "scripts": {
+ "copy": "cp -r ../../../crumble ./crumble_js/ && cp draw/* crumble_js/draw/ && cp main.js crumble_js/",
+ "bundle": "esbuild --bundle --format=esm --outfile=../bundle.js ./crumble_js/main.js",
+ "cleanup": "rm -r crumble_js",
+ "build": "npm run copy && npm run bundle && npm run cleanup",
+ "watch": "npm run copy && npm run bundle -- --watch"
+ },
+ "devDependencies": {
+ "esbuild": "0.25.8"
+ }
+}
\ No newline at end of file
diff --git a/glue/crumpy/tests/test_package.py b/glue/crumpy/tests/test_package.py
new file mode 100644
index 000000000..73f7cd39f
--- /dev/null
+++ b/glue/crumpy/tests/test_package.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+import importlib.metadata
+
+import crumpy as m
+
+
+def test_version():
+ assert m.__version__ in importlib.metadata.version("crumpy")
diff --git a/glue/crumpy/tests/test_widget.py b/glue/crumpy/tests/test_widget.py
new file mode 100644
index 000000000..dfd9d3df4
--- /dev/null
+++ b/glue/crumpy/tests/test_widget.py
@@ -0,0 +1,124 @@
+# mypy: ignore-errors
+
+from __future__ import annotations
+
+import cirq
+import ipywidgets
+import pytest
+import qiskit
+import stim
+import traitlets
+
+from crumpy import CircuitWidget
+
+
+def test_basic_no_exception():
+ """CircuitWidget should initialize without errors."""
+ CircuitWidget(stim="H 0;", indentCircuitLines=False, curveConnectors=False)
+
+
+def test_bad_stim_instruction_no_exception():
+ """CircuitWidget should not raise an error if given any invalid stim instructions."""
+ CircuitWidget(stim="H 0;thisIsNotAnInstruction 123;CNOT 0 1;")
+
+
+def test_bad_trait_assignment_exception():
+ """Assigning a non-string value to a CircuitWidget's stim should raise a TraitError."""
+ widg = CircuitWidget()
+ with pytest.raises(traitlets.TraitError):
+ widg.stim = 123
+
+
+def test_has_traits():
+ """CircuitWidget should have the expected traits and sync metadata."""
+ widg = CircuitWidget()
+ assert widg.has_trait("stim")
+ assert widg.has_trait("indentCircuitLines")
+ assert widg.has_trait("curveConnectors")
+ assert widg.has_trait("showAnnotationRegions")
+ assert widg.trait_metadata("stim", "sync")
+ assert widg.trait_metadata("indentCircuitLines", "sync")
+ assert widg.trait_metadata("curveConnectors", "sync")
+ assert widg.trait_metadata("showAnnotationRegions", "sync")
+
+
+def test_use_case_dlink():
+ """CircuitWidget should update its stim when linked to an IntSlider via ipywidgets.dlink."""
+
+ def sliderToCircuit(slider_val):
+ return "H 0;" * int(slider_val)
+
+ circuit = CircuitWidget()
+ slider_default_value = 1
+ slider = ipywidgets.IntSlider(
+ description="# of H gates", value=slider_default_value, min=0, max=30
+ )
+ ipywidgets.dlink((slider, "value"), (circuit, "stim"), sliderToCircuit)
+ assert circuit.stim == sliderToCircuit(slider_default_value)
+
+ new_slider_value = 5
+ slider.value = new_slider_value
+ assert circuit.stim == sliderToCircuit(new_slider_value)
+
+ new_stim = "Y 0;"
+ circuit.stim = new_stim
+ assert circuit.stim == new_stim
+
+
+class Test_CircuitImporting:
+ def test_from_cirq_valid(self):
+ """CircuitWidget.from_cirq should create a CircuitWidget with the correct circuit when given a valid (stim-transpilable) cirq circuit."""
+
+ q0 = cirq.LineQubit(0)
+ q1 = cirq.LineQubit(1)
+ cirq_circuit = cirq.Circuit(cirq.H(q0), cirq.CNOT(q0, q1))
+
+ widg = CircuitWidget.from_cirq(cirq_circuit)
+
+ assert "H 0" in widg.stim
+ assert "CNOT 0 1" in widg.stim or "CX 0 1" in widg.stim
+
+ def test_from_cirq_invalid(self):
+ """CircuitWidget.from_cirq should raise an error when given an invalid (non-stim-transpilable) cirq circuit."""
+
+ q0 = cirq.LineQubit(0)
+ q1 = cirq.LineQubit(1)
+ cirq_circuit = cirq.Circuit(cirq.H(q0), cirq.CNOT(q0, q1), cirq.rx(2).on(q0))
+ with pytest.raises(TypeError):
+ CircuitWidget.from_cirq(cirq_circuit)
+
+ def test_from_qiskit_valid(self):
+ """CircuitWidget.from_qiskit should create a CircuitWidget with the correct circuit when given a valid (stim-transpilable) qiskit circuit."""
+
+ qc = qiskit.QuantumCircuit(2)
+ qc.z(0)
+ qc.cx(1, 0)
+
+ widg = CircuitWidget.from_qiskit(qc)
+
+ assert "Z 0" in widg.stim
+ assert "CNOT 1 0" in widg.stim or "CX 1 0" in widg.stim
+
+ def test_from_qiskit_invalid(self):
+ """CircuitWidget.from_qiskit should raise an error when given an invalid (non-stim-transpilable) qiskit circuit."""
+
+ qc = qiskit.QuantumCircuit(2)
+ qc.z(0)
+ qc.cx(1, 0)
+ qc.ry(1.234, 0)
+
+ with pytest.raises(TypeError):
+ CircuitWidget.from_qiskit(qc)
+
+ def test_from_stim(self):
+ """CircuitWidget.from_stim should create a CircuitWidget with the given stim circuit."""
+
+ stim_circuit = stim.Circuit("""
+ X 1
+ CY 0 1
+ """)
+
+ widg = CircuitWidget.from_stim(stim_circuit)
+
+ assert "X 1" in widg.stim
+ assert "CY 0 1" in widg.stim # also covers alternate name ZCY