diff --git a/build.py b/build.py index 026bab13e3e..a11c8cde4e9 100755 --- a/build.py +++ b/build.py @@ -769,6 +769,7 @@ def run_ci_historic_benchmark(): notebook_files = [ os.path.join(dp, f) for dp, _, filenames in os.walk(samples_src) + if os.path.basename(dp) != "qdk_ec" for f in filenames if f.endswith(".ipynb") and not f.startswith(SKIP_NOTEBOOK_PREFIXES) ] diff --git a/samples/notebooks/qdk_ec/c4.qodec.yaml b/samples/notebooks/qdk_ec/c4.qodec.yaml new file mode 100644 index 00000000000..3813eab9ec4 --- /dev/null +++ b/samples/notebooks/qdk_ec/c4.qodec.yaml @@ -0,0 +1,438 @@ +--- +qodec.yaml: + name: c4 + description: Protocol based on Knill's C4 + layers: + - instruction_set: C4.isa.yaml + codes: + c4: C4.code.yaml + gadgets: + idle: idle.gadget.yaml + measure_xx: measure_xx.gadget.yaml + measure_zz: measure_zz.gadget.yaml + prepare_xx: prepare_xx.gadget.yaml + prepare_zz: prepare_zz.gadget.yaml + transversal_cx: transversal_cx.gadget.yaml + x0: x0.gadget.yaml + x1: x1.gadget.yaml + z0: z0.gadget.yaml + z1: z1.gadget.yaml + - instruction_set: stim.isa.yaml +--- +C4.isa.yaml: + name: C4 + blocks: + c4: 2 + instructions: + - mnemonic: prepare_zz + description: '' + out: + - c4 + action: + - stabilize: + - Z_0 + - Z_1 + flags: + - reject + - mnemonic: idle + description: '' + in: + - c4 + out: + - c4 + - mnemonic: measure_zz + description: '' + in: + - c4 + action: + - observe: + - Z_0 + - Z_1 + - mnemonic: prepare_xx + description: '' + out: + - c4 + action: + - stabilize: + - X_0 + - X_1 + flags: + - reject + - mnemonic: measure_xx + description: '' + in: + - c4 + action: + - observe: + - X_0 + - X_1 + - mnemonic: transversal_cx + description: '' + in: + - c4 + - c4 + out: + - c4 + - c4 + action: + - clifford: + X_0: X_0 X_2 + X_1: X_1 X_3 + Z_2: Z_0 Z_2 + Z_3: Z_1 Z_3 + - mnemonic: x0 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: X_0 + - mnemonic: x1 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: X_1 + - mnemonic: z0 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: Z_0 + - mnemonic: z1 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: Z_1 +--- +stim.isa.yaml: + name: stim + blocks: + qubit: 1 + instructions: + - mnemonic: R + description: '' + out: + - qubit + action: + - stabilize: + - Z_0 + - mnemonic: H + description: '' + in: + - qubit + out: + - qubit + action: + - clifford: + X_0: Z_0 + Z_0: X_0 + - mnemonic: CX + description: '' + in: + - qubit + - qubit + out: + - qubit + - qubit + action: + - clifford: + X_0: X_0 X_1 + Z_1: Z_0 Z_1 + - mnemonic: M + description: '' + in: + - qubit + action: + - observe: Z_0 + - mnemonic: X + description: '' + in: + - qubit + out: + - qubit + action: + - pauli: X_0 + - mnemonic: Z + description: '' + in: + - qubit + out: + - qubit + action: + - pauli: Z_0 +--- +C4.code.yaml: + name: C4 + stabilizers: + - X_0 X_1 X_2 X_3 + - Z_0 Z_1 Z_2 Z_3 + x: + - X_0 X_1 + - X_0 X_2 + z: + - Z_0 Z_2 + - Z_0 Z_1 +--- +idle.gadget.yaml: + implements: ./C4.isa.yaml#idle + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Data qubits: 0-3; X-stabilizer ancilla: 4; Z-stabilizer ancilla: 5 + R 4 5 + H 4 + CX 4 0 4 1 4 2 4 3 + H 4 + CX 0 5 1 5 2 5 3 5 + M 4 5 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - in[0].stabilizers[0] + - - circuit.readouts[1] + - in[0].stabilizers[1] + - - circuit.readouts[0] + - out[0].stabilizers[0] + - - circuit.readouts[1] + - out[0].stabilizers[1] +--- +measure_xx.gadget.yaml: + implements: ./C4.isa.yaml#measure_xx + circuit: + instruction_set: ./stim.isa.yaml + source: | + H 0 1 2 3 + M 0 1 2 3 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - circuit.readouts[1] + - circuit.readouts[2] + - circuit.readouts[3] + - in[0].stabilizers[0] + readouts: + - - circuit.readouts[0] + - circuit.readouts[1] + - - circuit.readouts[0] + - circuit.readouts[2] +--- +measure_zz.gadget.yaml: + implements: ./C4.isa.yaml#measure_zz + circuit: + instruction_set: ./stim.isa.yaml + source: | + M 0 1 2 3 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - circuit.readouts[1] + - circuit.readouts[2] + - circuit.readouts[3] + - in[0].stabilizers[1] + readouts: + - - circuit.readouts[0] + - circuit.readouts[2] + - - circuit.readouts[0] + - circuit.readouts[1] +--- +prepare_xx.gadget.yaml: + implements: ./C4.isa.yaml#prepare_xx + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Fault-tolerant preparation of |++⟩_L in XX basis + R 0 1 2 3 + H 0 + CX 0 4 + CX 0 1 + CX 0 2 + CX 0 3 + CX 0 4 + H 0 1 2 3 + # Flag = reject bit + M 4 + format: stim + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - out[0].stabilizers[0] + - - out[0].stabilizers[1] + readouts: + - reject: + - circuit.readouts[0] +--- +prepare_zz.gadget.yaml: + implements: ./C4.isa.yaml#prepare_zz + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Fault-tolerant preparation of |00⟩_L in ZZ basis + R 0 1 2 3 + H 0 + CX 0 4 + CX 0 1 + CX 0 2 + CX 0 3 + CX 0 4 + # Flag = reject bit + M 4 + format: stim + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - out[0].stabilizers[0] + - - out[0].stabilizers[1] + readouts: + - reject: + - circuit.readouts[0] +--- +transversal_cx.gadget.yaml: + implements: ./C4.isa.yaml#transversal_cx + circuit: + instruction_set: ./stim.isa.yaml + source: | + CX 0 4 1 5 2 6 3 7 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + - c4: + - 4 + - 5 + - 6 + - 7 + out: + - c4: + - 0 + - 1 + - 2 + - 3 + - c4: + - 4 + - 5 + - 6 + - 7 +--- +x0.gadget.yaml: + implements: ./C4.isa.yaml#x0 + circuit: + instruction_set: ./stim.isa.yaml + source: | + X 0 1 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +x1.gadget.yaml: + implements: ./C4.isa.yaml#x1 + circuit: + instruction_set: ./stim.isa.yaml + source: | + X 0 2 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +z0.gadget.yaml: + implements: ./C4.isa.yaml#z0 + circuit: + instruction_set: ./stim.isa.yaml + source: | + Z 0 2 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +z1.gadget.yaml: + implements: ./C4.isa.yaml#z1 + circuit: + instruction_set: ./stim.isa.yaml + source: | + Z 0 1 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 diff --git a/samples/notebooks/qdk_ec/qdk_ec_walkthrough.ipynb b/samples/notebooks/qdk_ec/qdk_ec_walkthrough.ipynb new file mode 100644 index 00000000000..bd1e69f9b7e --- /dev/null +++ b/samples/notebooks/qdk_ec/qdk_ec_walkthrough.ipynb @@ -0,0 +1,1431 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Develop and test a QEC protocol (qodec)\n", + "\n", + "Use `qdk` and `qodec` to develop and test a quantum error correction protocol. Start with the C4 code and build a baseline qodec. Then inspect its gadgets and improve them. Along the way, use `qdk`'s built-in profile and audit features to debug and validate." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Executive summary\n", + "\n", + "```python\n", + "code = qodec.Code(stabilizers=..., x=..., z=...) # define a code\n", + "protocol = qdk.ec.build_qodec(c4, strategy=...) # build a qodec\n", + "qdk.ec.audit(protocol) # validate the qodec\n", + "results = qdk.run_qir(qir_program, qodec=protocol, ...) # simulate a (logical!) program\n", + "```\n", + "\n", + "TODO: Plot of the results" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Install\n", + "\n", + "```bash\n", + "pip install \"qdk[ec]\"\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Define your code\n", + "\n", + "We will base our protocol on the $4$-qubit $[[4,2,2]]$ error-detecting code C4.\n", + "The C4 code stores two logical qubits in four physical qubits. Its two stabilizers are $XXXX$ and $ZZZZ$.\n", + "\n", + "The `qodec` package is used to define the protocol; `qdk` provides the analysis." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import qodec\n", + "\n", + "c4 = qodec.Code(\n", + " \"C4\",\n", + " stabilizers=[\"X_0 X_1 X_2 X_3\", \"Z_0 Z_1 Z_2 Z_3\"],\n", + " x=[\"X_0 X_1\", \"X_0 X_2\"],\n", + " z=[\"Z_0 Z_2\", \"Z_0 Z_1\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Pauli strings in `qodec` use the sparse notation `P_i`, where `P` is one of the Pauli matrices `{X, Y, Z}` and `i` is the index of the qubit on which the Pauli acts." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Check the code distance\n", + "\n", + "We've defined the code. Now we'd like to make sure it's _correct_. In particular, it should have distance two. QDK provides a `CodeProfile` that, among other things, can compute the code distance." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Code distance: 2\n", + "Witness: XX, (syndrome: [], logical effect: X)\n" + ] + } + ], + "source": [ + "import qdk.ec as ec\n", + "\n", + "code = ec.CodeProfile(c4)\n", + "code_distance = code.distance()\n", + "print(\"Code distance:\", code_distance)\n", + "\n", + "error = code_distance.witness.product\n", + "syndrome = code.syndrome_of(error)\n", + "effect = code.logical_effect_of(error)\n", + "\n", + "print(f\"Witness: {error}, (syndrome: {list(syndrome)}, logical effect: {effect})\")\n", + "assert code_distance == error.weight == 2\n", + "assert code.is_logical(error)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Build a baseline qodec\n", + "\n", + "We have a code. But a code alone is a mathematical object: it doesn't describe how to _use_ it on a quantum computer. For that, we need a qodec, a formal description of an executable protocol.\n", + "\n", + "Authoring a full qodec, including its instruction sets and gadgets, can be a substantial task. To get started, QDK provides `build_qodec`, which builds a baseline protocol from a code definition. There are many ways to do this; the `strategy` parameter selects the recipe.\n", + "\n", + "We will use `bare-css/v1`, a strategy that measures syndromes with ancillas but adds no flag qubits.\n", + "\n", + "The result has two layers: logical C4 operations and physical Stim operations. Each logical instruction has a _gadget_: a circuit that implements it using the next layer. Its encodings identify the physical qubits for each logical block; its equations describe checks and readouts.\n", + "\n", + "Let's build the qodec and inspect the start of its YAML representation. Then we'll use `gadgets` to edit the protocol directly." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---\n", + "qodec.yaml:\n", + " name: C4\n", + " description: 'Built from the ''C4'' stabilizer code ([[4, 2]]). Strategy: bare-css/v1.'\n", + " layers:\n", + " - instruction_set: C4.isa.yaml\n", + " codes:\n", + " C4: C4.code.yaml\n", + " gadgets:\n", + " cx_all: cx_all.gadget.yaml\n", + " measure_x_all: measure_x_all.gadget.yaml\n", + " measure_z_all: measure_z_all.gadget.yaml\n", + " prepare_x_all: prepare_x_all.gadget.yaml\n", + " prepare_z_all: prepare_z_all.gadget.yaml\n", + " syndrome: syndrome.gadget.yaml\n", + " - instruction_set: stim.isa.yaml\n", + " ...\n" + ] + } + ], + "source": [ + "protocol = ec.build_qodec(c4, strategy=\"bare-css/v1\", strict=False)\n", + "print(protocol.dumps()[:500], \"...\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Inspect `prepare` instructions and gadgets\n", + "\n", + "Let's look at `prepare_z_all` and its implementation together. The instruction produces one C4 block in logical $|00\\rangle$. It has no input block and one output block, which encodes two logical qubits. The `stabilize` action requires both logical Z operators, `Z_0` and `Z_1`, to have eigenvalue +1." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "mnemonic: prepare_z_all\n", + "description: Prepare all 2 logical qubit(s) in |0⟩.\n", + "out: [C4]\n", + "action:\n", + "- stabilize: [Z_0, Z_1]\n" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "instruction_set = protocol.layers[0].instruction_set\n", + "instruction_set.instructions[\"prepare_z_all\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The instruction specifies the contract but does _not_ supply the implementation. That is the gadget's responsibility. The corresponding `prepare_z_all` gadget is shown below.\n", + "It includes a circuit and parity checks. A _check_ is an equation that should evaluate to zero without faults. The equations include measurement bits and input/output encoding signs." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "circuit:\n", + " source: |\n", + " R 0 1 2 3\n", + " R 4\n", + " H 4\n", + " CX 4 0\n", + " CX 4 1\n", + " CX 4 2\n", + " CX 4 3\n", + " H 4\n", + " R 5\n", + " H 5\n", + " CZ 5 0\n", + " CZ 5 1\n", + " CZ 5 2\n", + " CZ 5 3\n", + " H 5\n", + " M 4 5\n", + " format: stim\n", + " out:\n", + " '0': qubit\n", + " '1': qubit\n", + " '2': qubit\n", + " '3': qubit\n", + "out:\n", + "- C4: [0, 1, 2, 3]\n", + "checks:\n", + "- ['circuit.readouts[1]']\n", + "- ['circuit.readouts[0]', 'out[0].stabilizers[0]']\n", + "- ['out[0].stabilizers[1]']\n" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gadgets = protocol.layers[0].gadgets\n", + "gadgets[\"prepare_z_all\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The circuit source is Stim (qodec supports any circuit representation with an associated parser). So we can use `stim` directly to look at the circuit." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "\n", + "\n", + "\n", + "q0\n", + "\n", + "q1\n", + "\n", + "q2\n", + "\n", + "q3\n", + "\n", + "q4\n", + "\n", + "q5\n", + "\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "H\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "H\n", + "\n", + "M\n", + "rec[0]\n", + "\n", + "M\n", + "rec[1]\n", + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import stim\n", + "stim.Circuit(gadgets[\"prepare_z_all\"].circuit.source).diagram(\"timeline-svg-html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Does this circuit prepare the state that the instruction claims it does? As with `CodeProfile`, we can use a `GadgetProfile` to analyze it. Its `objective` is the declared instruction; its `action` is the behavior computed from the circuit. Let's inspect the computed action and check that it matches the instruction." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "**Computed action**\n", + "\n", + "```text\n", + "stabilizers:\n", + " Z_0 = +1\n", + " Z_1 = +1\n", + "```\n", + "\n", + "**Meets objective:** ✓" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import Markdown, display\n", + "\n", + "\n", + "def display_gadget_action(profile: ec.GadgetProfile) -> None:\n", + " objective, action = profile.objective, profile.action\n", + " if objective is None:\n", + " result = \"**Comparison unavailable:** no declared operation.\"\n", + " elif action.is_equivalent_to(objective):\n", + " result = \"**Meets objective:** ✓\"\n", + " else:\n", + " result = f\"**Meets objective:** ✗\\n\\n{objective.why_not_equivalent_to(action)}\"\n", + " display(Markdown(f\"**Computed action**\\n\\n```text\\n{action}\\n```\\n\\n{result}\"))\n", + "\n", + "\n", + "preparation_profile = ec.GadgetProfile(gadgets[\"prepare_z_all\"])\n", + "display_gadget_action(preparation_profile)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Inspect `cx`\n", + "\n", + "Now let's look at `cx_all`. It takes two C4 blocks and applies CNOT between corresponding logical qubits. The `action` treats qubit indices as flat. The first C4 block corresponds to indices 0 and 1. The second C4 block corresponds to indices 2 and 3." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "mnemonic: cx_all\n", + "description: CNOT from the first block to the second, pairing logical qubits.\n", + "in: [C4, C4]\n", + "out: [C4, C4]\n", + "action:\n", + "- clifford:\n", + " X_0: X_0 X_2\n", + " X_1: X_1 X_3\n", + " Z_2: Z_0 Z_2\n", + " Z_3: Z_1 Z_3\n" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "instruction_set.instructions[\"cx_all\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, let's inspect the corresponding CNOT gadget. " + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "circuit:\n", + " source: |\n", + " CX 0 4\n", + " CX 1 5\n", + " CX 2 6\n", + " CX 3 7\n", + " format: stim\n", + " in:\n", + " '0': qubit\n", + " '1': qubit\n", + " '2': qubit\n", + " '3': qubit\n", + " '4': qubit\n", + " '5': qubit\n", + " '6': qubit\n", + " '7': qubit\n", + " out:\n", + " '0': qubit\n", + " '1': qubit\n", + " '2': qubit\n", + " '3': qubit\n", + " '4': qubit\n", + " '5': qubit\n", + " '6': qubit\n", + " '7': qubit\n", + "in:\n", + "- C4: [0, 1, 2, 3]\n", + "- C4: [4, 5, 6, 7]\n", + "out:\n", + "- C4: [0, 1, 2, 3]\n", + "- C4: [4, 5, 6, 7]\n", + "checks:\n", + "- ['out[0].stabilizers[0]', 'in[0].stabilizers[0]', 'in[1].stabilizers[0]']\n", + "- ['out[0].stabilizers[1]', 'in[0].stabilizers[1]']\n", + "- ['out[1].stabilizers[0]', 'in[1].stabilizers[0]']\n", + "- ['out[1].stabilizers[1]', 'in[0].stabilizers[1]', 'in[1].stabilizers[1]']\n" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "gadgets[\"cx_all\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The circuit is very simple: just transversal CNOTs across the two 4-qubit physical blocks. The `in/out` fields specify how to connect circuit labels to the logical blocks.\n", + "Note that, even though this gadget has no readouts, it _does_ include parity checks. The parity checks here relate the input stabilizers to the output stabilizers.\n", + "\n", + "Next, we can look at the action of the gadget and verify that it implements the intended instruction." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "**Computed action**\n", + "\n", + "```text\n", + "mapping:\n", + " X_0 → X_0 X_2\n", + " Z_0 → Z_0\n", + " X_1 → X_1 X_3\n", + " Z_1 → Z_1\n", + " X_2 → X_2\n", + " Z_2 → Z_0 Z_2\n", + " X_3 → X_3\n", + " Z_3 → Z_1 Z_3\n", + "```\n", + "\n", + "**Meets objective:** ✓" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "cnot_profile = ec.GadgetProfile(gadgets[\"cx_all\"])\n", + "display_gadget_action(cnot_profile)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Check the entire qodec for correctness\n", + "\n", + "We've inspected two instructions and their gadgets. Now we'd like to check the whole protocol. QDK provides `audit` to compare the circuits with their declared operations and equations. The audit should report no errors or warnings for the generated protocol. \n", + "\n", + "To see what the audit can tell us, we will deliberately remove some equations. The circuits will stay the same. Then we'll use QDK to derive the missing equations and check the protocol again." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "audit: ok\n" + ] + } + ], + "source": [ + "report = ec.audit(protocol)\n", + "print(report)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Remove the measurement readouts\n", + "\n", + "The `measure_x_all` gadget should report two logical measurement results. Its readout equations tell us which physical measurement bits and frame signs to combine for each result.\n", + "\n", + "Let's clear those equations. The circuit still measures the qubits, but the protocol no longer defines its logical results. The audit should report two `gadget/missing-observable` errors and show where the equations are missing." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[ERROR] gadget/missing-observable\n", + "layers[0].gadgets['measure_x_all'] (C4 -> stim)\n", + "readouts[0] has no equation for the required logical X_0 measurement\n", + " Verified readout equation: [\"in[0].x[0]\", \"circuit.readouts[0]\", \"circuit.readouts[1]\"]\n", + "\n", + "[ERROR] gadget/missing-observable\n", + "layers[0].gadgets['measure_x_all'] (C4 -> stim)\n", + "readouts[1] has no equation for the required logical X_1 measurement\n", + " Verified readout equation: [\"in[0].x[1]\", \"circuit.readouts[0]\", \"circuit.readouts[2]\"]\n", + "\n", + "audit: 2 error(s), 0 warning(s), 0 informational\n" + ] + } + ], + "source": [ + "gadgets[\"measure_x_all\"].readouts.clear()\n", + "print(ec.audit(protocol))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Remove the CNOT checks\n", + "\n", + "Now let's remove the four checks from `cx_all`. These relate the input and output stabilizer signs. The circuit still performs the logical CNOT, but we have removed the equations that specify its output stabilizer signs. The audit should add four `gadget/incomplete-output-frame` warnings to the two readout errors." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[ERROR] gadget/missing-observable\n", + "layers[0].gadgets['measure_x_all'] (C4 -> stim)\n", + "readouts[0] has no equation for the required logical X_0 measurement\n", + " Verified readout equation: [\"in[0].x[0]\", \"circuit.readouts[0]\", \"circuit.readouts[1]\"]\n", + "\n", + "[ERROR] gadget/missing-observable\n", + "layers[0].gadgets['measure_x_all'] (C4 -> stim)\n", + "readouts[1] has no equation for the required logical X_1 measurement\n", + " Verified readout equation: [\"in[0].x[1]\", \"circuit.readouts[0]\", \"circuit.readouts[2]\"]\n", + "\n", + "[WARNING] gadget/incomplete-output-frame\n", + "layers[0].gadgets['cx_all'] (C4 -> stim)\n", + "Declared relations do not determine out[0].stabilizers[0] (X_0 X_1 X_2 X_3)\n", + " Code: C4; circuit support: ['0', '1', '2', '3'].\n", + " Verified relation: [\"out[0].stabilizers[0]\", \"in[0].stabilizers[0]\", \"in[1].stabilizers[0]\"]\n", + "\n", + "[WARNING] gadget/incomplete-output-frame\n", + "layers[0].gadgets['cx_all'] (C4 -> stim)\n", + "Declared relations do not determine out[0].stabilizers[1] (Z_0 Z_1 Z_2 Z_3)\n", + " Code: C4; circuit support: ['0', '1', '2', '3'].\n", + " Verified relation: [\"out[0].stabilizers[1]\", \"in[0].stabilizers[1]\"]\n", + "\n", + "[WARNING] gadget/incomplete-output-frame\n", + "layers[0].gadgets['cx_all'] (C4 -> stim)\n", + "Declared relations do not determine out[1].stabilizers[0] (X_0 X_1 X_2 X_3)\n", + " Code: C4; circuit support: ['4', '5', '6', '7'].\n", + " Verified relation: [\"out[1].stabilizers[0]\", \"in[1].stabilizers[0]\"]\n", + "\n", + "[WARNING] gadget/incomplete-output-frame\n", + "layers[0].gadgets['cx_all'] (C4 -> stim)\n", + "Declared relations do not determine out[1].stabilizers[1] (Z_0 Z_1 Z_2 Z_3)\n", + " Code: C4; circuit support: ['4', '5', '6', '7'].\n", + " Verified relation: [\"out[1].stabilizers[1]\", \"in[0].stabilizers[1]\", \"in[1].stabilizers[1]\"]\n", + "\n", + "audit: 2 error(s), 4 warning(s), 0 informational\n" + ] + } + ], + "source": [ + "gadgets[\"cx_all\"].checks.clear()\n", + "print(ec.audit(protocol))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Replacing the missing gadget information can be tedious. For situations like these, QDK offers `filled`. This function accepts a partial qodec (or gadget) and attempts to fill it in based on the existing contents." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "audit: ok\n" + ] + } + ], + "source": [ + "protocol = ec.filled(protocol)\n", + "gadgets = protocol.layers[0].gadgets\n", + "print(ec.audit(protocol))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Check the gadgets for fault tolerance\n", + "\n", + "The qodec passes the audit. But the audit doesn't consider fault tolerance. What happens when a circuit has faults?\n", + "\n", + "We checked the code distance in section 1. Now we will use `GadgetProfile.distance()` to check the _gadget distance_: the fewest allowed circuit faults that change the logical action, stay within the output codespaces, and leave every check and flag zero.\n", + "\n", + "The default model is circuit noise: a Pauli error after a call, a flip of that call's recorded readout bits, or both.\n", + "\n", + "Let's compute the distance of each gadget. Each result includes a _witness_: a set of faults that achieves the reported distance." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "| Gadget | Distance | Witness |\n", + "| --- | ---: | --- |\n", + "| `cx_all` | 2 | X_4 after call 0; X_5 after call 1 |\n", + "| `measure_x_all` | 2 | X_0 after call 0; X_1 after call 1 |\n", + "| `measure_z_all` | 2 | flip call 0 readout 0; flip call 1 readout 0 |\n", + "| `prepare_x_all` | 1 | X_5 after call 18 |\n", + "| `prepare_z_all` | 1 | X_4 after call 7 |\n", + "| `syndrome` | 1 | X_4 after call 3 |" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def report_gadget_distances(protocol: qodec.Qodec) -> None:\n", + " distances = {\n", + " mnemonic: ec.GadgetProfile(gadget).distance()\n", + " for mnemonic, gadget in protocol.layers[0].gadgets.items()\n", + " }\n", + " rows = [\n", + " \"| Gadget | Distance | Witness |\",\n", + " \"| --- | ---: | --- |\",\n", + " ]\n", + " rows.extend(\n", + " f\"| `{mnemonic}` | {distance} | {distance.witness} |\"\n", + " for mnemonic, distance in sorted(distances.items())\n", + " )\n", + " display(Markdown(\"\\n\".join(rows)))\n", + "\n", + "\n", + "report_gadget_distances(protocol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We're using a distance-two code, so we might hope that our gadgets also achieve distance two. Some of them do, but the `prepare` and `syndrome` gadgets do not. We fix that in the next section." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Increase the gadget distances\n", + "\n", + "### Why the syndrome gadget has distance one\n", + "\n", + "Let's inspect the `syndrome` circuit and the distance-one witness.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
0: R [4]\n",
+       "1: H [4]\n",
+       "2: CX [4, 0]\n",
+       "3: CX [4, 1]\n",
+       "4: CX [4, 2]\n",
+       "5: CX [4, 3]\n",
+       "6: H [4]\n",
+       "7: R [5]\n",
+       "8: H [5]\n",
+       "9: CZ [5, 0]\n",
+       "10: CZ [5, 1]\n",
+       "11: CZ [5, 2]\n",
+       "12: CZ [5, 3]\n",
+       "13: H [5]\n",
+       "14: M [4]\n",
+       "15: M [5]
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import HTML, display\n", + "\n", + "circuit = gadgets[\"syndrome\"].circuit\n", + "diagram = str(stim.Circuit(circuit.source).diagram(\"timeline-svg-html\"))\n", + "call_text = \"\\n\".join(\n", + " f\"{index}: {call.mnemonic} {call.operands}\"\n", + " for index, call in enumerate(circuit.calls())\n", + ")\n", + "\n", + "display(HTML(\n", + " '
'\n", + " f'
{diagram}
'\n", + " f'
{call_text}
'\n", + " '
'\n", + "))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "Ancilla 4 measures the X stabilizer with CNOTs. Ancilla 5 then measures the Z stabilizer with controlled-Z gates. We will follow faults on these ancillas through the remaining gates and inspect which checks and output signs they change." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "X_4 after call 3 (CX [4, 1]) → ['out[0].z[0]']\n" + ] + } + ], + "source": [ + "idle_profile = ec.GadgetProfile(gadgets[\"syndrome\"])\n", + "calls = gadgets[\"syndrome\"].circuit.calls()\n", + "distance = idle_profile.distance()\n", + "location = distance.witness.product.locations[0]\n", + "call = calls[location.after_call]\n", + "effect = idle_profile.effects_of([distance.witness.product])[0]\n", + "print(f\"{distance.witness} ({call.mnemonic} {call.operands}) → {effect}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The offending fault occurs on the third call of the gadget, `CX 4 1`. It spreads through subsequent `CX` gates, ultimately flipping the output `z[0]` observable, but _not_ flipping the Z-stabilizer measurement. It is a logical `X_0` error.\n", + "\n", + "But that's not the only problematic fault. The distance has several witnesses, which we examine below." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "X_4 after call 3\t(CX [4, 1])\t→ ['out[0].z[0]']\n", + "X_2 X_4 after call 4\t(CX [4, 2])\t→ ['out[0].z[0]']\n", + "X_5 after call 10\t(CZ [5, 1])\t→ ['out[0].x[1]']\n", + "Z_2 X_5 after call 11\t(CZ [5, 2])\t→ ['out[0].x[1]']\n" + ] + } + ], + "source": [ + "faults = [witness.product for witness in distance.witnesses]\n", + "effects = idle_profile.effects_of(faults)\n", + "\n", + "for fault, effect in zip(faults, effects):\n", + " location = fault.locations[0]\n", + " call = calls[location.after_call]\n", + " print(f\"{fault}\\t({call.mnemonic} {call.operands})\\t→ {effect}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Improve the syndrome circuit\n", + "\n", + "All of the problematic faults are errors that occur on an ancilla qubit and then spread to multiple data qubits and go undetected. We need a circuit that avoids this phenomenon.\n", + "In this case, a circuit by [Reichardt](https://arxiv.org/pdf/1804.06995#page=4) does the trick. It interleaves the ancilla interactions so that any dangerous error spreads to the other ancilla's measurement result. It uses eight CNOTs and the same two ancillas, without adding a flag qubit.\n", + "\n", + "Let's replace our circuit with Reichardt's. Then we'll use `filled` to recompute the parity checks and evaluate the new gadget distance." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "\n", + "\n", + "\n", + "q0\n", + "\n", + "q1\n", + "\n", + "q2\n", + "\n", + "q3\n", + "\n", + "q4\n", + "\n", + "q5\n", + "\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "H\n", + "\n", + "M\n", + "rec[0]\n", + "\n", + "M\n", + "rec[1]\n", + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Improved syndrome distance: 2\n", + "audit: ok\n" + ] + } + ], + "source": [ + "reichardt_source = \"\"\"\n", + "R 4 5\n", + "H 4\n", + "CX 4 0\n", + "CX 2 5\n", + "CX 0 5\n", + "CX 1 5\n", + "CX 4 2\n", + "CX 4 3\n", + "CX 4 1\n", + "CX 3 5\n", + "H 4\n", + "M 4 5\n", + "\"\"\"\n", + "display(stim.Circuit(reichardt_source).diagram(\"timeline-svg-html\"))\n", + "\n", + "gadgets[\"syndrome\"].circuit.source = reichardt_source\n", + "gadgets[\"syndrome\"] = ec.filled(gadgets[\"syndrome\"])\n", + "\n", + "improved_syndrome_profile = ec.GadgetProfile(gadgets[\"syndrome\"])\n", + "print(\"Improved syndrome distance:\", improved_syndrome_profile.distance())\n", + "print(ec.audit(protocol))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Detect analogous hook faults\n", + "\n", + "The reordered circuit still lets an ancilla fault spread to two data qubits, but the other ancilla now detects it. The gadget distance of two is proof.\n", + "\n", + "But it's possible to see this improvement explicitly by looking at how previously dangerous ancilla faults impact the new circuit." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Z_5 after call 5 (CX [0, 5]) →\n", + " checks[0] = ['circuit.readouts[0]', 'in[0].stabilizers[0]']\n", + " checks[2] = ['circuit.readouts[0]', 'out[0].stabilizers[0]']\n", + " out[0].x[0]\n", + "\n", + "X_4 after call 7 (CX [4, 2]) →\n", + " checks[1] = ['circuit.readouts[1]', 'in[0].stabilizers[1]']\n", + " checks[3] = ['circuit.readouts[1]', 'out[0].stabilizers[1]']\n", + " out[0].z[1]\n", + "\n" + ] + } + ], + "source": [ + "improved_calls = gadgets[\"syndrome\"].circuit.calls()\n", + "faults = [\n", + " ec.FaultEvent.after(5, ec.Pauli(\"Z_5\")), \n", + " ec.FaultEvent.after(7, ec.Pauli(\"X_4\"))\n", + "]\n", + "effects = improved_syndrome_profile.effects_of(faults)\n", + "\n", + "for fault, effect in zip(faults, effects):\n", + " call = improved_calls[fault.locations[0].after_call]\n", + " print(f\"{fault} ({call.mnemonic} {call.operands}) →\")\n", + " for reference in effect.checks + effect.readouts:\n", + " equation = gadgets[\"syndrome\"].resolve(reference).value(tuple)\n", + " print(f\" {reference} = {[str(term) for term in equation]}\")\n", + " for reference in effect.frames:\n", + " print(f\" {reference}\")\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The faults still flip logical operators (`Z_5` flips `out[0].x[0]`, `X_4` flips `out[0].z[1]`). But now, those faults are detected by parity checks, thereby increasing the distance." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Improve both preparations with a flag\n", + "\n", + "The `syndrome` gadget now looks good. We also need to improve the `prepare` gadgets. Let's look at the $|00\\rangle$ circuit." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "\n", + "\n", + "\n", + "q0\n", + "\n", + "q1\n", + "\n", + "q2\n", + "\n", + "q3\n", + "\n", + "q4\n", + "\n", + "q5\n", + "\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "H\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "H\n", + "\n", + "M\n", + "rec[0]\n", + "\n", + "M\n", + "rec[1]\n", + "" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "stim.Circuit(gadgets[\"prepare_z_all\"].circuit.source).diagram(\"timeline-svg-html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This circuit is nearly identical to our original `syndrome` circuit, and so it's subject to the same problems: a fault on an ancilla spreads to an undetected logical error.\n", + "\n", + "We'll fix this by instead using a unitary encoding circuit and a _flag_. A flag is a special parity check that gets promoted to a readout. It can then be used to reject noisy states, a process known as pre-selection.\n", + "Below, we add a `reject` flag to each gadget and bind it to the measurement. The `GadgetProfile.distance` calculation accounts for the flag automatically.\n", + "\n", + "The $|++\\rangle$ preparation is identical to the $|00\\rangle$ preparation followed by transversal Hadamard gates.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "prepare_z_all distance: 2\n", + "prepare_x_all distance: 2\n", + "audit: ok\n" + ] + } + ], + "source": [ + "flagged_preparation_source = \"\"\"R 0 1 2 3 4\n", + "H 0\n", + "CX 0 4\n", + "CX 0 1\n", + "CX 0 2\n", + "CX 0 3\n", + "CX 0 4\n", + "\"\"\"\n", + "preparation_suffixes = {\n", + " \"prepare_z_all\": \"M 4\\n\",\n", + " \"prepare_x_all\": \"H 0 1 2 3\\nM 4\\n\",\n", + "}\n", + "for mnemonic, suffix in preparation_suffixes.items():\n", + " gadget = gadgets[mnemonic]\n", + " gadget.implements.flags = [\"reject\"]\n", + " gadget.circuit.source = flagged_preparation_source + suffix\n", + " gadget.readouts = [{\"reject\": [\"circuit.readouts[0]\"]}]\n", + " gadgets[mnemonic] = ec.filled(gadget)\n", + " print(mnemonic, \"distance:\", ec.GadgetProfile(gadgets[mnemonic]).distance())\n", + "print(ec.audit(protocol))" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "\n", + "\n", + "\n", + "q0\n", + "\n", + "q1\n", + "\n", + "q2\n", + "\n", + "q3\n", + "\n", + "q4\n", + "\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "R\n", + "\n", + "H\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "M\n", + "rec[0]\n", + "" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "stim.Circuit(gadgets[\"prepare_z_all\"].circuit.source).diagram(\"timeline-svg-html\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Inspect and save the improved qodec\n", + "\n", + "We've changed the syndrome and preparation circuits. Now let's check the whole qodec again. The audit should report no errors or warnings, and all six gadgets should have distance two." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "audit: ok\n" + ] + }, + { + "data": { + "text/markdown": [ + "| Gadget | Distance | Witness |\n", + "| --- | ---: | --- |\n", + "| `cx_all` | 2 | X_4 after call 0; X_5 after call 1 |\n", + "| `measure_x_all` | 2 | X_0 after call 0; X_1 after call 1 |\n", + "| `measure_z_all` | 2 | flip call 0 readout 0; flip call 1 readout 0 |\n", + "| `prepare_x_all` | 2 | X_1 after call 1; X_2 after call 2 |\n", + "| `prepare_z_all` | 2 | X_1 after call 1; X_2 after call 2 |\n", + "| `syndrome` | 2 | X_4 after call 0; Z_5 after call 5 |" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "protocol.description = (\n", + " \"C4 built with bare-css/v1, with self-checking syndrome extraction \"\n", + " \"and single-flag preparation circuits.\"\n", + ")\n", + "print(ec.audit(protocol))\n", + "report_gadget_distances(protocol);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Save the qodec\n", + "\n", + "We now have a qodec we'd like to keep. Use `save` to write its circuits and declarations into one YAML bundle." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Round trip: OK\n" + ] + } + ], + "source": [ + "from tempfile import TemporaryDirectory\n", + "\n", + "with TemporaryDirectory() as directory:\n", + " saved_path = protocol.save(directory, single_file=True)\n", + " assert qodec.Qodec.load(saved_path) == protocol\n", + "print(\"Round trip: OK\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Evaluate performance (TODO)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What we established\n", + "\n", + "We started with the C4 code and built a baseline qodec. We used the audit to find missing equations, then used gadget profiles to find faults and test better circuits. The resulting six gadgets pass the audit and each has distance two. We can also save and load the protocol without changing its declarations.\n", + "\n", + "These results apply to individual gadgets under our Pauli and readout-fault model. To assess a complete implementation, we would still need to account for hardware connectivity, timing, additional fault locations, correlated noise, and how the gadgets work together." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qdk-ec", + "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.12.14" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/notebooks/qdk_ec/qdk_sim_evolution.ipynb b/samples/notebooks/qdk_ec/qdk_sim_evolution.ipynb new file mode 100644 index 00000000000..90a1b2fb351 --- /dev/null +++ b/samples/notebooks/qdk_ec/qdk_sim_evolution.ipynb @@ -0,0 +1,116 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "import qdk\n", + "from qdk import qsharp\n", + "from qdk.simulation import run_qir\n", + "from collections import Counter\n", + "\n", + "qsharp.init(target_profile=qdk.TargetProfile.Adaptive)\n", + "qir = qsharp.compile(\"\"\"\n", + "{\n", + " use q = Qubit();\n", + " X(q);\n", + " MResetZ(q)\n", + "}\n", + "\"\"\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Circuit: X(q); MResetZ(q)\n", + "\n", + "# At some point we could only run noiseless simulations\n", + "Counter(run_qir(qir, shots=4_000, type=\"clifford\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Circuit: X(q); MResetZ(q)\n", + "\n", + "# Currently, we can configure noise\n", + "from qdk.simulation import NoiseConfig\n", + "\n", + "noise = NoiseConfig()\n", + "noise.x.x = 0.01\n", + "Counter(run_qir(qir, shots=4_000, type=\"clifford\", noise=noise))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "slide" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Now we can incorporate an error correction strategy.\n", + "import qodec as qc\n", + "\n", + "c4 = qc.Qodec.load(\"c4.qodec.yaml\")\n", + "Counter(run_qir(qir, shots=4_000, type=\"clifford\", noise=noise, qodec=c4))\n", + " # New!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/samples/notebooks/qdk_ec/qodec_from_code.ipynb b/samples/notebooks/qdk_ec/qodec_from_code.ipynb new file mode 100644 index 00000000000..e9c1ffadd53 --- /dev/null +++ b/samples/notebooks/qdk_ec/qodec_from_code.ipynb @@ -0,0 +1,332 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# From a code on paper to a complete qodec\n", + "\n", + "A quantum error-correcting code in a paper is a short list of Pauli operators. That is enough to reason about the code, but not enough to prepare, preserve, or read out an encoded state.\n", + "\n", + "`qdk.ec.build_qodec` bridges the gap. Given a `qodec.Code`, it builds a [qodec](https://github.com/microsoft/qodec) with a logical instruction set over the code's logical qubits, a physical instruction set, and a gadget for every instruction.\n", + "\n", + "This notebook takes the Steane code from its stabilizers to a complete qodec without writing a circuit by hand.\n", + "\n", + "## Installing\n", + "\n", + "```bash\n", + "pip install \"qdk[ec]\"\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. The code, as you would write it down\n", + "\n", + "The Steane [[7,1,3]] code: seven physical qubits, one logical qubit, distance 3.\n", + "Six stabilizer generators — three X-type, three Z-type — and one logical X / Z\n", + "pair. This is the whole input." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import qodec as qc\n", + "\n", + "steane = qc.Code(\n", + " \"steane\",\n", + " stabilizers=[\n", + " \"X_0 X_3 X_4 X_6\",\n", + " \"X_1 X_3 X_5 X_6\",\n", + " \"X_2 X_4 X_5 X_6\",\n", + " \"Z_0 Z_3 Z_4 Z_6\",\n", + " \"Z_1 Z_3 Z_5 Z_6\",\n", + " \"Z_2 Z_4 Z_5 Z_6\",\n", + " ],\n", + " x=[\"X_0 X_1 X_3\"],\n", + " z=[\"Z_1 Z_2 Z_5\"],\n", + ")\n", + "\n", + "print(f\"{len(list(steane.stabilizers))} stabilizers, {len(list(steane.x))} logical qubit(s)\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Build the qodec\n", + "\n", + "One call builds a runnable qodec from the code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import qdk.ec as ec\n", + "\n", + "protocol = ec.build_qodec(steane)\n", + "print(protocol.summary())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The builder creates two layers. The top layer's logical instruction set acts on the encoded qubit, not the seven physical ones. The bottom layer is the physical stim instruction set the gadgets lower into." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "logical = protocol.layers[0]\n", + "\n", + "for mnemonic, instruction in sorted(logical.isa.instructions.items()):\n", + " print(f\"{mnemonic:12s} {instruction.description}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. The circuits it wrote\n", + "\n", + "`syndrome` is a syndrome-extraction round: one ancilla per stabilizer, each prepared\n", + "in |+⟩, coupled to its stabilizer's support with a controlled Pauli, then\n", + "rotated back and measured.\n", + "\n", + "Note that `CX` is used where the stabilizer has an X, and `CZ` where it has a Z.\n", + "That one uniform construction handles CSS and non-CSS codes alike, and no data\n", + "qubit is ever touched by a basis-changing gate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(logical.gadgets[\"syndrome\"].circuit.source)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Readout is transversal. Logical Pauli operations are frame updates, so no Pauli circuits are built. The Steane code also supports H on every data qubit and CNOT between corresponding data qubits of two blocks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for mnemonic in (\"prepare_z_all\", \"measure_z_all\", \"measure_x_all\", \"h_all\", \"cx_all\"):\n", + " source = logical.gadgets[mnemonic].circuit.source.strip().replace(\"\\n\", \" ; \")\n", + " print(f\"{mnemonic:14s} {source[:78]}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. What makes it trustworthy\n", + "\n", + "The builder does not assume that its circuits are right. Each draft is completed through exact simulation, which discovers deterministic checks and logical readouts, then its realized channel is compared with the instruction's objective. With the default `strict=True`, any gadget that cannot be completed and verified raises instead of being silently omitted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "syndrome_gadget = logical.gadgets[\"syndrome\"]\n", + "\n", + "print(f\"{len(syndrome_gadget.checks)} checks discovered for `syndrome`; the first two:\")\n", + "for check in list(syndrome_gadget.checks)[:2]:\n", + " print(\" \", [str(atom) for atom in check])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Second, every finished gadget is checked against the instruction it claims to\n", + "implement: the action its circuit *realizes* must equal the action the\n", + "instruction *declares*. Anything that fails is dropped rather than shipped, so a\n", + "gadget that survives is one whose circuit provably does what it says.\n", + "\n", + "(Correctness is necessary but not sufficient — a circuit can implement the right\n", + "operation and still squander the code's protection. Section 5 measures that.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mismatches = {\n", + " mnemonic: profile.action.why_not_equivalent_to(profile.objective)\n", + " for mnemonic, gadget in logical.gadgets.items()\n", + " if (profile := ec.GadgetProfile(gadget)).action.why_not_equivalent_to(profile.objective)\n", + "}\n", + "print(\"gadgets whose circuit disagrees with its objective:\", mismatches or \"none\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The code's distance survives the build, and the full audit runs over the resulting qodec exactly as it would over a hand-authored one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "code = ec.CodeProfile(protocol.codes[\"steane\"])\n", + "distance = code.distance()\n", + "print(\"code distance:\", distance, \"| witness:\", distance.witness)\n", + "\n", + "report = ec.audit(protocol)\n", + "print(f\"audit: {len(report.errors)} error(s), {len(report.warnings)} warning(s)\")\n", + "for diagnostic in report.errors:\n", + " print(\" \", diagnostic.rule, \"|\", diagnostic.summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> [!NOTE]\n", + "> Audit rules evaluate the protocol beyond the builder's equation completion and channel-equivalence tests. Inspect every error and warning before using the protocol." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Serializing it" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The built qodec is ordinary data. It serializes, round-trips, and is the\n", + "artifact you hand to a compilation pipeline. Nothing about it is second-class\n", + "compared to a hand-written one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "from tempfile import TemporaryDirectory\n", + "\n", + "with TemporaryDirectory() as directory:\n", + " path = Path(directory) / \"steane.qodec.yaml\"\n", + " protocol.save(str(path), single_file=True)\n", + " restored = qc.Qodec.load(str(path))\n", + "\n", + "print(\"round-trips:\", sorted(restored.layers[0].gadgets) == sorted(logical.gadgets))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. When the build cannot finish\n", + "\n", + "Not every construction works for every code. `build_qodec` defaults to `strict=True`, so it raises with the failing instruction instead of returning a protocol that silently omits part of its instruction set. Take the five-qubit code as it is conventionally written, with a logical Z that carries X components." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "FIVE_QUBIT_STABILIZERS = [\n", + " \"Z_0 X_1 X_2 Z_3\",\n", + " \"Z_1 X_2 X_3 Z_4\",\n", + " \"Z_0 Z_2 X_3 X_4\",\n", + " \"X_0 Z_1 Z_3 X_4\",\n", + "]\n", + "\n", + "as_written = qc.Code(\n", + " \"five_qubit\",\n", + " stabilizers=FIVE_QUBIT_STABILIZERS,\n", + " x=[\"X_0 X_1 X_2 X_3 X_4\"],\n", + " z=[\"X_0 X_3 Z_4\"],\n", + ")\n", + "\n", + "try:\n", + " ec.build_qodec(as_written)\n", + "except ValueError as error:\n", + " print(\"build rejected the incomplete construction:\")\n", + " print(error)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Where to go next\n", + "\n", + "* Use `ec.build_qodec(code)` to build a complete two-layer protocol.\n", + "* Use `ec.filled(artifact)` to return a new gadget or qodec with derived checks and readout equations.\n", + "* Use `ec.GadgetProfile(gadget)` to inspect realized actions, checks, readouts, and fault effects.\n", + "* Use `ec.CodeProfile(code)` for code algebra and distance calculations.\n", + "* Use `ec.audit(protocol)` to evaluate audit rules across the whole protocol.\n", + "\n", + "### Further reading\n", + "\n", + "* Dennis, Kitaev, Landahl, and Preskill, *Topological quantum memory*, quant-ph/0110143, discusses hook errors.\n", + "* Chao and Reichardt, *Quantum error correction with only two extra qubits*, arXiv:1705.02329, describes the flag construction for distance-3 codes.\n", + "* Chamberland and Beverland, *Flag fault-tolerant error correction with arbitrary distance codes*, arXiv:1708.02246, generalizes the construction.\n", + "\n", + "See `qdk_ec_walkthrough.ipynb` for the authoring, profiling, and testing lifecycle on a hand-authored qodec." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/notebooks/qdk_ec/qodec_from_code__carbon.ipynb b/samples/notebooks/qdk_ec/qodec_from_code__carbon.ipynb new file mode 100644 index 00000000000..066816e60c8 --- /dev/null +++ b/samples/notebooks/qdk_ec/qodec_from_code__carbon.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "6c13973b", + "metadata": {}, + "outputs": [], + "source": [ + "import qodec as qc\n", + "import qdk.ec as ec\n", + "\n", + "carbon_code = qc.Code(\n", + " \"carbon\",\n", + " stabilizers=[\n", + " 'X_0 X_1 X_2 X_3',\n", + " 'X_4 X_5 X_6 X_7',\n", + " 'X_8 X_9 X_10 X_11',\n", + " 'Z_0 Z_1 Z_2 Z_3',\n", + " 'Z_4 Z_5 Z_6 Z_7',\n", + " 'Z_8 Z_9 Z_10 Z_11',\n", + " 'X_0 X_1 X_5 X_7 X_8 X_11',\n", + " 'X_0 X_3 X_4 X_5 X_9 X_11',\n", + " 'Z_0 Z_2 Z_6 Z_7 Z_8 Z_11',\n", + " 'Z_0 Z_3 Z_4 Z_6 Z_10 Z_11'\n", + " ],\n", + " x=['X_0 X_2 X_4 X_5', 'X_0 X_1 X_5 X_6'],\n", + " z=['Z_0 Z_1 Z_8 Z_11', 'Z_0 Z_2 Z_8 Z_9'],\n", + ")\n", + "\n", + "carbon = ec.build_qodec(carbon_code)\n", + "print(carbon.summary())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f75ad84", + "metadata": {}, + "outputs": [], + "source": [ + "import qdk.qsharp\n", + "from qdk.simulation import NoiseConfig, run_qir\n", + "from collections import Counter\n", + "\n", + "qdk.qsharp.init(target_profile=qdk.TargetProfile.Adaptive)\n", + "qir = qdk.qsharp.compile(\"{ use q = Qubit(); X(q); MResetZ(q) }\")\n", + "\n", + "noise = NoiseConfig()\n", + "noise.x.x = 0.01\n", + "\n", + "Counter(run_qir(qir, shots=4_000, type=\"clifford\", noise=noise, qodec=carbon))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples/notebooks/qdk_ec/qodec_from_code__steane.ipynb b/samples/notebooks/qdk_ec/qodec_from_code__steane.ipynb new file mode 100644 index 00000000000..16c58cdb879 --- /dev/null +++ b/samples/notebooks/qdk_ec/qodec_from_code__steane.ipynb @@ -0,0 +1,74 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "6c13973b", + "metadata": {}, + "outputs": [], + "source": [ + "import qdk.ec as ec\n", + "import qodec as qc\n", + "\n", + "steane_code = qc.Code(\n", + " \"steane\",\n", + " stabilizers=[\n", + " \"X_0 X_3 X_4 X_6\",\n", + " \"X_1 X_3 X_5 X_6\",\n", + " \"X_2 X_4 X_5 X_6\",\n", + " \"Z_0 Z_3 Z_4 Z_6\",\n", + " \"Z_1 Z_3 Z_5 Z_6\",\n", + " \"Z_2 Z_4 Z_5 Z_6\",\n", + " ],\n", + " x=[\"X_0 X_1 X_3\"],\n", + " z=[\"Z_1 Z_2 Z_5\"],\n", + ")\n", + "\n", + "steane = ec.build_qodec(steane_code)\n", + "print(steane.summary())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f75ad84", + "metadata": {}, + "outputs": [], + "source": [ + "import qdk\n", + "from qdk import qsharp\n", + "from qdk.simulation import NoiseConfig, run_qir\n", + "from collections import Counter\n", + "\n", + "qsharp.init(target_profile=qdk.TargetProfile.Adaptive)\n", + "qir = qsharp.compile(\"{ use q = Qubit(); X(q); MResetZ(q) }\")\n", + "\n", + "noise = NoiseConfig()\n", + "noise.x.x = 0.01\n", + "\n", + "Counter(run_qir(qir, shots=4_000, type=\"clifford\", noise=noise, qodec=steane))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/source/qdk_package/README.md b/source/qdk_package/README.md index c00bd240336..6444cbd1e4c 100644 --- a/source/qdk_package/README.md +++ b/source/qdk_package/README.md @@ -109,6 +109,503 @@ canonical format's guarantees, and the visitor's context protocol. ## Public API Surface +### Error Correction Preview + +`qdk.ec` analyzes codes and gadgets and can derive checks and readout equations. +It requires Python 3.11 or newer and `qodec>=0.2.0.dev0,<0.3`. Install the local +qodec Python bindings first while that version is unpublished, then install +`qdk[ec]`. + +On Linux aarch64 and Windows ARM64, `qdk[ec]` installs `qodec` without its +`parsers` extra because `stim` publishes no wheels for those platforms. Features +that parse `format="stim"` circuits, including `ec.build_qodec`, raise +`ValueError` there unless you build and install `stim` from source. + +The default audit reports no errors or warnings for a qodec returned by +`ec.build_qodec(code)`. Both `strategy="flagged-css/v1"` and `strategy="bare-css/v1"` +include incoming-frame corrections in logical readouts and stabilizer transport +relations for supported transversal gates. `strict=False` allows unsupported +instructions to be omitted and recorded in the build metadata; it does not +allow inconsistent retained gadgets. A failed final audit raises `ValueError`. + +Both strategies attempt `prepare_z_all`, `prepare_x_all`, `syndrome`, +`measure_z_all`, `measure_x_all`, `h_all`, and `cx_all`. The `_all` suffix means +all logical qubits in a block; `cx_all` pairs corresponding logical qubits of +two blocks. `syndrome` measures the stabilizers while preserving the logical +state. The names describe logical actions, not their circuit implementations. + +Audit cleanliness establishes noiseless consistency, not fault tolerance. +The bare strategy intentionally uses no flag qubits and can lose code distance. +Evaluate gadget distance separately before choosing an implementation. +`ec.filled(artifact)` returns a new gadget or qodec with derived checks and +readout equations, leaving the input unchanged. It retains verified direct +readout and check equations, including the builder's boundary-frame relations. + +Use `qodec.gadgets.Circuit(instruction_set, source, format=...)` for circuits. +Parsed invocations are `circuit.calls`; each call has positional `operands` +and named classical `arguments`. The instruction definitions are in +`circuit.instruction_set.instructions`. + +Gadget readouts are typed objects with `name`, `position`, `is_flag`, and +`equation`. Constructors and setters accept these values directly, preserving +names and equations while recomputing positions and roles. Parity sequences +and named mappings are also accepted. Checks, readouts, and readout equations +are immutable tuple snapshots; assign new collections to change the gadget. +`str(readout)` gives the authored form, and `repr(readout)` includes its equation. +Bundle fixtures use schema version 7; +`Qodec.load` takes an explicit manifest or bundle file path, not a directory. + +`Diagnostic` has an optional keyword-only field, `source_location`. It contains +the actual source file and 1-based line when qodec retained a reliable location. +Reports print `filename:line` on the line after the severity and rule, before +the existing layer and artifact context. Printed paths under the current user's +home directory use `~/`; other paths remain absolute. The stored +`SourceLocation.path` remains absolute in either case. +Equation failures point to the equation; other findings fall back to the +containing artifact. Source metadata does not affect diagnostic equality. +Constructed or modified models may have no source locations. `qdk.ec` has +12 top-level exports. + +Use `ec.CodeProfile(code)` to analyze a `qodec.Code`, just as +`ec.GadgetProfile(gadget)` analyzes a gadget. Both profiles snapshot their input +at construction; later edits to the original do not change the profile. +Code names, descriptions, and persistence remain with qodec. + +`CodeProfile` has 17 named public members: + +- `stabilizers`: a tuple of independent Pauli copies in declaration order. + Use `paulimer.PauliGroup(profile.stabilizers)` when a group is needed. +- `x` and `z`: read-only tuples of physical logical-X and logical-Z + representatives, indexed by logical qubit in the order of `qodec.Code.x` + and `qodec.Code.z`. +- `gauge_x` and `gauge_z`: read-only tuples of physical gauge-X and gauge-Z + generators, with matching indexes identifying gauge pairs. The derived gauge + basis is fixed within the snapshot but is not canonical. +- `support`, `length`, and `logical_qubit_count`: physical labels, their count, + and the number of logical X/Z pairs. `length` is not the largest physical + label plus one. +- `syndrome_of(error)`: zero-based positions in `stabilizers` that anticommute + with the physical error. +- `logical_effect_of(error, *, including_phase=True)`: a Pauli on zero-based + logical-qubit indexes. By default, a nonzero syndrome or a gauge component + raises `ValueError` rather than dropping phase. Use `including_phase=False` + for the phase-free logical-basis component of any supported physical error. +- `is_logical(error)`: true only for zero syndrome and a nonidentity logical + effect. Identity, stabilizers, gauge-only changes, and detectable errors are + false, regardless of global phase. The name describes a nontrivial logical + error, not every operator that preserves the code space. +- `representative_of(pauli)`: expand a logical Pauli to physical labels, + preserving phase. +- `encoding_clifford(*, supported_by=None)`: compute an encoding with every + physical support label appearing once in the requested order. +- `distance` and `distance_bounds`: exact and bounded distance searches. +- `is_equivalent_to` and `why_not_equivalent_to`: the same comparison policy + with `including_signs=True` and `strict_basis=True` by default. Set + `strict_basis=False` to compare logical groups instead of ordered bases. + The explanation is empty exactly when the predicate is true. + +All five operator collections return independent Pauli copies. X and Z name +the logical or gauge role, not the physical factors: `profile.x[0]` can contain +physical Y or Z factors. Logical and gauge indexes start at zero; neither is a +physical qubit label. + +All physical-error entry points reject labels outside `support`. Logical +Pauli inputs reject indexes outside `range(logical_qubit_count)`. Profiles +are unhashable; `==` compares the ordered operator snapshot rather than +algebraic equivalence. Code construction and relocation remain with qodec +and the internal analysis algorithms. + +`CodeProfile.distance()` and `distance_bounds()` default to single-qubit +`"XYZ"` errors, each with unit cost. Supplying `errors` restricts those Pauli +kinds or replaces them with an explicit sequence of allowed Pauli errors, +including correlated errors. Both methods return `Distance[Pauli]` rather than +tuples. `result.witness.product` is the combined Pauli; `result.witness.factors` +is the tuple of selected unit-cost errors. + +Both searches accept `logical_observable=Pauli("Z_0")` to restrict failure to +flipping logical Z on logical qubit zero; logical X and Y errors qualify. +The observable must be nonidentity and Hermitian. `None` permits any logical +failure. This is an observable-flip constraint, not an exact error-class query. + +The former `coset_representative` argument is replaced, not just renamed. +To translate a physical representative from that parity query, obtain its +phase-free logical effect, then exchange X and Z on each logical qubit +(leaving Y unchanged) to form `logical_observable`. For two unencoded qubits, +the old `coset_representative=Pauli("X_1")` becomes +`logical_observable=Pauli("Z_1")`. + +`is_logical` replaces `is_non_trivial_logical_error`; the three other overlapping +error predicates are removed. `logical_effect_of` replaces `logical_action_of`; +`including_phase=False` replaces `unsigned_logical_action_of`. The existing +`logical_effect_of` name follows the walkthrough's error queries rather than +adding another spelling of “action.” `including_phase` follows `Pauli.phase`; +the positive option avoids the ambiguous “unsigned” method name. Public group +views and anti-stabilizer accessors are removed; internal encoding machinery +is unchanged. `length` retains the existing code-length terminology rather +than adding a synonymous `physical_qubit_count`. + +The public `logical_basis` and `gauge_basis` properties are replaced, not +retained as aliases. `x` and `z` borrow qodec's names rather than introducing +`logical_x` and `logical_z`. `gauge_x` and `gauge_z` extend those axis names with +the existing gauge prefix, rather than reversing it to `x_gauge` and `z_gauge`. +The interleaved bases remain internal to the analysis algorithms. + +`Distance` has read-only `lower_bound`, `upper_bound`, `is_exact`, `value`, +`witness`, and `witnesses` properties. `None` means positive infinity in both +bounds. `(3, None)` means at least three with no finite upper bound established; +`(None, None)` proves no allowed logical failure exists. `value` requires equal +bounds and raises `ValueError` otherwise. `witness` returns the retained +`Distance.Witness` without searching, or raises `LookupError` if none is available. + +```python +distance = ec.CodeProfile(code).distance() +print(distance) +if distance.upper_bound is not None: + error = distance.witness.product + factors = distance.witness.factors +``` + +Strings contain only the value or interval: `2`, `[2, 4]`, `[3, ∞]`, or `∞`. +Comparisons against integers and other distances use the certified bounds. +For `[3, 5]`, `result > 2` is true and `result == 2` is false, but `result > 4` +raises `ValueError` because it is unresolved. Comparisons never search. +`bool(result)` raises `TypeError`; use an explicit comparison or `is_exact`. +String formatting supports alignment, while numeric formatting uses `value`. +Distance equality compares numerical values, not witness choices; results are +unhashable. Witness equality and hashing use the ordered factors, not the product. + +Each access to `witnesses` returns a fresh lazy iterator. Its first item is the +retained `witness`; further items enumerate distinct selections of original +allowed-factor positions at the same finite upper-bound cost. Different selections +may have the same product. Enumeration uses the snapshotted binary problem, +does not rerun circuit simulation or alter the bounds, and may be combinatorially +expensive. Exhaustion means all selections at that cost were enumerated; +interruptions and errors propagate. No finite upper bound gives an empty iterator. + +Neither type has a public constructor or supports implicit iteration or tuple +unpacking. Use `len(witness.factors)` for cost, never the product's Pauli or fault +weight. `Distance` is generic without a restriction on factor types; the profiles +produce Pauli and FaultEvent factors. + +`GadgetProfile.distance()` and `distance_bounds()` apply the same search to +circuit faults: + +```python +profile = ec.GadgetProfile(gadget) +distance = profile.distance() +bounds = profile.distance_bounds() + +faults = [ec.FaultEvent.after(0, ec.Pauli({0: "X", 1: "X"}))] +distance = profile.distance(faults=faults) +if distance.upper_bound is not None: + (combined_effect,) = profile.effects_of([distance.witness.product]) + +measurement_call = next( + index for index, call in enumerate(gadget.circuit.calls) + if any(isinstance(action, qc.actions.Observe) + for action in gadget.circuit.instruction_set.instructions[call.mnemonic].action) +) +readout_fault = ec.FaultEvent.after(measurement_call, readout_flips=0) +effect, = profile.effects_of([readout_fault]) +``` + +The default includes post-call Pauli errors and flips of the readout bits produced +by that call, including every combination except the identity event. A call with +`n` qubits and `r` readouts contributes `4**n * 2**r - 1` events. Calls without +readouts retain 3 faults for one qubit and 15 for two. Instruction support is +expanded from block operands. This is a set of possible faults, not a probability +distribution; every event at one call costs one, including correlated quantum +and readout errors. + +A readout flip changes the reported bit without changing the surviving quantum +state. For a non-destructive Pauli measurement it is equivalent to applying an +anticommuting Pauli before and after measurement. For destructive measurement, +the trailing Pauli has no surviving quantum output to affect. Use the same +constructor for quantum, readout, and correlated errors: + +```python +ec.FaultEvent.after(7, ec.Pauli("X_0")) +ec.FaultEvent.after(7, readout_flips=0) +ec.FaultEvent.after(7, ec.Pauli("X_0"), readout_flips=[0, 2]) +``` + +The first argument always indexes `Circuit.calls` from zero. `readout_flips` +accepts one integer or a sequence of indexes into that call's own readouts; +`0` and `[0]` mean the same thing. These are not absolute `Circuit.readouts` +indexes or gadget logical-readout positions. Booleans are rejected. Call and +readout bounds are checked when the event is applied to a circuit, including +readout faults on a call that has no readouts. + +Pass `faults=...` to replace the default set; `faults=[]` means no allowed faults. +An explicit event may span several call positions and still counts as one allowed +factor. The reported distance counts witness factors, not `FaultEvent.weight`, +which sums Pauli support weights and the number of flipped readout bits. +`FaultEvent` is immutable; its `repr` shows equivalent `after(...)` +expressions with call-local indexes. Witness products combine Pauli errors and +XOR readout flips at each call for replay with `effects_of`. `FaultEvent()` is +the identity; the mapping constructor remains available for post-call Pauli +errors. + +Inspect an event through `event.locations`, a tuple of `FaultEvent.Location` +values stored in increasing call-index order. Each location provides +`after_call`, `error`, and `readout_flips`. `after_call` is the zero-based index +in `Circuit.calls()` after which the fault is applied. The Pauli acts on circuit +qubits, and readout flips use that call's local indexes. Each affected call +appears once, with its combined +changes; canceled changes are omitted. The identity event has no locations. +Products merge the ordered locations without sorting again. + +```python +calls = gadget.circuit.calls() +for location in fault.locations: + call = calls[location.after_call] + print(call, location.error, location.readout_flips) +``` + +Locations are immutable values returned by events, not separately constructed +faults. Equality and hashing compare their fields, not circuit ownership. +`error` returns a defensive Pauli copy, and `readout_flips` is a `frozenset`; +readout-only faults have an identity Pauli. Event construction also copies +input Paulis, so later mutations do not change the event or its hash. + +`FaultEffect` is an immutable set of changed gadget quantities, expressed as +qodec references: + +```python +effect = ec.FaultEffect(["checks[1]", "checks[3]", "out[0].z[1]"]) +assert "checks[1]" in effect +assert "readouts[0]" not in effect +assert effect ^ effect == ec.FaultEffect() +print(effect) +``` + +This prints `["checks[1]", "checks[3]", "out[0].z[1]"]`. Checks and readouts use +the gadget's declaration indices, including flag readouts. Output references +identify logical X/Z signs or stabilizer signs in each output encoding, after +applying frame corrections. A flipped Z sign corresponds to an X error, not an +X-sign change. These are evaluated changes, not mutations to the model fields +or to `Gadget.frames` equations. + +Construction expands final selectors and deduplicates canonical targets; +iteration yields typed `qodec.Reference` values in deterministic numeric order. +qodec owns their normalized equality and hashing. Effects store those references +directly; only the distance adapter assigns solver indices. +Membership requires a single selected target. The effect is hashable; XOR takes +the symmetric difference for effects on the same analyzed snapshot. Empty means +no recorded change, not no fault. Bounds are checked by analysis or model +resolution, not by the standalone set constructor. + +The read-only `effect.checks`, `effect.readouts`, and `effect.frames` properties +return `tuple[qodec.Reference, ...]` in the effect's iteration order. `frames` +includes output X/Z and stabilizer signs. For example, `bool(effect.checks)` asks +whether any check flipped. These are ordinary tuples: use Reference values for +membership, not strings. XOR and string-aware membership belong to `FaultEffect`. +The tuples are computed on access, not stored as additional state. + +For bare circuits, readouts index the full record and checks are discovered. +Output encodings follow increasing circuit-layout slot order, excluding slots +prepared by the circuit but including unused slots. Each encoding represents +one qubit, so its logical index is zero. Effects describe the profile's snapshot, +not later edits to its source. + +`FaultEvent` has four named members: `after`, `weight`, `locations`, and the nested +`Location` type. `Location` has three properties: `after_call`, `error`, and +`readout_flips`. `Location` names a self-contained change after one call; +`Change` alone would not identify its position in the circuit. There is no new +top-level export. +`after` keeps the existing call-location convention, and the `readout_flips` +keyword describes recorded-bit changes. A separate `flip_readout` +method would duplicate this constructor; a boolean shortcut would need an +extra rule for calls with multiple readouts. + +Detection means a nonzero **declared check** syndrome. The search also requires +the combined fault to commute with every stabilizer of every output encoding. +This ensures that its residual preserves the output codespace; a lone detectable +data error must not count as a logical error merely because a logical probe flips. +Individual fault factors need not preserve the codespace: their output syndromes +may cancel in combination. Syndromes on different output blocks cannot cancel +each other. This is a constraint on what constitutes a logical error, not a new +declared detector: `out[k].stabilizers[i]` targets remain distinct from +`checks[j]` targets in a `FaultEffect`. + +Among combinations satisfying both constraints, failure means changing the +**realized logical action**. Indicators come from its prepared-state stabilizers, +preserved logical mappings, and logical measurement signs. A logical Z fault on +a prepared logical zero is harmless; a logical X fault changes the prepared state. +Output errors and measurement-dependent signs are evaluated together, so their +changes can cancel. A nonidentity output Pauli alone does not establish failure. +No logical measurement is required; readout-free gadgets use their output +encodings. With no quantum outputs the codespace constraint is empty, so destructive +measurements are assessed through their logical readouts. Flag readouts are not +automatically detectors or logical failures. The effect evaluator +uses complete check/readout equations, including output signs and uniquely defined +readout dependencies; circuit-internal faults do not change incoming frame signs. +Distance computes check, output-code, and action changes in one fault-propagation +pass. Independent readout equations are evaluated directly; dependent equations +are reduced once with all fault columns. Physical action probes are cached on +the profile and shared by both distance methods. +A bare circuit uses its discovered checks and the action on its identity-encoded +boundary: the qubits it does not prepare. +No decoder or additional output recovery is assumed. Full noiseless validation remains an +explicit `ec.audit(protocol)` call, not an implicit part of every distance search. +Distance still rejects missing information that would make the result partial, +including declared but unbound logical readouts, and requires an action that can +be interpreted against the boundary codes. These guards are calculation +preconditions, not a validity certificate. + +Both methods accept keyword-only `upper_bound` and `solver` like the code methods. +Both accept `solver="highs"`, `"enumeration"`, or `"mwpf"`. HiGHS is the default +for both `distance()` and `distance_bounds()` and is included in `qdk[ec]`: + +```bash +pip install 'qdk[ec]' +``` + +```python +distance = profile.distance() +bounds = profile.distance_bounds() +``` + +MWPF remains available with `solver="mwpf"` after a separate `pip install mwpf`. +It is not included in any QDK extra. + +The same selection works on `CodeProfile`. No solver classes or interfaces +are exported. Enumeration search enumerates subsets; HiGHS minimizes fault count +subject to the binary check and logical constraints. Enumeration and HiGHS use +`upper_bound` as an inclusive search cutoff; MWPF ignores it. The proven +distance-1/2 shortcuts can answer without invoking a backend. + +Internally all backends return bounds and a witness. `distance()` returns only +when the optimum is proved; it raises `RuntimeError` if a cutoff or solver limit +leaves a gap. `distance_bounds()` can return that gap. Public results translate +internal sentinels into `None` bounds; no witness is fabricated for infinity. +HiGHS limits with no feasible witness, backend failures, invalid witnesses, or +missing bound certificates raise rather than claiming a result. Only logical +searches proved impossible are skipped. This applies to both code and gadget +distance. The solver algorithms are internal and may change without a public +interface change. +The default fault set itself grows exponentially with instruction support. +Invalid call indices or parity references, unbound logical readouts, and ambiguous +readout dependencies raise rather than silently omitting effects. Conditional or +selected circuits and circuit instruction flags are not supported. + +`GadgetProfile` has 10 named members. Both distance methods return +`Distance[FaultEvent]`. Names follow `CodeProfile.distance` and +`distance_bounds`; separate `gadget_distance_*` free functions would duplicate +the profile's ownership. `faults` follows `effects_of(faults)`, rather than `errors`, +because the inputs include circuit locations. The existing `fault_effects` property +uses a compact X/Z and readout-flip propagation basis, not the full unit-cost +circuit fault set. + +`ec.audit` evaluates audit rules for code algebra, complete Clifford maps +(including implicit identities), gadget actions, and check, flag, and readout +equations. It also owns protocol completeness, code-list shapes and capacities, +reference bounds, parameter uses, and circuit-call validity. These declaration findings use +`qodec/invalid-structure`; qodec itself enforces only preservation preconditions +such as unambiguous resolution and bindings that can survive a round trip. +This replaces `gadget/reference-out-of-bounds` and +`gadget/missing-source-instruction`; update any rule filters using those IDs. +Use `gadget/unsupported-action-step` for unsupported instruction steps. It +replaces `gadget/unsupported-action-atom`; "step" follows the action model and +avoids confusion with physical atoms. The registry has 17 rule IDs. +Invalid prerequisites block dependent gadget analyses, including shared +definitions, while unrelated gadgets remain analyzable. Direct analysis calls +still check their mathematical preconditions. + +The returned `Report` contains errors, warnings, and informational notes as +`Diagnostic` objects. An empty report prints `audit: ok`; otherwise, the report +prints errors and warnings followed by counts for all three severities. +`report.ok` means there are no errors; warnings do not make it false. Reports +do not record successful rule evaluations, so an empty report is not a record +that every audit rule ran and passed. + +Repeated circuit labels within an encoding, or shared by two encodings on the +same boundary, are `qodec/invalid-structure` errors. The message identifies both +support positions. Input and output boundaries are checked separately; reusing +a label across them is normal. An overlap blocks that gadget's dependent analyses +without suppressing unrelated gadgets. qodec loading and saving are unchanged. + +Three informational rules describe declaration redundancy: + +- `gadget/vacuous-check`: an explicit check is empty or all its reference terms + cancel. It is valid, but cannot detect a fault. An omitted checks list has no + entry to report. +- `gadget/redundant-check`: a nonvacuous check is the exact XOR of earlier + independent checks. The finding lists those check positions. It compares formal + reference equations, not noiseless values, and does not substitute readouts. + Distinct measurements that agree noiselessly can still detect faults differently. +- `code/redundant-stabilizer`: a valid code has a generator that is the product of + earlier independent generators, or is +I. The finding shows the dependency and + the rank of the full stabilizer list. Inconsistent signs remain algebra errors. + +These informational notes preserve authored declarations and positions, remain +INFO under `promote_warnings=True`, and can be disabled by rule ID. They add no +top-level Python exports. The names use the existing check and stabilizer terms: +`duplicate-*` would miss combinations of earlier declarations, while `invalid-*` +would wrongly label harmless redundancy. Audit rules that depend on a fault +model remain deferred until an explicit fault set and detection requirement +are supplied. + +Algebraically incorrect drafts can be constructed, loaded, and saved by +qodec, as can unequal code lists, incomplete protocols, and malformed circuit +text. Parsed accessors can fail on a loadable draft. Entirely omitted readout +lists are allowed for later derivation, but audit reports missing observable +and flag equations as errors. Each error identifies the missing readout, whether +the list is empty or partially supplied. Missing observable errors include a +verified readout equation when the analysis can derive one. Extra readouts are +structural errors. An omitted flag equation is undefined; an explicit `[]` +equation declares zero. The audit does not invent flag equations: noiseless +consistency alone does not determine which faults a flag should detect. + +Parity verification assumes valid noiseless input +codewords with arbitrary incoming Pauli frames. A measurement readout that +omits a required logical-frame correction is an error even if it works for a +zero-frame input. The authored C4 example currently has such omissions; the +audit reports them without changing the protocol. + +Readout messages distinguish an incorrect equation from a result the circuit +does not provide. A recoverable mismatch shows only the declared and verified +equations. Otherwise, a short explanation identifies missing information, +a required constant inversion, or contradictory/undetermined definitions. +Readout messages do not include counterexamples. Check and flag failures retain +term values when needed to demonstrate firing without a fault, and label the +required noiseless value as zero. + +Readout references are solved as binary linear equations, including cycles +with unique consistent solutions. A group of contradictory equations is reported +once, with verified observable equations where available. Readouts that depend +on the conflict are reported as unverified; independent readouts remain +analyzable. Undetermined observables also include verified equations when +available. Output stabilizer signs must be determined +by valid declared constraints, not merely mentioned in them. Empty equations +are zero but provide no constraint. Unsupported parity analysis produces +warnings rather than claiming a result. These audit rules do not establish fault +tolerance or verify a particular fault model. + +`gadget/missing-check` is informational. It reports an independent noiseless +measurement check that is available but not implied by valid declared checks, +readout definitions, and verified zero-valued flags. Candidates combine circuit +bits and incoming stabilizer signs, under the same arbitrary-frame contract. +Equivalent XOR bases are accepted; duplicate and invalid checks do not hide +omissions. Each finding includes a copyable equation. Unsupported discovery +produces an informational notice, never an invented equation. Authors may +intentionally leave checks for derivation, so these findings are not promoted +by `promote_warnings=True` and do not claim inadequate fault tolerance. + +Input and output frames are not symmetric requirements. Input signs are supplied +boundary information; output stabilizer signs must be determined from that +information and the circuit results. A gadget need not remeasure or reconstruct +every input sign. There is therefore no `gadget/incomplete-input-frame` rule. +Missing dependence on an input sign is caught by check/readout verification; +an omitted available syndrome relation is a missing check. Pure input-to-output +transport remains the responsibility of `gadget/incomplete-output-frame`. + +The registry has 17 built-in rule IDs. This adds no public Python exports: +`missing-check` follows `missing-observable` and `missing-flag`; `no-checks` +would miss incomplete nonempty check lists. + +### Submodules + Submodules: - `qdk.qsharp` – Q# interpreter functions: `init`, `eval`, `run`, `compile`, `circuit`, `estimate`, `dump_machine`, `dump_circuit`, `dump_operation`, and related types. diff --git a/source/qdk_package/check_api_surface.py b/source/qdk_package/check_api_surface.py index 303928293c0..a544f3ae495 100644 --- a/source/qdk_package/check_api_surface.py +++ b/source/qdk_package/check_api_surface.py @@ -13,11 +13,15 @@ reference directly, so it does not constitute actionable leakage. Types that are defined in a private module but re-exported through a -public module's ``__all__`` are **not** flagged — they are considered -public. +public module's ``__all__`` or public attributes of an exported class +are **not** flagged — they are considered public. Exit code 0 - no violations found. Exit code 1 - one or more violations found (details printed to stderr). +Exit code 2 - scan incomplete because the qdk package or a declared export is unavailable. + +Public submodules that fail to import (for example, because an optional extra +is not installed) are skipped with a warning. Usage:: @@ -91,7 +95,7 @@ def _type_fqn(tp: type) -> str: return qual -def _extract_leaf_types(annotation) -> list: +def _extract_leaf_types(annotation: typing.Any) -> list: """Recursively unwrap generic aliases and return leaf types.""" origin = getattr(annotation, "__origin__", None) args = getattr(annotation, "__args__", None) @@ -147,6 +151,10 @@ def __str__(self) -> str: ) +class ScanIncomplete(RuntimeError): + """A required public module or export could not be inspected.""" + + def _build_public_types( modules: list[tuple[str, types.ModuleType]], ) -> tuple[set[int], set[str]]: @@ -155,30 +163,54 @@ def _build_public_types( Returns: A tuple of (public_type_ids, public_type_names) where: - public_type_ids is a set of ``id()`` values for type objects - found in any public module's ``__all__``. + reachable through a public module's ``__all__`` or public + attributes of its exported classes. - public_type_names is a set of unqualified names (e.g. "Config") for resolving forward-reference strings. """ public_type_ids: set[int] = set() public_type_names: set[str] = set() + pending_types: list[tuple[str, type]] = [] for mod_name, mod in modules: all_symbols = getattr(mod, "__all__", None) if all_symbols is None: continue for sym_name in all_symbols: - obj = getattr(mod, sym_name, None) + obj = _lazy_getattr(mod, mod_name, sym_name) if obj is None: continue if isinstance(obj, type): - public_type_ids.add(id(obj)) - public_type_names.add(sym_name) + pending_types.append((sym_name, obj)) + + while pending_types: + type_name, public_type = pending_types.pop() + public_type_names.add(type_name) + if id(public_type) in public_type_ids: + continue + public_type_ids.add(id(public_type)) + for attr_name in dir(public_type): + if attr_name.startswith("_"): + continue + attr = _lazy_getattr(public_type, _type_fqn(public_type), attr_name) + if isinstance(attr, type): + pending_types.append((attr_name, attr)) return public_type_ids, public_type_names +def _lazy_getattr(mod: types.ModuleType | type, mod_name: str, sym_name: str): + """Resolve a declared export; missing dependencies make the scan incomplete.""" + try: + return getattr(mod, sym_name) + except (ImportError, AttributeError) as error: + raise ScanIncomplete( + f"could not resolve {mod_name}.{sym_name}: {error}" + ) from error + + def _check_annotation( - annotation, + annotation: typing.Any, module_name: str, symbol_name: str, context: str, @@ -223,7 +255,7 @@ def _check_annotation( def _check_callable( - obj, + obj: typing.Any, module_name: str, symbol_name: str, violations: list[Violation], @@ -272,10 +304,7 @@ def _check_class( for attr_name in dir(cls): if attr_name.startswith("_") and not attr_name.startswith("__"): continue # skip private methods - try: - attr = getattr(cls, attr_name) - except Exception: - continue + attr = _lazy_getattr(cls, _type_fqn(cls), attr_name) if not callable(attr): continue if not (inspect.isfunction(attr) or inspect.ismethod(attr)): @@ -341,7 +370,10 @@ def _import_root_package() -> types.ModuleType: def _iter_qdk_modules() -> list[tuple[str, types.ModuleType]]: """Import and yield all public qdk submodules.""" - root = _import_root_package() + try: + root = _import_root_package() + except ImportError as error: + raise ScanIncomplete(f"could not import {ROOT_PACKAGE}: {error}") from error result: list[tuple[str, types.ModuleType]] = [(ROOT_PACKAGE, root)] for importer, modname, ispkg in pkgutil.walk_packages( @@ -373,7 +405,7 @@ def scan() -> list[Violation]: continue # only check modules that declare __all__ for sym_name in all_symbols: - obj = getattr(mod, sym_name, None) + obj = _lazy_getattr(mod, mod_name, sym_name) if obj is None: continue @@ -411,7 +443,14 @@ def main() -> int: parser.add_argument("--json", action="store_true", help="Output violations as JSON") args = parser.parse_args() - violations = scan() + try: + violations = scan() + except ScanIncomplete as error: + if args.json: + print(json.dumps({"error": "incomplete scan", "detail": str(error)})) + else: + print(f"API scan incomplete: {error}", file=sys.stderr) + return 2 if not violations: print("No private API leakage detected.", file=sys.stderr) diff --git a/source/qdk_package/check_requirements.txt b/source/qdk_package/check_requirements.txt index 033a9df1088..01d56af2b67 100644 --- a/source/qdk_package/check_requirements.txt +++ b/source/qdk_package/check_requirements.txt @@ -1,9 +1,16 @@ # Libraries that need to be installed to run Python static checks. azure-quantum>=3.8.0 +binar>=0.1.2 cirq-core>=1.6.1,<1.7 +highspy>=1.11,<2 matplotlib +more-itertools>=10.0 pandas>=2.1 pandas-stubs +paulimer>=0.2.2 pyqir>=0.12.5,<0.13 pyright==1.1.411 qiskit>=1.2.2,<3.0.0 +# `stim` (via `qodec[parsers]`) has no wheels for Linux aarch64 or Windows ARM64. +qodec[parsers]>=0.2.0.dev0,<0.3; platform_machine != 'aarch64' and platform_machine != 'ARM64' +qodec>=0.2.0.dev0,<0.3; platform_machine == 'aarch64' or platform_machine == 'ARM64' diff --git a/source/qdk_package/pyproject.toml b/source/qdk_package/pyproject.toml index 4851a3c6d7a..30be6a02a0b 100644 --- a/source/qdk_package/pyproject.toml +++ b/source/qdk_package/pyproject.toml @@ -33,6 +33,19 @@ qiskit = ["qiskit>=1.2.2,<3.0.0"] cirq = ["cirq-core>=1.6.1,<1.7", "cirq-ionq>=1.6.1,<1.7", "ply>=3.11"] qre = ["pandas>=2.1"] applications = ["cirq-core==1.6.1,<1.7"] +# Tooling to develop, test, and deploy quantum error correction schemes (qodecs). +# `qodec` is the declarative qodec file format and object model; `paulimer` and +# `binar` provide the Clifford/binary-algebra kernels the analyses run on. +# The `parsers` extra pulls in `stim`, which has no wheels for Linux aarch64 or +# Windows ARM64, so those platforms get `qodec` without it. +ec = [ + "qodec[parsers]>=0.2.0.dev0,<0.3; platform_machine != 'aarch64' and platform_machine != 'ARM64'", + "qodec>=0.2.0.dev0,<0.3; platform_machine == 'aarch64' or platform_machine == 'ARM64'", + "paulimer>=0.2.2", + "binar>=0.1.2", + "more-itertools>=10.0", + "highspy>=1.11,<2", +] all = [ "qsharp-widgets==0.0.0", "azure-quantum>=3.8.0", @@ -42,6 +55,12 @@ all = [ "pandas>=2.1", "ply>=3.11", "qsharp-jupyterlab==0.0.0", + "qodec[parsers]>=0.2.0.dev0,<0.3; platform_machine != 'aarch64' and platform_machine != 'ARM64'", + "qodec>=0.2.0.dev0,<0.3; platform_machine == 'aarch64' or platform_machine == 'ARM64'", + "paulimer>=0.2.2", + "binar>=0.1.2", + "more-itertools>=10.0", + "highspy>=1.11,<2", ] [tool.pytest.ini_options] diff --git a/source/qdk_package/qdk/__init__.py b/source/qdk_package/qdk/__init__.py index a954f7dce1c..eaa7106de49 100644 --- a/source/qdk_package/qdk/__init__.py +++ b/source/qdk_package/qdk/__init__.py @@ -38,6 +38,8 @@ - ``qdk[cirq]``: Cirq interoperability (:mod:`qdk.cirq`). - ``qdk[jupyter]``: interactive Jupyter widgets and JupyterLab integration (``qdk.widgets``). +- ``qdk[ec]`` — develop and test quantum error correction schemes + (:mod:`qdk.ec`). """ from .telemetry_events import on_qdk_import diff --git a/source/qdk_package/qdk/ec/__init__.py b/source/qdk_package/qdk/ec/__init__.py new file mode 100644 index 00000000000..983c6a1d74a --- /dev/null +++ b/source/qdk_package/qdk/ec/__init__.py @@ -0,0 +1,88 @@ +"""EXPERIMENTAL: Develop and test quantum error-correction schemes described by qodecs. + +The ``qodec`` package owns the data model and persistence. This module derives +facts by exact simulation, builds a qodec from a code, and audits complete +qodecs. Its public API is intentionally flat and small. + +There is no qodec-wide profile. A qodec is a stack of lowering layers, so its +action depends on the program lowered through it. Use :class:`GadgetProfile` to +ask what one gadget or circuit does, and :func:`audit` to ask whether a complete +qodec is internally consistent. + +Install the optional dependencies with ``pip install "qdk[ec]"``. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + # Imported eagerly only for type checkers and editors; at runtime the names + # below are resolved lazily, so `import qdk.ec` does not pull in paulimer, + # highspy and binar for a one-line call. + from ._analysis.channel_action import ChannelAction + from ._analysis.propagation.pauli import Pauli + from ._audit._auditor import audit + from ._audit._diagnostic import Diagnostic + from ._audit._report import Report + from ._code_profile import CodeProfile + from ._distance_result import Distance + from ._fill import filled + from ._faults import FaultEffect, FaultEvent + from ._profile import GadgetProfile + from ._build import build_qodec + +__all__ = [ + "ChannelAction", + "CodeProfile", + "Diagnostic", + "Distance", + "FaultEffect", + "FaultEvent", + "GadgetProfile", + "Pauli", + "Report", + "audit", + "build_qodec", + "filled", +] + +_EXPORTS = { + "ChannelAction": ("._analysis.channel_action", "ChannelAction"), + "CodeProfile": ("._code_profile", "CodeProfile"), + "Diagnostic": ("._audit._diagnostic", "Diagnostic"), + "Distance": ("._distance_result", "Distance"), + "FaultEffect": ("._faults", "FaultEffect"), + "FaultEvent": ("._faults", "FaultEvent"), + "GadgetProfile": ("._profile", "GadgetProfile"), + "Pauli": ("._analysis.propagation.pauli", "Pauli"), + "Report": ("._audit._report", "Report"), + "audit": ("._audit._auditor", "audit"), + "build_qodec": ("._build", "build_qodec"), + "filled": ("._fill", "filled"), +} + + +def __getattr__(name: str) -> Any: + try: + module_name, attribute = _EXPORTS[name] + except KeyError as error: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from error + try: + value = getattr(import_module(module_name, __name__), attribute) + except ModuleNotFoundError as error: + if error.name in {"binar", "highspy", "more_itertools", "paulimer", "qodec"}: + raise ModuleNotFoundError( + f"qdk.ec requires optional dependencies; install them with " + f"'pip install \"qdk[ec]\"' (missing {error.name!r})" + ) from error + raise + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/source/qdk_package/qdk/ec/_analysis/__init__.py b/source/qdk_package/qdk/ec/_analysis/__init__.py new file mode 100644 index 00000000000..93ce1645d30 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/__init__.py @@ -0,0 +1,10 @@ +"""Analysis engines shared by more than one ``qdk.ec`` module. + +Nothing here is public API. A module belongs in this package when it has +several consumers. This includes the propagation interpreter and stabilizer +algebra used by the private channel-action, check, completion, distance, fault, +profile, readout, build, and audit modules. Code with a single consumer lives +in that module instead. + +Import the submodules directly; the layout here is free to change. +""" diff --git a/source/qdk_package/qdk/ec/_analysis/channel_action.py b/source/qdk_package/qdk/ec/_analysis/channel_action.py new file mode 100644 index 00000000000..3aed30423b7 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/channel_action.py @@ -0,0 +1,845 @@ +"""Input/output stabilizer and logical action of a qodec program.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Mapping, Sequence, Union + +from binar import BitMatrix, BitVector, solve +import qodec as qc +from paulimer import PauliGroup +from qodec.actions import Stabilize +from qodec.gadgets import Circuit + +from .._layout import ProgramLayout +from .._frames import FrameMap +from .propagation.conditional import ConditionalChoiResult, conditional_choi_state +from .propagation.frames import FrameGroup, PauliFrame +from .propagation.groups import restriction_indicator_basis_of +from .propagation.interpreter import program_of +from .propagation.isa_actions import remap_pauli +from .propagation.pauli import ( + Pauli, + complex_conjugate_of, + identity, + restrict, +) +from .propagation.pauli_remap import encoding_qubit_relocation +from .code_algebra import SubsystemCode, subsystem_code_of +from .separable_code import SeparableCode +from .stabilizer_code import StabilizerCode + + +@dataclass(init=False, repr=False, match_args=False, slots=True) +class ChannelAction: + """Opaque channel behavior returned by ``GadgetProfile.action`` or ``.objective``. + + Compare results with :meth:`is_equivalent_to` or + :meth:`why_not_equivalent_to`. Direct construction and access to the + internal stabilizers, observables, and logical mapping are not supported. + Frame indices are positions in the analyzed circuit's readouts, not simulator + rows or gadget readout equations. The objective uses the declared operation's + readouts. Artificial input preparation and code projection are conditioned + away; unrecorded outcomes are averaged over, retaining only readout-conditioned + relations. Dependent readouts use an independent subset of readout positions. + + Text displays observables on the input, stabilizers on the output, and Pauli + mappings from input to output. Empty sections are omitted. The sign notation + ``-1^(...)`` means (-1) raised to the enclosed readout parity; it is display + notation, not Python syntax. Text is not a canonical equivalence signature. + """ + + _observables: FrameGroup + _stabilizers: FrameGroup + _mapping: Mapping[Pauli, PauliFrame] + + def __new__(cls) -> "ChannelAction": + raise TypeError( + "ChannelAction cannot be constructed directly; " + "use GadgetProfile.action or GadgetProfile.objective" + ) + + @classmethod + def _create( + cls, + observables: FrameGroup, + stabilizers: FrameGroup, + mapping: Mapping[Pauli, PauliFrame], + ) -> "ChannelAction": + action = object.__new__(cls) + action._observables = observables + action._stabilizers = stabilizers + action._mapping = mapping + return action + + def is_equivalent_to( + self, other: "ChannelAction", *, modulo_paulis: bool = False + ) -> bool: + """Compare operators and their joint outcome-sign relations. + + Outcome labels are local to each action, so renumbering them does not + change equivalence. With modulo_paulis, ignore all signs. Otherwise, + outcome-dependent signs must agree; undeclared corrections are not assumed. + """ + if self is other: + return True + return are_equivalent_mod_paulis(self, other) and ( + modulo_paulis or are_outcome_equivalent(self, other) + ) + + def why_not_equivalent_to(self, other: "ChannelAction") -> str: + """Return the first difference, or an empty string for equivalent actions.""" + if self is other: + return "" + for name, expected, actual in ( + ("measured logical observable", self._observables, other._observables), + ("output logical stabilizer", self._stabilizers, other._stabilizers), + ): + expected_group = _unsigned(expected.unframed) + actual_group = _unsigned(actual.unframed) + if expected_group == actual_group: + continue + for generator in expected_group.standard_generators: + if actual_group.factorization_of(generator) is None: + return ( + f"Expected {name} {generator}; absent from the circuit's group." + ) + for generator in actual_group.standard_generators: + if expected_group.factorization_of(generator) is None: + return f"Circuit has unexpected {name} {generator}." + expected_mapping = {abs(key): value for key, value in self._mapping.items()} + actual_mapping = {abs(key): value for key, value in other._mapping.items()} + for operator in sorted( + expected_mapping.keys() | actual_mapping.keys(), key=_sort_key + ): + expected_image = expected_mapping.get(operator) + actual_image = actual_mapping.get(operator) + if ( + expected_image is None + or actual_image is None + or abs(expected_image.pauli) != abs(actual_image.pauli) + ): + expected_text = ( + str(expected_image.pauli) + if expected_image is not None + else "no output image" + ) + actual_text = ( + str(actual_image.pauli) + if actual_image is not None + else "no output image" + ) + return f"Logical {operator}: expected {expected_text}; circuit gives {actual_text}." + return _sign_difference(self, other) + + def __str__(self) -> str: + lines = [] + for name, group in ( + ("observables", self._observables), + ("stabilizers", self._stabilizers), + ): + if group.generators: + lines.append(f"{name}:") + for generator in sorted( + group.generators, key=lambda item: _sort_key(item.pauli) + ): + sign = _action_sign(1 / generator.pauli.phase, generator.frame) + lines.append(f" {abs(generator.pauli):sparse,ascii} = {sign}") + if self._mapping: + lines.append("mapping:") + for operator, image in sorted( + self._mapping.items(), key=lambda item: _sort_key(item[0]) + ): + lines.append(f" {operator:sparse,ascii} → {_action_image(image)}") + return "\n".join(lines) or "no observable, stabilizer, or mapping relations" + + def __repr__(self) -> str: + return str(self) + + def _repr_pretty_(self, printer: Any, cycle: bool) -> None: + printer.text("..." if cycle else str(self)) + + +def _action_sign(phase: complex, readouts: frozenset[int]) -> str: + """Format an eigenvalue or sign factor using circuit-readout positions. + + The compact text -1^(...) denotes (-1) raised to the enclosed bit parity. + """ + constant = {1: "+1", -1: "-1", 1j: "+i", -1j: "-i"}[phase] + if not readouts: + return constant + terms = [f"circuit.readouts[{index}]" for index in sorted(readouts)] + if phase in (-1, -1j): + terms.insert(0, "1") + factor = f"-1^({' ⊕ '.join(terms)})" + return f"i {factor}" if phase in (1j, -1j) else factor + + +def _action_image(image: PauliFrame) -> str: + if not image.frame: + return f"{image.pauli:sparse,ascii}" + return f"{_action_sign(image.pauli.phase, image.frame)} {abs(image.pauli):sparse,ascii}" + + +def input_qubits_of(program: Circuit) -> frozenset[int]: + seen: set[int] = set() + prepared: set[int] = set() + layout = ProgramLayout.of(program) + for call in program.calls(): + instruction = program.instruction_set.instructions[call.mnemonic] + qubit_map = layout.call_qubit_map(call) + for action in instruction.action: + touched: set[int] = set() + if isinstance(action, Stabilize): + for pauli_str in action.operators: + remapped = remap_pauli(pauli_str, qubit_map) + support = set(remapped.support) + touched |= support + if len(support) == 1: + qubit = next(iter(support)) + if qubit not in seen: + prepared.add(qubit) + else: + touched |= set(qubit_map.values()) + seen |= touched + return frozenset(range(layout.total_qubits)) - prepared + + +def action_of( + program: Circuit, + with_respect_to: Union[ + SubsystemCode, tuple[SubsystemCode, SubsystemCode], None + ] = None, +) -> ChannelAction: + if with_respect_to is None: + return _action_of(program, input_qubits=sorted(input_qubits_of(program))) + if isinstance(with_respect_to, SubsystemCode): + with_respect_to = (with_respect_to, with_respect_to) + code_in, code_out = with_respect_to + physical = _action_of( + program, + input_qubits=sorted(code_in.support), + codespace_projector=tuple(code_in.stabilizers), + output_support=sorted(code_out.support), + ) + return _decode(physical, with_respect_to=(code_in, code_out)) + + +def _action_of( + program: Circuit, + *, + input_qubits: Sequence[int], + codespace_projector: Sequence[Pauli] = (), + output_support: Sequence[int] | None = None, + frames: FrameMap | None = None, +) -> ChannelAction: + auxiliary_origin = _aux_origin_of( + program, + input_qubits=input_qubits, + codespace_projector=codespace_projector, + output_support=output_support, + ) + result = conditional_choi_state( + program, + input_qubits=input_qubits, + codespace_projector=codespace_projector, + aux_origin=auxiliary_origin, + ) + group = result.group + if frames is not None: + matrix = result.simulation.outcome_matrix + zero = BitVector.zeros(matrix.column_count + 1) + outcomes = iter(result.observe_outcome_rows) + one = zero.copy() + one[len(one) - 1] = True + values = {"1": one} + for position, readout in enumerate(program.readouts): + if isinstance(readout, qc.gadgets.Flag): + value = zero.copy() + else: + row = next(outcomes) + value = BitVector( + [ + *( + bool(matrix[row, column]) + for column in range(matrix.column_count) + ), + bool(result.simulation.outcome_shift[row]), + ] + ) + values[f"circuit.readouts[{position}]"] = value + corrected = [] + for generator in group.generators: + delta = frames.for_probe(generator.pauli, values, zero) + corrected.append( + PauliFrame( + -generator.pauli if delta[len(delta) - 1] else generator.pauli, + generator.frame + ^ frozenset( + index for index in delta.support if index < len(delta) - 1 + ), + ) + ) + group = FrameGroup(corrected) + auxiliary = {auxiliary_origin + offset for offset in range(len(input_qubits))} + physical_support = frozenset( + range(ProgramLayout.of(program).total_qubits) + if output_support is None + else output_support + ) + initial_count = 2 * len(input_qubits) + len(codespace_projector) + matrix = result.simulation.outcome_matrix + constraints = ( + BitMatrix(list(matrix.rows)[:initial_count]) + if initial_count + else BitMatrix.zeros(0, matrix.column_count) + ) + shifts = BitVector( + bool(result.simulation.outcome_shift[row]) for row in range(initial_count) + ) + initial = solve(constraints, shifts) + if initial is None: + raise ValueError("input encoding has no positive codeword") + group = _readout_conditioned_group(group, result, program, initial_count, initial) + stabilizers_out, stabilizers_in, logicals = group.partition(over=physical_support) + auxiliary_to_input = { + auxiliary_origin + offset: qubit for offset, qubit in enumerate(input_qubits) + } + return _assemble_action( + stabilizers_out, + stabilizers_in, + logicals, + auxiliary=auxiliary, + auxiliary_to_input=auxiliary_to_input, + physical_support=physical_support, + ) + + +def _independent_readout_rows( + result: ConditionalChoiResult, + program: Circuit, + unfixed_random_bits: Sequence[int], +) -> list[tuple[int, int]]: + """Select independent readouts after fixing the artificial input outcomes. + + Each pair gives a circuit-readout position and its simulation outcome row. + Constant shifts do not affect independence; sign conversion uses the original rows. + """ + readout_positions = ( + position + for position, readout in enumerate(program.readouts) + if not isinstance(readout, qc.gadgets.Flag) + ) + readout_rows = list( + zip(readout_positions, result.observe_outcome_rows, strict=True) + ) + if not readout_rows or not unfixed_random_bits: + return [] + outcome_matrix = result.simulation.outcome_matrix + coefficients = BitMatrix( + [ + [bool(outcome_matrix[row, bit]) for bit in unfixed_random_bits] + for _, row in readout_rows + ] + ) + return [readout_rows[index] for index in coefficients.T.echelonize()] + + +def _readout_conditioned_group( + group: FrameGroup, + result: ConditionalChoiResult, + program: Circuit, + initial_count: int, + initial: BitVector, +) -> FrameGroup: + """Express sign relations in an independent circuit-readout basis. + + Encode each remaining random bit as an auxiliary Z operator. Append the + recorded readout relations, then keep products with no auxiliary support. + Hidden signs cancel in those products; uncanceled hidden signs are averaged + out. Independent readouts keep correlated bits from appearing independent + to the action-equivalence check. + """ + random_outcome_rows = result.simulation.random_outcome_indicator.support + unfixed_random_bits = tuple( + bit for bit, row in enumerate(random_outcome_rows) if row >= initial_count + ) + readout_rows = _independent_readout_rows(result, program, unfixed_random_bits) + quantum_support = set(group.unframed.support) + auxiliary_start = max(quantum_support, default=-1) + 1 + + def sign_operator(random_bits: frozenset[int], phase: complex = 1) -> Pauli: + negative = sum(bool(initial[bit]) for bit in random_bits) % 2 + support = random_bits.intersection(unfixed_random_bits) + return Pauli({auxiliary_start + bit: "Z" for bit in support}) * identity( + -phase if negative else phase + ) + + joint_generators = [ + PauliFrame(generator.pauli * sign_operator(generator.frame)) + for generator in group.generators + ] + outcome_rows = list(result.simulation.outcome_matrix.rows) + joint_generators.extend( + PauliFrame( + sign_operator( + frozenset(outcome_rows[row].support), + -1 if result.simulation.outcome_shift[row] else 1, + ), + frozenset({position}), + ) + for position, row in readout_rows + ) + joint = FrameGroup(joint_generators) + return joint.subgroup( + restriction_indicator_basis_of(joint.unframed, supported_by=quantum_support) + ) + + +def _assemble_action( + stabilizers_out: FrameGroup, + stabilizers_in: FrameGroup, + logicals: FrameGroup, + *, + auxiliary: set[int], + auxiliary_to_input: Mapping[int, int], + physical_support: frozenset[int], +) -> ChannelAction: + def input_adjust(pauli: Pauli) -> Pauli: + relabeled = Pauli( + { + auxiliary_to_input[qubit]: pauli[qubit] + for qubit in set(pauli.support) & auxiliary + } + ) * identity(pauli.phase) + return complex_conjugate_of(relabeled) + + logicals = logicals % (stabilizers_in | stabilizers_out) + mapping = { + input_adjust(abs(framed.pauli)): PauliFrame( + restrict(framed.pauli, physical_support), framed.frame + ) + for framed in logicals.standardized().generators + } + observables = FrameGroup( + PauliFrame(input_adjust(framed.pauli), framed.frame) + for framed in stabilizers_in.standardized().generators + ) + return ChannelAction._create(observables, stabilizers_out.standardized(), mapping) + + +def _aux_origin_of( + program: Circuit, + *, + input_qubits: Sequence[int], + codespace_projector: Sequence[Pauli], + output_support: Sequence[int] | None, +) -> int: + support = set(range(ProgramLayout.of(program).total_qubits)) | set(input_qubits) + for stabilizer in codespace_projector: + support |= set(stabilizer.support) + if output_support is not None: + support |= set(output_support) + return max(support) + 1 if support else 0 + + +def _decode( + action: ChannelAction, + *, + with_respect_to: tuple[SubsystemCode, SubsystemCode], +) -> ChannelAction: + _validate(action, with_respect_to=with_respect_to) + code_in, code_out = with_respect_to + observables_group = action._observables.unframed + stabilizers_group = action._stabilizers.unframed + + def phase_of(pauli: Pauli) -> Pauli: + return _phase_of(pauli, within=stabilizers_group) + + code_out = SubsystemCode( + [phase_of(generator) * generator for generator in code_out.stabilizers], + logical_basis=code_out.logical_basis, + gauge_basis=code_out.gauge_basis, + ) + observables = _logical_form_of(action._observables, with_respect_to=code_in) + stabilizers = _logical_form_of( + action._stabilizers, with_respect_to=code_out, carry_code_frames=True + ) + input_generators = [ + *action._mapping, + *(item.pauli for item in action._observables.generators), + ] + output_generators = [ + *action._mapping.values(), + *( + PauliFrame(identity(), item.frame) + for item in action._observables.generators + ), + ] + indexed_inputs = FrameGroup( + PauliFrame(generator, frozenset({index})) + for index, generator in enumerate(input_generators) + ) + phased_inputs = indexed_inputs | FrameGroup([PauliFrame(identity(1j))]) + mapping = {} + for basis_element in code_in.logical_basis: + target = basis_element + # A logical with no image is normal here, not a failure to characterize: + # a destructive measurement produces both cases below. + if not (PauliGroup([target]) % observables_group).generators[0].weight: + # Read out by the circuit rather than carried forward. + continue + factorization = indexed_inputs.factorization_of(target) + phase_extended = factorization is None + if phase_extended: + factorization = phased_inputs.factorization_of(target) + if factorization is None: + # Nothing the channel carries reproduces it, so no output holds it. + continue + factors: frozenset[int] = frozenset() + for factor in factorization: + factors ^= factor.frame + input_product = identity() + for index, generator in enumerate(input_generators): + if index in factors: + input_product *= generator + output = PauliFrame(identity()) + for index in sorted(factors): + output = output * output_generators[index] + output = output * (target.phase / input_product.phase) + logical_output = _logical_form_of( + FrameGroup([*action._stabilizers.generators, output]), + with_respect_to=code_out, + carry_code_frames=True, + ) + reduced = logical_output % stabilizers + images = [item for item in reduced.generators if item.pauli.weight] + if len(images) != 1: + continue + mapping[code_in.logical_action_of(target)] = images[0] + return ChannelAction._create(observables, stabilizers, mapping) + + +def _phase_of(pauli: Pauli, *, within: PauliGroup) -> Pauli: + reduced = (PauliGroup([pauli]) % within).generators[0] + phases = within.phases + if reduced.weight or len(phases) != 1: + raise ValueError(f"{pauli} does not have a unique phase.") + return reduced * identity(1j ** phases[0]) + + +def _logical_form_of( + group: FrameGroup, + *, + with_respect_to: SubsystemCode, + carry_code_frames: bool = False, +) -> FrameGroup: + if not carry_code_frames: + logicals = FrameGroup( + PauliFrame(with_respect_to.logical_action_of(item.pauli), item.frame) + for item in group.generators + ) + return FrameGroup( + item for item in logicals.standardized().generators if item.pauli.weight + ) + code_frames = [] + for operator in with_respect_to.stabilizers: + factors = group.factorization_of(operator) + if factors is None: + factors = group.factorization_of(-operator) + if factors is None: + raise ValueError(f"code stabilizer {operator} has no framed image") + product = PauliFrame(identity()) + for factor in factors: + product = product * factor + code_frames.append(product) + stabilizers = FrameGroup(code_frames) + logicals = [] + for framed in group.generators: + logical = with_respect_to.logical_action_of(framed.pauli) + if not logical.weight: + continue + representative = with_respect_to.representative_of(logical) + difference = framed.pauli * representative + factors = stabilizers.factorization_of(difference) + if factors is None: + factors = stabilizers.factorization_of(-difference) + if factors is None: + raise ValueError( + f"logical representative of {framed.pauli} has no framed stabilizer relation" + ) + frame = framed.frame + for factor in factors: + frame ^= factor.frame + logicals.append(PauliFrame(logical, frame)) + logical_action = FrameGroup(logicals) + return FrameGroup( + framed + for framed in logical_action.standardized().generators + if framed.pauli.weight + ) + + +def _validate( + action: ChannelAction, + *, + with_respect_to: tuple[SubsystemCode, SubsystemCode], +) -> None: + code_in, code_out = with_respect_to + observables_group = action._observables.unframed + stabilizers_group = action._stabilizers.unframed + _validate_group(observables_group, against=code_in) + _validate_group(stabilizers_group, against=code_out) + observables = observables_group % (observables_group % code_in.stabilizer) + stabilizers = stabilizers_group % (stabilizers_group % code_out.stabilizer) + relative_syndrome = observables % stabilizers + if -Pauli.identity() in relative_syndrome.generators: + raise ValueError("Syndrome mapping is non-linear.") + + +def _validate_group(group: PauliGroup, *, against: SubsystemCode) -> None: + quotient = PauliGroup(against.stabilizers) % group + if any(generator.weight for generator in quotient.generators): + raise ValueError( + "Circuit generators do not include the respective code stabilizers." + ) + if not against.support >= set(group.support): + raise ValueError("Code support does not include the circuit support.") + + +def _unsigned(group: PauliGroup) -> PauliGroup: + return PauliGroup([abs(generator) for generator in group.generators]) + + +def are_equivalent_mod_paulis(action1: ChannelAction, action2: ChannelAction) -> bool: + """Whether two actions agree once measurement-dependent signs are ignored. + + Precondition: both actions must be decoded against the same logical + labelling, because the mappings are compared key by key rather than + canonicalized first. Actions produced by :func:`action_of` for the same + pair of codes satisfy this; two actions decoded against different logical + bases for the same code do not. + """ + if _unsigned(action1._observables.unframed) != _unsigned( + action2._observables.unframed + ) or _unsigned(action1._stabilizers.unframed) != _unsigned( + action2._stabilizers.unframed + ): + return False + mapping1 = {abs(key): abs(value.pauli) for key, value in action1._mapping.items()} + mapping2 = {abs(key): abs(value.pauli) for key, value in action2._mapping.items()} + return mapping1 == mapping2 + + +def are_outcome_equivalent(action1: ChannelAction, action2: ChannelAction) -> bool: + return not _sign_difference(action1, action2) + + +def _outcome_items( + action: ChannelAction, +) -> list[tuple[complex, frozenset[int]]]: + items = [] + for framed in action._observables.standardized().generators: + items.append((framed.pauli.phase, framed.frame)) + for framed in action._stabilizers.standardized().generators: + items.append((framed.pauli.phase, framed.frame)) + mapping = sorted(action._mapping.items(), key=lambda item: _sort_key(item[0])) + for key, _ in mapping: + items.append((key.phase, frozenset())) + for _, value in mapping: + items.append((value.pauli.phase, value.frame)) + return items + + +def _sort_key(pauli: Pauli) -> tuple[tuple[int, ...], tuple[str, ...]]: + """A structural order, so comparison does not depend on Pauli formatting.""" + return tuple(pauli.support), tuple(str(character) for character in pauli.characters) + + +def _sign_difference(expected: ChannelAction, actual: ChannelAction) -> str: + expected_items = _outcome_items(expected) + actual_items = _outcome_items(actual) + if len(expected_items) != len(actual_items): + return "Different numbers of outcome-sign relations." + if expected_items == actual_items: + return "" + labels = [ + *( + f"observable {abs(item.pauli)}" + for item in expected._observables.standardized().generators + ), + *( + f"output stabilizer {abs(item.pauli)}" + for item in expected._stabilizers.standardized().generators + ), + *( + f"input {abs(operator)}" + for operator in sorted(expected._mapping, key=_sort_key) + ), + *( + f"output image of {abs(operator)}" + for operator in sorted(expected._mapping, key=_sort_key) + ), + ] + products = [] + for index, ( + (expected_phase, expected_frame), + (actual_phase, actual_frame), + ) in enumerate(zip(expected_items, actual_items, strict=True)): + product = ( + Pauli({2 * bit: "Z" for bit in expected_frame}) + * Pauli({2 * bit + 1: "Z" for bit in actual_frame}) + * identity(expected_phase * actual_phase) + ) + products.append(PauliFrame(product, frozenset({index}))) + support = {qubit for item in products for qubit in item.pauli.support} + group = FrameGroup(products) + for side in (0, 1): + restricted, _, _ = group.partition( + over={qubit for qubit in support if qubit % 2 == side} + ) + for witness in restricted.generators: + if not witness.pauli.weight and witness.pauli.phase == 1: + continue + terms = " ⊕ ".join(labels[index] for index in sorted(witness.frame)) + if not witness.pauli.weight: + return f"Opposite sign parity: {terms}." + variable = "expected" if side == 0 else "circuit" + fixed = "circuit" if side == 0 else "expected" + return f"Sign parity ({terms}): {variable} varies with outcomes; {fixed} is fixed." + return "" + + +def declared_program_of(gadget: qc.Gadget) -> Circuit: + instruction = gadget.implements + input_count, output_count = _declared_logical_counts(gadget) + unit = qc.instructions.BlockOperand("declared") + synthetic = qc.Instruction( + mnemonic=instruction.mnemonic, + inputs=[unit for _ in range(input_count)], + outputs=[unit for _ in range(output_count)], + parameters=list(instruction.parameters), + flags=list(instruction.flags), + action=list(instruction.action), + ) + isa = _declared_isa(synthetic) + return Circuit( + isa, + json.dumps( + [ + { + instruction.mnemonic: { + "operands": list(range(max(input_count, output_count))), + "arguments": { + parameter.name: parameter.name + for parameter in instruction.parameters + }, + } + } + ] + ), + format="yaml", + ) + + +def _declared_isa( + instruction: qc.Instruction, +) -> qc.InstructionSet: + block = qc.instructions.Block("declared", encodes=1) + return qc.InstructionSet( + name="declared", blocks=[block], instructions=[instruction] + ) + + +def _declared_logical_counts(gadget: qc.Gadget) -> tuple[int, int]: + return ( + sum(len(encoding.code.x) for encoding in gadget.inputs), + sum(len(encoding.code.x) for encoding in gadget.outputs), + ) + + +def declared_codes_of( + gadget: qc.Gadget, +) -> tuple[SeparableCode, SeparableCode]: + input_count, output_count = _declared_logical_counts(gadget) + return ( + _identity_codes_over(range(input_count)), + _identity_codes_over(range(output_count)), + ) + + +def _identity_codes_over(qubit_indices: Sequence[int] | range) -> SeparableCode: + blocks = [ + StabilizerCode( + [], + logical_basis=[ + Pauli({qubit: "X"}), + Pauli({qubit: "Z"}), + ], + ) + for qubit in qubit_indices + ] + return SeparableCode(*blocks) + + +def realized_codes_of( + gadget: qc.Gadget, +) -> tuple[SeparableCode, SeparableCode]: + return ( + _stack_encodings(gadget.inputs), + _stack_encodings(gadget.outputs), + ) + + +def _stack_encodings(encodings: Sequence[qc.gadgets.Encoding]) -> SeparableCode: + blocks = [] + for encoding in encodings: + code = subsystem_code_of(encoding.code) + blocks.append(code.relocated(encoding_qubit_relocation(encoding))) + return SeparableCode(*blocks) + + +def declared_action_of(gadget: qc.Gadget) -> ChannelAction: + codes_in, codes_out = declared_codes_of(gadget) + return action_of( + declared_program_of(gadget), + with_respect_to=(codes_in, codes_out), + ) + + +def realized_action_of(gadget: qc.Gadget) -> ChannelAction: + codes_in, codes_out = realized_codes_of(gadget) + physical = _action_of( + program_of(gadget), + input_qubits=sorted(codes_in.support), + codespace_projector=tuple(codes_in.stabilizers), + output_support=sorted(codes_out.support), + frames=FrameMap(gadget) if gadget.frames else None, + ) + return _decode(physical, with_respect_to=(codes_in, codes_out)) + + +def gadget_action_mismatch(gadget: qc.Gadget) -> str | None: + expected = declared_action_of(gadget) + actual = realized_action_of(gadget) + if not are_equivalent_mod_paulis(expected, actual): + return "logical action differs between declared and realized" + if not are_outcome_equivalent(expected, actual): + return "logical action matches up to Pauli signs but not outcome-wise" + return None + + +__all__ = [ + "ChannelAction", + "action_of", + "are_equivalent_mod_paulis", + "are_outcome_equivalent", + "gadget_action_mismatch", + "declared_action_of", + "realized_action_of", + "input_qubits_of", + "declared_codes_of", + "declared_program_of", + "realized_codes_of", +] diff --git a/source/qdk_package/qdk/ec/_analysis/check_discovery.py b/source/qdk_package/qdk/ec/_analysis/check_discovery.py new file mode 100644 index 00000000000..20b4813683f --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/check_discovery.py @@ -0,0 +1,386 @@ +"""Discover gadget checks and logical readouts by exact simulation.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import cast + +import qodec as qc +from paulimer import OutcomeCompleteSimulation, UnitaryOpcode +from qodec.actions import Observe +from qodec.gadgets import Circuit + +from .._layout import ProgramLayout +from .._readouts import flag_slots, observables_as_xor_map, observe_count_of +from .._references import Atom, Equation, Outcome, StabilizerSign, outcomes_of +from .propagation.interpreter import program_of, walk_program +from .propagation.pauli import Pauli, PauliCharacter, relabel +from .propagation.pauli_remap import declared_pauli_of, encoding_qubit_relocation + + +@dataclass(frozen=True) +class ProgramSimulation: + simulation: OutcomeCompleteSimulation + observe_outcomes: tuple[int, ...] + + +@dataclass(frozen=True) +class ChannelSimulation: + simulation: OutcomeCompleteSimulation + in_stab_outcomes: tuple[int, ...] + program_outcomes: tuple[int, ...] + out_stab_outcomes: tuple[int, ...] + declared_outcomes: tuple[tuple[str, int], ...] = () + in_refs: tuple["StabilizerReference", ...] = field(default_factory=tuple) + out_refs: tuple["StabilizerReference", ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class Profile: + checks: list[Equation] + + #: Every declared readout, keyed by name, including both flags and observe + #: outcomes. + readouts: dict[str, list[int]] + + +@dataclass(frozen=True) +class StabilizerReference: + entry: int + stabilizer_index: int + + +def simulate_program( + program: Circuit, + simulation: OutcomeCompleteSimulation | None = None, +) -> ProgramSimulation: + walk = walk_program(program, simulation=simulation) + return ProgramSimulation(walk.simulation, walk.observe_outcomes) + + +def choi_prepare(gadget: qc.Gadget) -> OutcomeCompleteSimulation: + program = program_of(gadget) + input_qubits = _input_data_qubits(gadget) + auxiliary_origin = _auxiliary_origin(program, input_qubits) + simulation = _fresh_sim(auxiliary_origin + len(input_qubits)) + for offset, data_qubit in enumerate(input_qubits): + simulation.apply_unitary( + UnitaryOpcode.PrepareBell, + [data_qubit, auxiliary_origin + offset], + ) + return simulation + + +def simulate_channel( + gadget: qc.Gadget, *, with_declared: bool = False +) -> ChannelSimulation: + program = program_of(gadget) + simulation = choi_prepare(gadget) + input_stabilizers, input_refs = _stabilizer_probes(gadget.inputs) + output_stabilizers, output_refs = _stabilizer_probes(gadget.outputs) + input_outcomes = [_measure(simulation, item) for item in input_stabilizers] + program_result = simulate_program(program, simulation) + output_outcomes = [_measure(simulation, item) for item in output_stabilizers] + declared_outcomes: tuple[tuple[str, int], ...] = () + if with_declared: + declared_outcomes = tuple( + (name, _measure(simulation, probe)) + for name, probe in _declared_observable_probes(gadget) + if probe is not None + ) + return ChannelSimulation( + simulation, + tuple(input_outcomes), + program_result.observe_outcomes, + tuple(output_outcomes), + declared_outcomes, + input_refs, + output_refs, + ) + + +def checks_of(gadget: qc.Gadget) -> list[Equation]: + result = simulate_channel(gadget) + return _emit_checks(result, _deterministic_rows(result)) + + +def _output_relations_of(gadget: qc.Gadget) -> list[tuple[Equation, int]]: + """Noiseless relations retaining input-to-output stabilizer transport.""" + result = simulate_channel(gadget) + relations = [] + for row in _deterministic_rows(result): + if not row.out_stabs: + continue + positions = ( + [result.in_stab_outcomes[index] for index in row.in_stabs] + + [result.program_outcomes[index] for index in row.outcomes] + + [result.out_stab_outcomes[index] for index in row.out_stabs] + ) + offset = ( + sum(bool(result.simulation.outcome_shift[index]) for index in positions) % 2 + ) + relations.append((_check_equation(result, row), offset)) + return relations + + +def profile_of(gadget: qc.Gadget) -> Profile: + result = simulate_channel(gadget, with_declared=True) + rows = _deterministic_rows(result) + checks = [row for row in rows if not row.declared] + declared_rows = [row for row in rows if row.declared] + readouts, excluded = _emit_readouts(result, gadget, declared_rows, checks) + return Profile( + checks=_emit_checks(result, checks, exclude=excluded), + readouts=readouts, + ) + + +@dataclass(frozen=True) +class CheckRow: + in_stabs: frozenset[int] + outcomes: frozenset[int] + out_stabs: frozenset[int] + declared: frozenset[int] = frozenset() + + def xor(self, other: "CheckRow") -> "CheckRow": + return CheckRow( + self.in_stabs ^ other.in_stabs, + self.outcomes ^ other.outcomes, + self.out_stabs ^ other.out_stabs, + self.declared ^ other.declared, + ) + + +def _emit_checks( + result: ChannelSimulation, + rows: Sequence[CheckRow], + *, + exclude: Sequence[frozenset[int]] = (), +) -> list[Equation]: + candidates = _eliminate(rows, lambda row: row.out_stabs) + _eliminate( + rows, lambda row: row.in_stabs + ) + excluded = set(exclude) + seen = set() + emitted = [] + for row in candidates: + if (not row.outcomes and row.in_stabs and row.out_stabs) or not ( + row.outcomes or row.in_stabs or row.out_stabs + ): + continue + if row.outcomes in excluded and not row.in_stabs and not row.out_stabs: + continue + key = (row.in_stabs, row.outcomes, row.out_stabs) + if key in seen: + continue + seen.add(key) + emitted.append(_check_equation(result, row)) + return emitted + + +def _check_equation(result: ChannelSimulation, row: CheckRow) -> Equation: + atoms: list[Atom] = [Outcome(index) for index in sorted(row.outcomes)] + for index in sorted(row.in_stabs): + reference = result.in_refs[index] + atoms.append(StabilizerSign("in", reference.entry, reference.stabilizer_index)) + for index in sorted(row.out_stabs): + reference = result.out_refs[index] + atoms.append(StabilizerSign("out", reference.entry, reference.stabilizer_index)) + return tuple(atoms) + + +def _eliminate( + rows: Sequence[CheckRow], target: Callable[[CheckRow], frozenset[int]] +) -> list[CheckRow]: + surviving = list(rows) + while True: + pivot_index = next( + (index for index, row in enumerate(surviving) if target(row)), + None, + ) + if pivot_index is None: + return surviving + pivot = surviving[pivot_index] + column = min(target(pivot)) + surviving = [ + row.xor(pivot) if column in target(row) else row + for index, row in enumerate(surviving) + if index != pivot_index + ] + + +def _deterministic_rows(result: ChannelSimulation) -> list[CheckRow]: + simulation = result.simulation + matrix = simulation.outcome_matrix + random = simulation.random_outcome_indicator + rank_profile = [index for index in range(matrix.row_count) if random[index]] + groups = ( + result.in_stab_outcomes, + result.program_outcomes, + result.out_stab_outcomes, + tuple(row for _, row in result.declared_outcomes), + ) + indexes = [{row: index for index, row in enumerate(group)} for group in groups] + reportable = set().union(*(set(group) for group in groups)) + rows = [] + for row in range(matrix.row_count): + if random[row] or row not in reportable: + continue + columns: list[set[int]] = [set(), set(), set(), set()] + _classify(row, indexes, columns) + for column, contributor in enumerate(rank_profile): + if matrix[row, column] and contributor in reportable: + _classify(contributor, indexes, columns) + rows.append(CheckRow(*(frozenset(column) for column in columns))) + return rows + + +def _classify( + row: int, indexes: Sequence[dict[int, int]], columns: Sequence[set[int]] +) -> None: + for lookup, target in zip(indexes, columns): + if row in lookup: + target.symmetric_difference_update({lookup[row]}) + return + + +def _emit_readouts( + result: ChannelSimulation, + gadget: qc.Gadget, + declared_rows: Sequence[CheckRow], + check_rows: Sequence[CheckRow], +) -> tuple[dict[str, list[int]], list[frozenset[int]]]: + basis = _eliminate( + _eliminate(list(declared_rows) + list(check_rows), lambda row: row.in_stabs), + lambda row: row.out_stabs, + ) + by_index = { + next(iter(row.declared)): row.outcomes + for row in basis + if len(row.declared) == 1 and not row.in_stabs and not row.out_stabs + } + discoverable = { + name: index for index, (name, _) in enumerate(result.declared_outcomes) + } + readouts = {} + flag_patterns = [] + flag_bindings = _flag_bindings_of(gadget) + authored = observables_as_xor_map(gadget) + for name in _declared_readout_names(gadget): + if name in discoverable: + index = discoverable[name] + if index not in by_index: + raise ValueError( + f"declared observable {name!r} could not be expressed " + "in terms of realized outcomes" + ) + outcomes = by_index[index] + elif name in flag_bindings: + outcomes = flag_bindings[name] + flag_patterns.append(outcomes) + elif name in authored: + outcomes = frozenset(authored[name]) + flag_patterns.append(outcomes) + else: + raise KeyError(f"flag {name!r} is not bound by gadget readouts") + readouts[name] = sorted(outcomes) + return readouts, flag_patterns + + +def _flag_bindings_of(gadget: qc.Gadget) -> dict[str, frozenset[int]]: + return { + slot.name: frozenset(outcomes_of(slot.equation)) for slot in flag_slots(gadget) + } + + +def _declared_readout_names(gadget: qc.Gadget) -> list[str]: + """Every readout the instruction declares: its flags, then its observe outcomes.""" + instruction = gadget.implements + return [ + *instruction.flags, + *(str(position) for position in range(observe_count_of(instruction))), + ] + + +def _fresh_sim(qubit_count: int) -> OutcomeCompleteSimulation: + simulation = OutcomeCompleteSimulation.with_capacity(qubit_count, 100, 100) + simulation.reserve_qubits(qubit_count) + simulation.reserve_outcomes(100, 100) + return simulation + + +def _measure(simulation: OutcomeCompleteSimulation, pauli: Pauli) -> int: + row = simulation.outcome_count + simulation.measure(pauli) + return row + + +def _input_data_qubits(gadget: qc.Gadget) -> list[int]: + qubits: set[int] = set() + for encoding in gadget.inputs: + qubits.update(encoding_qubit_relocation(encoding).values()) + return sorted(qubits) + + +def _stabilizer_probes( + encodings: Sequence[qc.gadgets.Encoding], +) -> tuple[tuple[Pauli, ...], tuple[StabilizerReference, ...]]: + paulis: list[Pauli] = [] + references: list[StabilizerReference] = [] + for entry, encoding in enumerate(encodings): + relocation = encoding_qubit_relocation(encoding) + for index, stabilizer in enumerate(encoding.code.stabilizers): + sparse = Pauli(str(stabilizer)) + paulis.append( + Pauli( + { + relocation[local]: cast(PauliCharacter, character) + for local, character in zip(sparse.support, sparse.characters) + } + ) + ) + references.append(StabilizerReference(entry, index)) + return tuple(paulis), tuple(references) + + +def _declared_observable_probes( + gadget: qc.Gadget, +) -> list[tuple[str, Pauli | None]]: + program = program_of(gadget) + auxiliary_origin = _auxiliary_origin(program, _input_data_qubits(gadget)) + partners = { + qubit: auxiliary_origin + offset + for offset, qubit in enumerate(_input_data_qubits(gadget)) + } + specs: list[tuple[str, Pauli | None]] = [ + (name, None) for name in gadget.implements.flags + ] + position = 0 + for action in gadget.implements.action: + if not isinstance(action, Observe): + continue + for observable in action.observables: + probe = declared_pauli_of(gadget.inputs, observable) + specs.append((str(position), relabel(probe, partners))) + position += 1 + return specs + + +def _auxiliary_origin(program: Circuit, input_qubits: Sequence[int]) -> int: + # Must agree with `channel_action._aux_origin_of`, which also accounts for a + # codespace projector and an output support. The two only coincide while + # neither reaches past the program's own qubits. + support = set(range(ProgramLayout.of(program).total_qubits)) | set(input_qubits) + return max(support) + 1 if support else 0 + + +__all__ = [ + "ChannelSimulation", + "Profile", + "checks_of", + "choi_prepare", + "profile_of", + "simulate_channel", + "simulate_program", +] diff --git a/source/qdk_package/qdk/ec/_analysis/code_algebra.py b/source/qdk_package/qdk/ec/_analysis/code_algebra.py new file mode 100644 index 00000000000..ee97f10c234 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/code_algebra.py @@ -0,0 +1,603 @@ +"""Algebraic view used to profile qodec code definitions.""" + +from __future__ import annotations + +from functools import cached_property +from itertools import chain, product +from typing import Callable, Iterable, Mapping, Optional, Sequence + +import qodec as qc +from binar import BitMatrix +from more_itertools import chunked, interleave, take +from paulimer import ( + CliffordUnitary, + DensePauli, + PauliGroup, + centralizer_of, + symplectic_form_of, +) + +from .propagation.groups import is_stabilizer_group +from .propagation.pauli import ( + Pauli, + as_literals, + characters_of, + identity, + relabel, +) + + +class SubsystemCode: # pylint: disable=too-many-public-methods + """Internal algebraic value with stabilizers, logicals, and optional gauges.""" + + def __init__( + self, + stabilizers: Sequence[Pauli], + logical_basis: Sequence[Pauli], + *, + gauge_basis: Optional[Sequence[Pauli]] = None, + ) -> None: + _validate_stabilizers(stabilizers) + _validate_basis(logical_basis, centralized=stabilizers, name="Logical") + self._stabilizer = PauliGroup(stabilizers, all_commute=True) + self._logical = PauliGroup(logical_basis) + self._support = frozenset(self._stabilizer.support) | frozenset( + self._logical.support + ) + self._declared_gauge: Optional[PauliGroup] = None + if gauge_basis is not None: + _validate_basis( + gauge_basis, + centralized=tuple(stabilizers) + tuple(logical_basis), + name="Gauge", + ) + self._support |= frozenset(PauliGroup(gauge_basis).support) + self._declared_gauge = PauliGroup(gauge_basis) + + @property + def stabilizer(self) -> PauliGroup: + return self._stabilizer + + @property + def stabilizers(self) -> Sequence[Pauli]: + return self.stabilizer.generators + + @cached_property + def anti_stabilizer(self) -> PauliGroup: + return PauliGroup(_anti_stabilizers_of(self), all_commute=True) + + @property + def anti_stabilizers(self) -> Sequence[Pauli]: + return self.anti_stabilizer.generators + + @property + def gauge(self) -> PauliGroup: + """The gauge group as declared, or derived when none was declared.""" + if self._declared_gauge is not None: + return self._declared_gauge + return self._derived_gauge + + @cached_property + def _derived_gauge(self) -> PauliGroup: + group = PauliGroup( + logical_basis_of(self._stabilizer, supported_by=tuple(self.support)) + ) + mod_group = (group | self.stabilizer) % (self.stabilizer | self.logical) + mod_group = PauliGroup( + normalize(mod_group.generators, with_respect_to_basis=self.logical_basis) + ) + return PauliGroup( + abs(generator) + for generator in symplectic_form_of(mod_group.generators) + if generator.weight + ) + + @property + def gauge_basis(self) -> tuple[Pauli, ...]: + return tuple(self.gauge.generators) + + @property + def logical(self) -> PauliGroup: + return self._logical + + @property + def logical_basis(self) -> Sequence[Pauli]: + return self.logical.generators + + @property + def support(self) -> frozenset[int]: + return self._support + + @property + def length(self) -> int: + return len(self.support) + + @property + def logical_qubit_count(self) -> int: + return len(self.logical_basis) // 2 + + def syndrome_of(self, error: Pauli) -> frozenset[int]: + return frozenset( + label + for label, generator in enumerate(self.stabilizers) + if not generator.commutes_with(error) + ) + + def logical_effect_of(self, error: Pauli) -> Pauli: + """Return the logical Pauli induced by ``error``.""" + return self.logical_action_of(error) + + def encoding_clifford( + self, *, supported_by: Sequence[int] | None = None + ) -> CliffordUnitary: + return encoding_clifford_of(self, supported_by=supported_by) + + def is_trivial_error(self, error: Pauli) -> bool: + return self.is_logical_error(error) and self.is_trivial_logical_error(error) + + def is_trivial_logical_error(self, error: Pauli) -> bool: + return all(error.commutes_with(generator) for generator in self.logical_basis) + + def is_logical_error(self, error: Pauli) -> bool: + return all(error.commutes_with(generator) for generator in self.stabilizers) + + def is_non_trivial_logical_error(self, error: Pauli) -> bool: + return self.is_logical_error(error) and not self.is_trivial_logical_error(error) + + def logical_action_of(self, error: Pauli, *, require_phase: bool = False) -> Pauli: + logical = self.unsigned_logical_action_of(error) + representative = self.representative_of(logical) + stabilizer = abs(error) * representative + reduced = (PauliGroup([stabilizer]) % self._stabilizer).generators[0] + if reduced.weight: + if require_phase: + raise ValueError( + "error has a nonzero syndrome or a gauge component; " + "no logical action with a scalar phase exists" + ) + return logical + return logical * reduced * identity(error.phase) + + def representative_of(self, pauli: Pauli) -> Pauli: + if not isinstance(pauli, Pauli): + raise TypeError(f"expected Pauli, got {type(pauli).__name__}") + if not set(pauli.support) <= frozenset(range(self.logical_qubit_count)): + raise ValueError(f"Pauli {pauli} has no logical representative.") + representative = Pauli.identity() + for index, character in characters_of(pauli).items(): + if character == "X": + representative *= self.logical_basis[2 * index] + elif character == "Z": + representative *= self.logical_basis[2 * index + 1] + elif character == "Y": + representative *= ( + self.logical_basis[2 * index] + * self.logical_basis[2 * index + 1] + * identity(1j) + ) + return representative * identity(pauli.phase) + + def unsigned_logical_action_of(self, error: Pauli) -> Pauli: + _validate_error_support(error, self.support) + character_of = ("Y", "Z", "X", "I") + commutations = map(error.commutes_with, self.logical_basis) + indexes = [2 * x + z for x, z in chunked(commutations, 2)] + return Pauli.from_string("".join(character_of[index] for index in indexes)) + + def is_equivalent_to( + self, + other: "SubsystemCode", + *, + including_signs: bool = False, + strict_basis: bool = True, + ) -> bool: + if self.support != other.support or not _are_equivalent( + self.stabilizer, + other.stabilizer, + including_signs=including_signs, + ): + return False + if strict_basis: + return self.logical_basis == other.logical_basis + return _are_equivalent( + self.logical, other.logical, including_signs=including_signs + ) + + def why_not_equivalent_to(self, other: "SubsystemCode") -> str: + if self.support != other.support: + return f"Code supports differ: {self.support!r} vs {other.support!r}." + if not _are_equivalent( + self.stabilizer, other.stabilizer, including_signs=False + ): + return "Stabilizer groups differ." + if self.logical_basis != other.logical_basis: + return "Logical bases differ." + if self.gauge_basis != other.gauge_basis: + return "Gauge bases differ." + return "" + + def relocated(self, by: Mapping[int, int]) -> "SubsystemCode": + return SubsystemCode( + [relabel(generator, by) for generator in self.stabilizers], + [relabel(generator, by) for generator in self.logical_basis], + gauge_basis=[relabel(generator, by) for generator in self.gauge_basis], + ) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, SubsystemCode) + and self.support == other.support + and self.stabilizers == other.stabilizers + and self.logical_basis == other.logical_basis + and self.gauge_basis == other.gauge_basis + ) + + def __hash__(self) -> int: + return hash((self.stabilizers, self.logical_basis)) + + +def subsystem_code_of(code: qc.Code) -> SubsystemCode: + """Snapshot a qodec code's operators as a validated algebraic value.""" + if not isinstance(code, qc.Code): + raise TypeError(f"expected qodec.Code, got {type(code).__name__}") + if len(code.x) != len(code.z): + raise ValueError( + f"Logical operator counts disagree: x has {len(code.x)}, z has {len(code.z)}." + ) + stabilizers = [Pauli(text) for text in code.stabilizers] + logical_basis = [ + Pauli(str(text)) + for x_operator, z_operator in zip(list(code.x), list(code.z)) + for text in (x_operator, z_operator) + ] + gauges = [Pauli(text) for text in getattr(code, "gauges", [])] + return SubsystemCode(stabilizers, logical_basis, gauge_basis=gauges or None) + + +def as_qodec_code(view: SubsystemCode, name: str, description: str = "") -> "qc.Code": + """Materialize an algebraic view as a named qodec code.""" + import qodec as qc + + if not name: + raise ValueError("Cannot materialize qodec.Code without a name.") + if list(view.gauge.generators): + raise ValueError( + "Cannot materialize a subsystem code with gauge operators as " + "qodec.Code; qodec does not yet model gauge pairs." + ) + return qc.Code( + name=name, + description=description, + stabilizers=[_format_pauli(stabilizer) for stabilizer in view.stabilizers], + x=[_format_pauli(operator) for operator in view.logical_basis[0::2]], + z=[_format_pauli(operator) for operator in view.logical_basis[1::2]], + ) + + +def anti_commutation_indicator_of( + observable: Pauli, paulis: Sequence[Pauli] +) -> frozenset[int]: + return frozenset( + index + for index, pauli in enumerate(paulis) + if not pauli.commutes_with(observable) + ) + + +def logical_effect_indicators_of( + code: SubsystemCode, errors: Sequence[Pauli] +) -> list[frozenset[int]]: + return [ + anti_commutation_indicator_of(error, code.logical_basis) for error in errors + ] + + +def syndrome_indicators_of( + code: SubsystemCode, errors: Sequence[Pauli] +) -> list[frozenset[int]]: + return [anti_commutation_indicator_of(error, code.stabilizers) for error in errors] + + +def one_qubit_errors_on_support(code: SubsystemCode, error_kinds: str) -> list[Pauli]: + return [ + Pauli({qubit: pauli_label}) + for pauli_label in as_literals(error_kinds) + for qubit in code.support + ] + + +def encoding_clifford_of( + code: SubsystemCode, *, supported_by: Optional[Sequence[int]] = None +) -> CliffordUnitary: + if supported_by is None: + supported_by = sorted(code.support) + elif frozenset(supported_by) != code.support: + raise ValueError( + f"Specified support {supported_by} is incomplete (need {code.support})." + ) + qubit_count = len(supported_by) + index_of = {qubit: index for index, qubit in enumerate(supported_by)} + images = [] + for image in clifford_images_of(code): + remapped = Pauli( + {index_of[qubit]: image[qubit] for qubit in image.support} + ) * identity(image.phase) + images.append(DensePauli.from_sparse(remapped, qubit_count)) + return CliffordUnitary.from_preimages(images).inverse() + + +def clifford_images_of(code: SubsystemCode) -> Sequence[Pauli]: + stabilizer_images = interleave(code.anti_stabilizers, code.stabilizers) + return list(chain(code.logical_basis, code.gauge_basis, stabilizer_images)) + + +def _are_equivalent( + left: PauliGroup, right: PauliGroup, *, including_signs: bool +) -> bool: + canonical: Callable[[Pauli], Pauli] = ( + (lambda generator: generator) if including_signs else abs + ) + return list(map(canonical, left.standard_generators)) == list( + map(canonical, right.standard_generators) + ) + + +def _validate_error_support(error: Pauli, support: frozenset[int]) -> None: + if not isinstance(error, Pauli): + raise TypeError(f"expected Pauli, got {type(error).__name__}") + if not set(error.support) <= support: + raise ValueError(f"Error {error} is not supported by {support}.") + + +def _validate_stabilizers(stabilizers: Sequence[Pauli]) -> None: + if not is_stabilizer_group(PauliGroup(stabilizers)): + raise ValueError("The provided stabilizer generators are invalid.") + + +def _format_pauli(pauli: Pauli) -> str: + return " ".join(f"{pauli[index]}_{index}" for index in sorted(pauli.support)) + + +def _validate_basis( + logical_basis: Sequence[Pauli], + *, + centralized: Sequence[Pauli], + name: str, +) -> None: + if not is_symplectic_basis(logical_basis): + raise ValueError( + f"{name} elements are not a symplectic basis: " + f"{why_not_symplectic_basis(logical_basis)}." + ) + if not _logical_basis_centralizes(logical_basis, centralized): + raise ValueError( + f"{name} basis elements do not commute with the complementary space." + ) + + +def _logical_basis_centralizes( + logical_basis: Sequence[Pauli], generators: Sequence[Pauli] +) -> bool: + return are_mutually_commutative(PauliGroup(logical_basis), PauliGroup(generators)) + + +def _anti_stabilizers_of(code: SubsystemCode) -> Sequence[Pauli]: + generators = code.stabilizers + logical_basis = tuple(code.logical_basis) + tuple(code.gauge_basis) + pure_errors = full_binary_rank_completion_of(list(generators) + list(logical_basis)) + pure_errors = normalize(pure_errors, with_respect_to_basis=logical_basis) + pure_errors = _ensure_anti_stabilizers_relations_with_generators( + pure_errors, generators + ) + return _make_abelian(pure_errors, generators) + + +def full_binary_rank_completion_of(generators: Sequence[Pauli]) -> Sequence[Pauli]: + matrix, support = sparse_paulis_as_bitmatrix(generators) + rank_profile = matrix.echelonize() + qubit_count = len(support) + complement = set(range(2 * qubit_count)).difference(rank_profile) + result = [] + for index in complement: + if index >= qubit_count: + result.append(Pauli({support[index - qubit_count]: "Z"})) + else: + result.append(Pauli({support[index]: "X"})) + return result + + +def _ensure_anti_stabilizers_relations_with_generators( + pure_errors: Sequence[Pauli], generators: Sequence[Pauli] +) -> list[Pauli]: + if len(pure_errors) != len(generators): + raise ValueError( + f"Pure errors ({len(pure_errors)}) and generators " + f"({len(generators)}) have different lengths." + ) + ordered_support = ordered_support_of(list(pure_errors) + list(generators)) + qubit_count = len(ordered_support) + generator_count = len(generators) + matrix = BitMatrix.zeros(len(pure_errors), generator_count + 2 * qubit_count) + support_pos = { + label: generator_count + position + for position, label in enumerate(ordered_support) + } + assign_bitmatrix_from_sparse_paulis(pure_errors, support_pos, qubit_count, matrix) + for row_id, pure_error in enumerate(pure_errors): + for column_id, generator in enumerate(generators): + matrix[row_id, column_id] = not pure_error.commutes_with(generator) + matrix.echelonize() + return [ + sparse_pauli_from_row(matrix, ordered_support, row_id, generator_count) + for row_id in range(len(pure_errors)) + ] + + +def _make_abelian( + pure_errors: list[Pauli], stabilizers: Sequence[Pauli] +) -> Sequence[Pauli]: + anti_stabilizers = list(pure_errors) + + def commuting_pure_error(error: Pauli, index: int) -> Pauli: + interleaved = list(interleave(anti_stabilizers, stabilizers)) + return normalizer_of_element( + error, interleaved[: 2 * index] + interleaved[2 * index + 2 :] + ) + + for index in range(len(anti_stabilizers) - 1): + anti_stabilizers[index] = commuting_pure_error(anti_stabilizers[index], index) + return anti_stabilizers + + +def is_symplectic_basis(basis: Sequence[Pauli]) -> bool: + return ( + _all_square_to_identity(basis) + and _pairs_anticommute(basis) + and _is_non_degenerate(basis) + ) + + +def why_not_symplectic_basis(basis: Sequence[Pauli]) -> str: + if not _all_square_to_identity(basis): + return "elements do not square to identity." + if not _pairs_anticommute(basis): + return "pairs do not anti-commute" + if not _is_non_degenerate(basis): + return "the basis is degenerate" + return "" + + +def _all_square_to_identity(paulis: Sequence[Pauli]) -> bool: + return all(_is_identity(pauli * pauli) for pauli in paulis) + + +def _is_identity(pauli: Pauli) -> bool: + return pauli.weight == 0 and pauli.phase == 1 + + +def _pairs_anticommute(basis: Sequence[Pauli]) -> bool: + return all(not first.commutes_with(second) for first, second in chunked(basis, 2)) + + +def _is_non_degenerate(basis: Sequence[Pauli]) -> bool: + support = set().union(*(set(pauli.support) for pauli in basis)) if basis else set() + qubit_count = len(support) + dense_basis = [DensePauli.from_sparse(abs(pauli), qubit_count) for pauli in basis] + for index, (logical_x, logical_z) in enumerate(chunked(dense_basis, 2)): + remaining = dense_basis[2 * index + 2 :] + if not ( + logical_x.commutes_with(remaining) and logical_z.commutes_with(remaining) + ): + return False + return True + + +def normalize( + elements: Sequence[Pauli], with_respect_to_basis: Sequence[Pauli] +) -> Sequence[Pauli]: + return [ + normalizer_of_element(element, with_respect_to_basis) for element in elements + ] + + +def normalizer_of_element(element: Pauli, basis: Sequence[Pauli]) -> Pauli: + for logical_x, logical_z in chunked(basis, 2): + if not element.commutes_with(logical_x): + element *= logical_z + if not element.commutes_with(logical_z): + element *= logical_x + return element + + +def sparse_paulis_as_bitmatrix( + paulis: Sequence[Pauli], +) -> tuple[BitMatrix, list[int]]: + ordered_support = ordered_support_of(paulis) + support_pos = { + element: position for position, element in enumerate(ordered_support) + } + qubit_count = len(ordered_support) + result = BitMatrix.zeros(len(paulis), 2 * qubit_count) + assign_bitmatrix_from_sparse_paulis(paulis, support_pos, qubit_count, result) + return result, ordered_support + + +def assign_bitmatrix_from_sparse_paulis( + paulis: Sequence[Pauli], + support_pos: dict[int, int], + qubit_count: int, + result: BitMatrix, +) -> None: + for row_id, pauli in enumerate(paulis): + for qubit in pauli.support: + qubit_id = support_pos[qubit] + character = pauli[qubit] + if character == "X": + result[row_id, qubit_id] = True + elif character == "Y": + result[row_id, qubit_id] = True + result[row_id, qubit_id + qubit_count] = True + elif character == "Z": + result[row_id, qubit_id + qubit_count] = True + else: + raise ValueError(f"Unexpected Pauli letter {character}.") + + +def sparse_pauli_from_row( + matrix: BitMatrix, + ordered_support: list[int], + row_id: int, + offset: int, +) -> Pauli: + qubit_count = len(ordered_support) + x_part = Pauli( + { + ordered_support[qubit_id]: "X" + for qubit_id in range(qubit_count) + if matrix[row_id, offset + qubit_id] + } + ) + z_part = Pauli( + { + ordered_support[qubit_id]: "Z" + for qubit_id in range(qubit_count) + if matrix[row_id, offset + qubit_count + qubit_id] + } + ) + return abs(x_part * z_part) + + +def logical_basis_of( + group: PauliGroup, + *, + supported_by: Optional[Iterable[int]] = None, +) -> Iterable[Pauli]: + if supported_by is None: + supported_by = group.support + supported_by = tuple(supported_by) + logical_basis_size = 2 * max(0, len(supported_by) - group.binary_rank) + basis_elements = list( + symplectic_form_of(centralizer_of(group, supported_by=supported_by).generators) + ) + for index in range(0, logical_basis_size, 2): + x_operator, z_operator = basis_elements[index], basis_elements[index + 1] + if "Z" in characters_of(x_operator).values(): + basis_elements[index], basis_elements[index + 1] = ( + z_operator, + x_operator, + ) + return take(logical_basis_size, map(abs, basis_elements)) + + +def are_mutually_commutative(group1: PauliGroup, group2: PauliGroup) -> bool: + return all( + generator1.commutes_with(generator2) + for generator1, generator2 in product(group1.generators, group2.generators) + ) + + +def ordered_support_of(generators: Iterable[Pauli]) -> list[int]: + support: set[int] = set() + for generator in generators: + support.update(generator.support) + return sorted(support) diff --git a/source/qdk_package/qdk/ec/_analysis/declaration_issues.py b/source/qdk_package/qdk/ec/_analysis/declaration_issues.py new file mode 100644 index 00000000000..4c3dd38a919 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/declaration_issues.py @@ -0,0 +1,57 @@ +"""Structural issues in a gadget's declared instruction surface.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import qodec as qc +from qodec.actions import Clifford, Observe, Pauli, Stabilize + +from .._readouts import readouts_of + + +@dataclass(frozen=True) +class DeclarationIssues: + missing_observables: tuple[str, ...] = () + missing_flags: tuple[str, ...] = () + unsupported_steps: tuple[str, ...] = () + bound_flags: tuple[str, ...] = () + + +def declaration_issues(gadget: qc.Gadget) -> DeclarationIssues: + """Report declaration bindings the structural verifier cannot consume.""" + instruction = gadget.implements + readouts = readouts_of(gadget) + bound_observables = {slot.name for slot in readouts.observables} + declared_observable_count = sum( + len(action.observables) + for action in instruction.action + if isinstance(action, Observe) + ) + missing_observables = tuple( + str(index) + for index in range(declared_observable_count) + if str(index) not in bound_observables + ) + + bound_flag_count = min(len(readouts.flags), len(instruction.flags)) + bound_flags = tuple(instruction.flags[:bound_flag_count]) + missing_flags = tuple(instruction.flags[bound_flag_count:]) + + unsupported = [] + for action in instruction.action: + if isinstance(action, (Stabilize, Observe)): + continue + if isinstance(action, (Pauli, Clifford)) and action.condition is None: + continue + unsupported.append(type(action).__name__) + + return DeclarationIssues( + missing_observables=missing_observables, + missing_flags=missing_flags, + unsupported_steps=tuple(unsupported), + bound_flags=bound_flags, + ) + + +__all__ = ["DeclarationIssues", "declaration_issues"] diff --git a/source/qdk_package/qdk/ec/_analysis/distance_solvers.py b/source/qdk_package/qdk/ec/_analysis/distance_solvers.py new file mode 100644 index 00000000000..57f716a5119 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/distance_solvers.py @@ -0,0 +1,387 @@ +"""Internal distance backends sharing a certified-bounds result.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import reduce +from importlib import import_module +from itertools import combinations +from operator import xor +from typing import Any, Callable, Literal, Optional, TYPE_CHECKING, Union + +from binar import BitMatrix, BitVector + +if TYPE_CHECKING: + from .odd_cycles import OddCycles + + +@dataclass +class EnumerationSolverOptions: + size_upper_bound: Optional[int] = None + + +@dataclass +class MwpfSolverOptions: + solver: str = "joint_single_hair" + cluster_node_limit: Optional[int] = None + timeout: Optional[float] = None + + def config(self) -> dict[str, dict[str, float]]: + primal: dict[str, float] = {} + if self.timeout is not None: + primal["timeout"] = self.timeout + if self.cluster_node_limit is not None: + primal["cluster_node_limit"] = self.cluster_node_limit + return {"primal": primal} + + +@dataclass(kw_only=True) +class HighsSolverOptions: + timeout: float | None = None + + +@dataclass +class CustomExactSolver: + solver: Callable[ + ["OddCycles", Optional[int], Optional[frozenset[int]]], + tuple[int, list[int]], + ] + + +@dataclass +class CustomBoundsSolver: + solver: Callable[ + ["OddCycles", Optional[int], Optional[frozenset[int]]], + tuple[int, int, list[int]], + ] + + +Solver = Union[ + Literal["enumeration", "mwpf", "highs"], + EnumerationSolverOptions, + MwpfSolverOptions, + HighsSolverOptions, + CustomExactSolver, + CustomBoundsSolver, +] +ExactSolver = Solver +BoundsSolver = Solver + + +def solver_options(solver: Solver) -> Solver: + if isinstance(solver, str): + if solver == "enumeration": + return EnumerationSolverOptions() + if solver == "mwpf": + return MwpfSolverOptions() + if solver == "highs": + return HighsSolverOptions() + raise ValueError("solver must be 'enumeration', 'mwpf', or 'highs'") + return solver + + +def solve_bounds( + data: "OddCycles", + upper_bound: Optional[int], + coset: Optional[frozenset[int]], + solver: Solver, +) -> tuple[int, int, list[int]]: + """Return certified bounds in reduced-column coordinates for every backend.""" + if isinstance(solver, EnumerationSolverOptions): + return enumeration_bounds(data, upper_bound, coset, solver) + if isinstance(solver, MwpfSolverOptions): + return mwpf_bounds(data, upper_bound, coset, solver) + if isinstance(solver, HighsSolverOptions): + return highs_bounds(data, upper_bound, coset, solver) + if isinstance(solver, CustomBoundsSolver): + return solver.solver(data, upper_bound, coset) + if isinstance(solver, CustomExactSolver): + size, cycle = solver.solver(data, upper_bound, coset) + return size, size, cycle + raise NotImplementedError(f"Unsupported solver {solver!r}") + + +def _is_logical(parity: frozenset[int], coset: Optional[frozenset[int]]) -> bool: + return bool(parity) if coset is None else len(parity & coset) % 2 == 1 + + +def _residual(matrix: list[frozenset[int]], columns: tuple[int, ...]) -> frozenset[int]: + return reduce(xor, (matrix[column] for column in columns), frozenset()) + + +def enumeration_bounds( + data: "OddCycles", + upper_bound: Optional[int], + coset: Optional[frozenset[int]], + options: EnumerationSolverOptions, +) -> tuple[int, int, list[int]]: + count = len(data.check_matrix) + cap = min( + value + for value in (count, upper_bound, options.size_upper_bound) + if value is not None + ) + for size in range(1, cap + 1): + for columns in combinations(range(count), size): + if _residual(data.check_matrix, columns): + continue + if _is_logical(_residual(data.parity_indicators, columns), coset): + return size, size, list(columns) + return cap + 1, count + 1, [] + + +def highs_bounds( + data: "OddCycles", + upper_bound: int | None, + coset: frozenset[int] | None, + options: HighsSolverOptions, +) -> tuple[int, int, list[int]]: + """Minimize unit-cost faults using parity constraints and integer slack.""" + if options.timeout is not None and ( + not math.isfinite(options.timeout) or options.timeout < 0 + ): + raise ValueError("HiGHS timeout must be finite and nonnegative") + try: + highs: Any = import_module("highspy") + except ModuleNotFoundError as exception: + if exception.name != "highspy": + raise + raise ImportError( + "HiGHS is required for this solver; install it with pip install 'qdk[ec]'" + ) from exception + count = len(data.check_matrix) + cap = count if upper_bound is None else min(count, upper_bound) + model = highs.Highs() + + def require_ok(status: Any) -> None: + if status != highs.HighsStatus.kOk: + raise RuntimeError(f"HiGHS rejected model configuration: {status}") + + require_ok(model.setOptionValue("output_flag", False)) + require_ok(model.setOptionValue("mip_rel_gap", 0.0)) + require_ok(model.setOptionValue("mip_abs_gap", 0.0)) + if options.timeout is not None: + require_ok(model.setOptionValue("time_limit", options.timeout)) + variables = [int(model.addBinary(obj=1.0)) for _ in range(count)] + checks: dict[int, list[int]] = {} + parities: dict[int, list[int]] = {} + for column, (check, indicator) in enumerate( + zip(data.check_matrix, data.parity_indicators, strict=True) + ): + for index in sorted(check): + checks.setdefault(index, []).append(variables[column]) + selected = ( + indicator + if coset is None + else (frozenset({0}) if len(indicator & coset) % 2 else frozenset()) + ) + for index in sorted(selected): + parities.setdefault(index, []).append(variables[column]) + if not parities: + return count + 1, count + 1, [] + changed = [] + for is_logical, rows in ((False, checks), (True, parities)): + for support in rows.values(): + slack = int(model.addIntegral(lb=0, ub=len(support) // 2)) + indices = support + [slack] + coefficients = [1.0] * len(support) + [-2.0] + if is_logical: + indicator = int(model.addBinary()) + changed.append(indicator) + indices.append(indicator) + coefficients.append(-1.0) + require_ok(model.addRow(0.0, 0.0, len(indices), indices, coefficients)) + require_ok( + model.addRow(1.0, highs.kHighsInf, len(changed), changed, [1.0] * len(changed)) + ) + if cap < count: + require_ok(model.addRow(0.0, float(cap), count, variables, [1.0] * count)) + if model.run() == highs.HighsStatus.kError: + raise RuntimeError("HiGHS failed to solve the distance model") + status = model.getModelStatus() + if status == highs.HighsModelStatus.kInfeasible: + return cap + 1, count + 1, [] + limited = ( + highs.HighsModelStatus.kTimeLimit, + highs.HighsModelStatus.kIterationLimit, + highs.HighsModelStatus.kSolutionLimit, + highs.HighsModelStatus.kInterrupt, + ) + if status != highs.HighsModelStatus.kOptimal and status not in limited: + raise RuntimeError( + f"HiGHS did not return usable distance bounds: {model.modelStatusToString(status)}" + ) + info = model.getInfo() + solution = model.getSolution() + if not info.valid or not solution.value_valid: + raise RuntimeError("HiGHS stopped without a feasible distance witness") + values = [float(solution.col_value[index]) for index in variables] + if any( + not math.isfinite(value) or min(abs(value), abs(value - 1)) > 1e-6 + for value in values + ): + raise RuntimeError("HiGHS returned a nonbinary distance witness") + cycle = [index for index, value in enumerate(values) if value > 0.5] + if ( + len(cycle) > cap + or _residual(data.check_matrix, tuple(cycle)) + or not _is_logical(_residual(data.parity_indicators, tuple(cycle)), coset) + ): + raise RuntimeError("HiGHS returned an invalid distance witness") + dual_bound = float(info.mip_dual_bound) + if not math.isfinite(dual_bound): + raise RuntimeError("HiGHS returned an invalid distance lower bound") + lower = max(1, math.ceil(dual_bound - 1e-6)) + if lower > len(cycle): + raise RuntimeError("HiGHS lower bound exceeds its witness weight") + if status == highs.HighsModelStatus.kOptimal and lower != len(cycle): + raise RuntimeError("HiGHS optimum has inconsistent distance bounds") + return lower, len(cycle), cycle + + +def _is_panic(exception: BaseException) -> bool: + return type(exception).__name__ == "PanicException" + + +def _mwpf_solver_class(name: str) -> Any: + mwpf: Any = import_module("mwpf") + + classes = { + "joint_single_hair": mwpf.SolverSerialJointSingleHair, + "single_hair": mwpf.SolverSerialSingleHair, + "union_find": mwpf.SolverSerialUnionFind, + } + if name not in classes: + raise ValueError( + f"Unknown mwpf solver {name!r}; expected one of {sorted(classes)}" + ) + return classes[name] + + +def _initializer( + checks: list[frozenset[int]], + parities: list[frozenset[int]], + observable: int, +) -> tuple[Any, int]: + mwpf: Any = import_module("mwpf") + + vertices: dict[int, int] = {} + edges = [] + for column, check_set in enumerate(checks): + edge = [vertices.setdefault(check, len(vertices)) for check in check_set] + edges.append((edge, observable in parities[column])) + boundary = len(vertices) + hyper_edges = [ + mwpf.HyperEdge(edge + [boundary] if touches else edge, 1.0) + for edge, touches in edges + ] + return mwpf.SolverInitializer(boundary + 1, hyper_edges), boundary + + +def _lower_bound(solver: Any) -> int: + try: + _, weight_range = solver.subgraph_range() + lower = float(weight_range.lower.float()) + except BaseException as exception: + if not _is_panic(exception): + raise + raise RuntimeError("MWPF could not certify a lower bound") from exception + if not math.isfinite(lower) or lower < 0: + raise RuntimeError("MWPF returned an invalid lower bound") + return max(1, math.ceil(lower - 1e-9)) + + +def _solve_observable( + checks: list[frozenset[int]], + parities: list[frozenset[int]], + observable: int, + options: MwpfSolverOptions, +) -> tuple[int, int, list[int]]: + mwpf: Any = import_module("mwpf") + + initializer, boundary = _initializer(checks, parities, observable) + solver = _mwpf_solver_class(options.solver)(initializer, options.config()) + try: + solver.solve(mwpf.SyndromePattern([boundary])) + subgraph = list(solver.subgraph()) + except BaseException as exception: + if not _is_panic(exception): + raise + raise RuntimeError( + f"MWPF failed for logical indicator {observable}" + ) from exception + columns = tuple(subgraph) + if ( + len(set(columns)) != len(columns) + or any(not 0 <= column < len(checks) for column in columns) + or _residual(checks, columns) + or observable not in _residual(parities, columns) + ): + raise RuntimeError( + f"MWPF returned an invalid witness for logical indicator {observable}" + ) + lower = _lower_bound(solver) + if lower > len(subgraph): + raise RuntimeError("MWPF lower bound exceeds its witness weight") + return lower, len(subgraph), subgraph + + +def mwpf_bounds( + data: "OddCycles", + upper_bound: Optional[int], + coset: Optional[frozenset[int]], + options: MwpfSolverOptions, +) -> tuple[int, int, list[int]]: + """Combine certified bounds, skipping only algebraically impossible searches.""" + del upper_bound + observables = ( + sorted(coset) + if coset is not None + else sorted( + { + observable + for indicator in data.parity_indicators + for observable in indicator + } + ) + ) + unreachable = len(data.check_matrix) + 1 + if not observables: + return unreachable, unreachable, [] + check_indices = { + index: position + for position, index in enumerate( + sorted({index for column in data.check_matrix for index in column}) + ) + } + transpose = BitMatrix.zeros(len(data.check_matrix), len(check_indices)) + for row, column in enumerate(data.check_matrix): + for index in column: + transpose[row, check_indices[index]] = True + undetectable = tuple(transpose.T.kernel().rows) + witnesses = [] + for observable in observables: + indicator = BitVector(observable in column for column in data.parity_indicators) + if not any(indicator.dot(cycle) for cycle in undetectable): + continue + witnesses.append( + _solve_observable( + data.check_matrix, data.parity_indicators, observable, options + ) + ) + if not witnesses: + return unreachable, unreachable, [] + lower = min(item[0] for item in witnesses) + best = min(witnesses, key=lambda item: item[1]) + return lower, best[1], best[2] + + +__all__ = [ + "BoundsSolver", + "CustomBoundsSolver", + "CustomExactSolver", + "ExactSolver", + "EnumerationSolverOptions", + "MwpfSolverOptions", +] diff --git a/source/qdk_package/qdk/ec/_analysis/equivalence.py b/source/qdk_package/qdk/ec/_analysis/equivalence.py new file mode 100644 index 00000000000..3ab62529b89 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/equivalence.py @@ -0,0 +1,52 @@ +"""Equivalence between qodec gadgets.""" + +from __future__ import annotations + +from typing import Iterable + +import qodec as qc + +from .channel_action import realized_action_of + +EncodingSignature = tuple[tuple[int, tuple[int, ...]], ...] + + +def gadgets_equivalent(left: qc.Gadget, right: qc.Gadget) -> bool: + return ( + _encoding_signature(left.inputs) == _encoding_signature(right.inputs) + and _encoding_signature(left.outputs) == _encoding_signature(right.outputs) + and realized_action_of(left).is_equivalent_to(realized_action_of(right)) + ) + + +def why_not_equivalent(left: qc.Gadget, right: qc.Gadget) -> str: + left_inputs = _encoding_signature(left.inputs) + right_inputs = _encoding_signature(right.inputs) + if left_inputs != right_inputs: + return f"Input encodings differ: {left_inputs!r} vs {right_inputs!r}." + left_outputs = _encoding_signature(left.outputs) + right_outputs = _encoding_signature(right.outputs) + if left_outputs != right_outputs: + return f"Output encodings differ: {left_outputs!r} vs {right_outputs!r}." + left_action = realized_action_of(left) + right_action = realized_action_of(right) + if left_action.is_equivalent_to(right_action): + return "" + if left_action.is_equivalent_to(right_action, modulo_paulis=True): + return "Logical actions differ in their outcome-dependent Pauli signs." + return "Logical actions differ." + + +def _encoding_signature( + encodings: Iterable[qc.gadgets.Encoding], +) -> EncodingSignature: + return tuple( + (entry, tuple(int(qubit) for qubit in encoding.support)) + for entry, encoding in enumerate(encodings) + ) + + +__all__ = [ + "gadgets_equivalent", + "why_not_equivalent", +] diff --git a/source/qdk_package/qdk/ec/_analysis/essential_checks.py b/source/qdk_package/qdk/ec/_analysis/essential_checks.py new file mode 100644 index 00000000000..51c849ac433 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/essential_checks.py @@ -0,0 +1,106 @@ +"""Identify checks independent of logical-input Pauli effects.""" + +from __future__ import annotations + +from binar import BitMatrix +import qodec as qc + +from .._references import outcomes_of, parse_equations +from .propagation.interpreter import propagate_input_paulis +from .propagation.pauli_remap import flat_logical_paulis + + +def outcomes_flipped_by_anti_observables_of( + gadget: qc.Gadget, +) -> list[frozenset[int]]: + input_paulis = flat_logical_paulis(gadget.inputs) + if not input_paulis: + return [] + deltas, hidden_count, outcome_count = propagate_input_paulis(gadget, input_paulis) + return [ + frozenset( + outcome + for outcome in range(outcome_count) + if deltas[hidden_count + outcome, shot] + ) + for shot in range(len(input_paulis)) + ] + + +def essential_checks_of( + gadget: qc.Gadget, + *, + checks: tuple[frozenset[int], ...] | None = None, +) -> tuple[frozenset[int], ...]: + checks_tuple = ( + tuple(frozenset(outcomes_of(check)) for check in parse_equations(gadget.checks)) + if checks is None + else tuple(frozenset(check) for check in checks) + ) + if not checks_tuple: + return () + flipped = outcomes_flipped_by_anti_observables_of(gadget) + if not flipped: + return checks_tuple + columns = sorted( + {outcome for check in checks_tuple for outcome in check} + | {outcome for pattern in flipped for outcome in pattern} + ) + if not columns: + return checks_tuple + column_index = {outcome: index for index, outcome in enumerate(columns)} + checks_matrix = _make_matrix(checks_tuple, column_index, len(columns)) + flipped_matrix = _make_matrix(flipped, column_index, len(columns)) + essential = _row_space_intersection(checks_matrix, flipped_matrix.kernel()) + return tuple( + frozenset(columns[index] for index in row.support) + for row in essential.rows + if row.weight > 0 + ) + + +def _make_matrix( + rows: list[frozenset[int]] | tuple[frozenset[int], ...], + column_index: dict[int, int], + width: int, +) -> BitMatrix: + matrix = BitMatrix.zeros(len(rows), width) + for row, items in enumerate(rows): + for item in items: + matrix[row, column_index[item]] = True + return matrix + + +def _row_space_intersection(left: BitMatrix, right: BitMatrix) -> BitMatrix: + if left.column_count != right.column_count: + raise ValueError("row spaces must have the same dimension") + width = left.column_count + left_rows = list(left.rows) + right_rows = list(right.rows) + if not left_rows or not right_rows: + return BitMatrix.zeros(0, width) + + stacked = BitMatrix([list(row) for row in (*left_rows, *right_rows)]) + dependencies = stacked.T.kernel() + candidates: list[list[bool]] = [] + for dependency in dependencies.rows: + candidate = [False] * width + for source in dependency.support: + if source >= len(left_rows): + continue + for column in left_rows[source].support: + candidate[column] = not candidate[column] + if any(candidate): + candidates.append(candidate) + + if not candidates: + return BitMatrix.zeros(0, width) + echelon = BitMatrix(candidates).echelonized() + basis = [list(row) for row in echelon.rows if row.weight > 0] + return BitMatrix(basis) if basis else BitMatrix.zeros(0, width) + + +__all__ = [ + "essential_checks_of", + "outcomes_flipped_by_anti_observables_of", +] diff --git a/source/qdk_package/qdk/ec/_analysis/odd_cycles.py b/source/qdk_package/qdk/ec/_analysis/odd_cycles.py new file mode 100644 index 00000000000..fc1551f8ae3 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/odd_cycles.py @@ -0,0 +1,160 @@ +"""Minimum odd-cycle engine used by code and gadget distance.""" + +from __future__ import annotations + +from itertools import combinations +from typing import Iterable, Iterator, Optional, Sequence, TypeVar + +from .distance_solvers import ( + HighsSolverOptions, + Solver, + solver_options, + solve_bounds, +) + +Label = TypeVar("Label") + + +def unique_non_empty_elements_of( + sets: Iterable[frozenset[int]], +) -> tuple[list[frozenset[int]], list[list[int]], list[int]]: + unique: list[frozenset[int]] = [] + ids: dict[frozenset[int], int] = {} + groups: list[list[int]] = [] + empty: list[int] = [] + for index, item in enumerate(sets): + if not item: + empty.append(index) + elif item in ids: + groups[ids[item]].append(index) + else: + ids[item] = len(unique) + unique.append(item) + groups.append([index]) + return unique, groups, empty + + +def cycle_labels(cycle: Iterable[int], labels: Sequence[Label]) -> list[Label]: + return [labels[index] for index in cycle] + + +class OddCycles: + def __init__( + self, + check_matrix: Sequence[frozenset[int]], + parity_indicators: Sequence[frozenset[int]], + unique_columns_ids: Optional[Sequence[int]] = None, + ) -> None: + if len(check_matrix) != len(parity_indicators): + raise ValueError("check and logical columns must have equal lengths") + self._source_checks = tuple(check_matrix) + self._source_parities = tuple(parity_indicators) + self._source_ids = ( + tuple(range(len(check_matrix))) + if unique_columns_ids is None + else tuple(unique_columns_ids) + ) + self.odd_cycle_length: Optional[int] = None + self.short_odd_cycle: Optional[list[int]] = None + self.short_odd_cycle_lower_bound = 3 + if unique_columns_ids is not None: + self.check_matrix = list(check_matrix) + self.parity_indicators = list(parity_indicators) + self.unique_columns_ids = list(unique_columns_ids) + return + unique, groups, empty = unique_non_empty_elements_of(check_matrix) + self.unique_columns_ids = [group[0] for group in groups] + self.check_matrix = unique + self.parity_indicators = [ + parity_indicators[index] for index in self.unique_columns_ids + ] + for index in empty: + if parity_indicators[index]: + self.odd_cycle_length = 1 + self.short_odd_cycle = [index] + self.short_odd_cycle_lower_bound = 1 + return + for group in groups: + base = parity_indicators[group[0]] + for other in group[1:]: + if parity_indicators[other] != base: + self.odd_cycle_length = 2 + self.short_odd_cycle = [group[0], other] + self.short_odd_cycle_lower_bound = 2 + return + + def witnesses( + self, size: int, coset_indicator: Optional[frozenset[int]] = None + ) -> Iterator[tuple[int, ...]]: + """Enumerate all selections at one cost in original factor coordinates.""" + for positions in combinations(range(len(self._source_checks)), size): + checks: frozenset[int] = frozenset() + parities: frozenset[int] = frozenset() + for position in positions: + checks ^= self._source_checks[position] + parities ^= self._source_parities[position] + logical = ( + bool(parities) + if coset_indicator is None + else bool(len(parities & coset_indicator) % 2) + ) + if not checks and logical: + yield tuple(self._source_ids[position] for position in positions) + + def shortest( + self, + solver: Solver, + coset_indicator: Optional[frozenset[int]] = None, + cycle_size_upper_bound: Optional[int] = None, + ) -> tuple[int, list[int]]: + lower, upper, cycle = self.bounds( + cycle_size_upper_bound, coset_indicator, solver + ) + if lower != upper: + raise RuntimeError( + f"solver did not prove exact distance: bounds are {lower} and {upper}" + ) + return upper, cycle + + def bounds( + self, + odd_cycle_length_upper_bound: Optional[int] = None, + coset_indicator: Optional[frozenset[int]] = None, + solver: Optional[Solver] = None, + ) -> tuple[int, int, list[int]]: + solver = solver_options(HighsSolverOptions() if solver is None else solver) + if ( + odd_cycle_length_upper_bound is not None + and odd_cycle_length_upper_bound < 0 + ): + raise ValueError("upper_bound must be nonnegative") + if coset_indicator is not None: + projected = OddCycles( + self._source_checks, + [ + ( + frozenset({0}) + if len(indicator & coset_indicator) % 2 + else frozenset() + ) + for indicator in self._source_parities + ], + ) + lower, upper, cycle = projected.bounds( + odd_cycle_length_upper_bound, solver=solver + ) + return lower, upper, cycle_labels(cycle, self._source_ids) + if self.odd_cycle_length is not None: + assert self.short_odd_cycle is not None + return ( + self.odd_cycle_length, + self.odd_cycle_length, + self.short_odd_cycle, + ) + lower, upper, cycle = solve_bounds( + self, odd_cycle_length_upper_bound, coset_indicator, solver + ) + return lower, upper, cycle_labels(cycle, self.unique_columns_ids) + + +__all__ = ["OddCycles", "cycle_labels", "unique_non_empty_elements_of"] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/__init__.py b/source/qdk_package/qdk/ec/_analysis/propagation/__init__.py new file mode 100644 index 00000000000..6b6d735c158 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/__init__.py @@ -0,0 +1 @@ +"""Exact, noiseless semantic propagation over qodec programs.""" diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/conditional.py b/source/qdk_package/qdk/ec/_analysis/propagation/conditional.py new file mode 100644 index 00000000000..fd017462bf0 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/conditional.py @@ -0,0 +1,69 @@ +"""Choi-prepared exact propagation with outcome-conditioned frames.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from paulimer import OutcomeCompleteSimulation +from qodec.gadgets import Circuit + +from ..._layout import ProgramLayout +from .frames import FrameGroup +from .pauli import Pauli +from .stabilizer import frame_group_of + + +@dataclass(frozen=True) +class ConditionalChoiResult: + group: FrameGroup + simulation: OutcomeCompleteSimulation + projector_outcome_rows: tuple[int, ...] + observe_outcome_rows: tuple[int, ...] + aux_origin: int + + +def conditional_choi_state( + program: Circuit, + *, + input_qubits: Sequence[int], + codespace_projector: Sequence[Pauli] = (), + aux_origin: int | None = None, +) -> ConditionalChoiResult: + from ..._analysis.check_discovery import simulate_program + + relevant_qubits: set[int] = set(range(ProgramLayout.of(program).total_qubits)) + relevant_qubits.update(input_qubits) + for stabilizer in codespace_projector: + relevant_qubits.update(stabilizer.support) + if aux_origin is None: + aux_origin = max(relevant_qubits) + 1 if relevant_qubits else 0 + + total_qubits = aux_origin + len(input_qubits) + simulation = OutcomeCompleteSimulation.with_capacity(total_qubits, 100, 64) + simulation.reserve_qubits(total_qubits) + simulation.reserve_outcomes(100, 64) + + for offset, qubit in enumerate(input_qubits): + auxiliary = aux_origin + offset + # Measuring XX then ZZ is a Bell preparation with random signs: the pair + # ends up in one of the four Bell states, and the frames carry which. + simulation.measure(Pauli({qubit: "X", auxiliary: "X"})) + simulation.measure(Pauli({qubit: "Z", auxiliary: "Z"})) + + projector_rows = [] + for stabilizer in codespace_projector: + projector_rows.append(simulation.outcome_count) + simulation.measure(stabilizer) + + walk = simulate_program(program, simulation=simulation) + return ConditionalChoiResult( + group=frame_group_of(simulation), + simulation=simulation, + projector_outcome_rows=tuple(projector_rows), + observe_outcome_rows=tuple(walk.observe_outcomes), + aux_origin=aux_origin, + ) + + +__all__ = ["ConditionalChoiResult", "conditional_choi_state"] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/frames.py b/source/qdk_package/qdk/ec/_analysis/propagation/frames.py new file mode 100644 index 00000000000..241be660698 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/frames.py @@ -0,0 +1,190 @@ +"""Outcome-conditioned Pauli frame result types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Iterable, Mapping, Sequence + +from paulimer import PauliGroup + +from .groups import rank_extension_of, restriction_indicator_basis_of +from .pauli import ( + Pauli, + complex_conjugate_of, + identity, + relabel, + restrict, +) + + +@dataclass(frozen=True, repr=False) +class PauliFrame: + """A Pauli and the measurement outcomes that condition its sign.""" + + pauli: Pauli + frame: frozenset[int] = frozenset() + + def __mul__(self, other: object) -> "PauliFrame": + if isinstance(other, PauliFrame): + return PauliFrame(self.pauli * other.pauli, self.frame ^ other.frame) + if isinstance(other, Pauli): + return PauliFrame(self.pauli * other, self.frame) + if isinstance(other, (int, float, complex)): + return PauliFrame(self.pauli * identity(other), self.frame) + return NotImplemented + + def __abs__(self) -> "PauliFrame": + return PauliFrame(abs(self.pauli), self.frame) + + def __str__(self) -> str: + if not self.frame: + return str(self.pauli) + outcomes = ",".join(str(index) for index in sorted(self.frame)) + return f"{self.pauli}^{{{outcomes}}}" + + def __repr__(self) -> str: + return self.__str__() + + +@dataclass(frozen=True) +class FrameGroup: + """An ordered set of frame-aware Pauli generators.""" + + generators: tuple[PauliFrame, ...] + + def __init__(self, generators: Iterable[PauliFrame]) -> None: + object.__setattr__(self, "generators", tuple(generators)) + + @property + def unframed(self) -> PauliGroup: + return PauliGroup([framed.pauli for framed in self.generators]) + + def __or__(self, other: "FrameGroup") -> "FrameGroup": + return FrameGroup(self.generators + other.generators) + + def _element(self, indicator: Sequence[int]) -> PauliFrame: + element = PauliFrame(Pauli.identity()) + for bit, framed in zip(indicator, self.generators): + if bit: + element = element * framed + return element + + def subgroup(self, indicators: Iterable[Sequence[int]]) -> "FrameGroup": + return FrameGroup(self._element(indicator) for indicator in indicators) + + def partition( + self, *, over: Iterable[int] + ) -> tuple["FrameGroup", "FrameGroup", "FrameGroup"]: + operators = self.unframed + over_set = set(over) + support = set(operators.support) + primary = list(restriction_indicator_basis_of(operators, supported_by=over_set)) + complementary = list( + restriction_indicator_basis_of(operators, supported_by=support - over_set) + ) + identity_indicator = [0] * len(self.generators) + extension = rank_extension_of(primary + complementary + [identity_indicator]) + return ( + self.subgroup(primary), + self.subgroup(complementary), + self.subgroup(extension), + ) + + def standardized(self) -> "FrameGroup": + return _carry_frames( + self.generators, + lambda tagged: PauliGroup(tagged).standard_generators, + ) + + def __mod__(self, modulus: "FrameGroup") -> "FrameGroup": + combined = self.generators + modulus.generators + offset = len(self.generators) + + def reduce(tagged: list[Pauli]) -> Sequence[Pauli]: + left = PauliGroup(tagged[:offset]) + right = PauliGroup(tagged[offset:]) + return (left % right).generators + + return _carry_frames(combined, reduce) + + def relabel(self, mapping: Mapping[int, int]) -> "FrameGroup": + return FrameGroup( + PauliFrame(relabel(framed.pauli, mapping), framed.frame) + for framed in self.generators + ) + + def restrict_to(self, support: Iterable[int]) -> "FrameGroup": + support_set = frozenset(support) + return FrameGroup( + PauliFrame(restrict(framed.pauli, support_set), framed.frame) + for framed in self.generators + ) + + def complex_conjugated(self) -> "FrameGroup": + return FrameGroup( + PauliFrame(complex_conjugate_of(framed.pauli), framed.frame) + for framed in self.generators + ) + + def factorization_of(self, target: Pauli) -> list[PauliFrame] | None: + factors = self.unframed.factorization_of(target) + if factors is None: + return None + frame_of = {abs(framed.pauli): framed.frame for framed in self.generators} + # A weightless factor is the factorization's overall phase, which no + # generator carries and which flips no outcome. + missing = [ + factor + for factor in factors + if factor.weight and abs(factor) not in frame_of + ] + if missing: + raise ValueError( + f"factor {missing[0]!r} of {target!r} is not a generator of this " + "group, so it carries no frame" + ) + return [ + PauliFrame(factor, frame_of.get(abs(factor), frozenset())) + for factor in factors + ] + + def frame_of(self, target: Pauli) -> frozenset[int]: + factors = self.factorization_of(target) + if factors is None: + raise ValueError(f"{target!r} is not in this group") + frame: frozenset[int] = frozenset() + for factored in factors: + frame ^= factored.frame + return frame + + +def _carry_frames( + framed: Sequence[PauliFrame], + transform: Callable[[list[Pauli]], Sequence[Pauli]], +) -> FrameGroup: + operators = [item.pauli for item in framed] + base = ( + max( + (qubit for operator in operators for qubit in operator.support), + default=-1, + ) + + 1 + ) + tagged = [ + operator * Pauli({base + index: "Z"}) + for index, operator in enumerate(operators) + ] + recovered: list[PauliFrame] = [] + for result in transform(tagged): + sources = [qubit - base for qubit in result.support if qubit >= base] + clean = Pauli( + {qubit: result[qubit] for qubit in result.support if qubit < base} + ) * identity(result.phase) + frame: frozenset[int] = frozenset() + for index in sources: + frame ^= framed[index].frame + recovered.append(PauliFrame(clean, frame)) + return FrameGroup(recovered) + + +__all__ = ["FrameGroup", "PauliFrame"] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/groups.py b/source/qdk_package/qdk/ec/_analysis/propagation/groups.py new file mode 100644 index 00000000000..9652a3bcbaf --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/groups.py @@ -0,0 +1,60 @@ +"""Stabilizer-group helpers for exact propagation.""" + +from __future__ import annotations + +from typing import Iterable, Sequence + +import binar +from more_itertools import flatten +from paulimer import PauliGroup + +from .pauli import Pauli + + +def is_stabilizer_group(group: PauliGroup) -> bool: + return group.is_abelian and 2 not in group.phases + + +def restriction_indicator_basis_of( + group: PauliGroup, *, supported_by: Iterable[int] +) -> Iterable[Sequence[int]]: + if len(group.generators) == 0: + return [] + + bitmap = { + "I": (False, False), + "X": (True, False), + "Y": (True, True), + "Z": (False, True), + } + complemented_by = set(group.support) - set(supported_by) + + def to_bits(pauli: Pauli) -> list[bool]: + return list(flatten(bitmap[pauli[index]] for index in complemented_by)) + + def to_indicator(bits: binar.BitVector) -> list[int]: + return list(map(int, bits)) + + complement_generators = binar.BitMatrix(list(map(to_bits, group.generators))) + nullspace = binar.null_space(complement_generators.T) + return map(to_indicator, (row for row in nullspace.rows if row.weight > 0)) + + +def rank_extension_of(rows: Sequence[Sequence[int]]) -> Sequence[Sequence[int]]: + if len(rows) == 0: + return rows + binary_rows = binar.BitMatrix(rows) # type: ignore[arg-type] + pivots = binary_rows.echelonize() + row_length = binary_rows.column_count + extension_columns = set(range(row_length)) - set(pivots) + extension = [[0] * row_length for _ in range(len(extension_columns))] + for row, column in zip(extension, extension_columns): + row[column] = 1 + return extension + + +__all__ = [ + "is_stabilizer_group", + "rank_extension_of", + "restriction_indicator_basis_of", +] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/interpreter.py b/source/qdk_package/qdk/ec/_analysis/propagation/interpreter.py new file mode 100644 index 00000000000..441f36c197c --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/interpreter.py @@ -0,0 +1,440 @@ +"""Canonical exact walker over qodec program instructions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Callable, + Mapping, + Protocol, + Sequence, + runtime_checkable, +) + +from binar import BitMatrix +import qodec as qc +from paulimer import CliffordUnitary, OutcomeCompleteSimulation +from qodec.actions import ( + Clifford as CliffordAction, + Observe, + Pauli as PauliAction, + Stabilize, +) +from qodec.gadgets import Circuit + +from ..._layout import ProgramLayout +from ..._readouts import observe_count_of +from ..._references import Outcome, reference_term +from .isa_actions import ( + build_clifford_images, + remap_pauli, +) +from .pauli import Pauli, PauliCharacter, characters_of + +if TYPE_CHECKING: + from ..._faults import FaultEvent + + +@runtime_checkable +class PropagationEngine(Protocol): + """What :func:`walk_program` requires of an ``extra_engines`` entry. + + An engine is driven alongside the primary simulation: the walker replays + every Clifford, Pauli, conditional Pauli, and measurement onto it, so the + engine can accumulate whatever view of the program it cares about (a Pauli + frame per fault, a stabilizer tableau, a record of outcomes, ...). + """ + + def apply_pauli(self, pauli: Pauli) -> None: ... + + def apply_conditional_pauli( + self, + pauli: Pauli, + outcomes: Sequence[int], + parity: bool = True, + ) -> None: ... + + def apply_clifford( + self, clifford: CliffordUnitary, qubits: Sequence[int] + ) -> None: ... + + def measure(self, observable: Pauli) -> int: ... + + +class _FramePropagator: + """Propagate one relative Pauli frame per fault-basis element.""" + + def __init__(self, shot_count: int) -> None: + self._frames = [Pauli.identity() for _ in range(shot_count)] + self._outcomes: list[list[bool]] = [] + + def apply_pauli_to_shot(self, shot: int, pauli: Pauli) -> None: + self._frames[shot] = abs(pauli * self._frames[shot]) + + def apply_pauli(self, pauli: Pauli) -> None: + del pauli + + def apply_conditional_pauli( + self, + pauli: Pauli, + outcomes: Sequence[int], + parity: bool = True, + ) -> None: + del parity + for shot, frame in enumerate(self._frames): + condition = sum(self._outcomes[index][shot] for index in outcomes) % 2 + if condition: + self._frames[shot] = abs(pauli * frame) + + def apply_clifford( + self, + clifford: CliffordUnitary, + qubits: Sequence[int], + ) -> None: + local_index = {qubit: index for index, qubit in enumerate(qubits)} + support = set(qubits) + evolved = [] + for frame in self._frames: + characters = characters_of(frame) + local = Pauli( + { + local_index[qubit]: character + for qubit, character in characters.items() + if qubit in support + } + ) + image = Pauli.from_dense(clifford.image_of(local)) + remapped: dict[int, PauliCharacter] = { + qubits[qubit]: character + for qubit, character in characters_of(image).items() + } + remapped.update( + { + qubit: character + for qubit, character in characters.items() + if qubit not in support + } + ) + evolved.append(Pauli(remapped)) + self._frames = evolved + + def measure(self, observable: Pauli) -> int: + outcome = [not frame.commutes_with(observable) for frame in self._frames] + self._outcomes.append(outcome) + return len(self._outcomes) - 1 + + @property + def outcome_deltas(self) -> BitMatrix: + return BitMatrix(self._outcomes) + + +@dataclass +class WalkResult: + simulation: OutcomeCompleteSimulation + hidden_count: int + outcome_count: int + output_stab_count: int = 0 + observe_outcomes: tuple[int, ...] = () + + +def _eigenstate_correction(observable: Pauli) -> Pauli: + qubit = observable.support[0] + correction = Pauli.z(qubit) + if observable.commutes_with(correction): + correction = Pauli.x(qubit) + return correction + + +def _condition_indices( + condition: qc.actions.Condition, arguments: Mapping[str, object], record_size: int +) -> tuple[list[int], bool]: + indices = [] + parity = not condition.invert + for predicate in condition.predicates: + value = arguments.get(predicate, predicate) + if isinstance(value, (bool, int)) and value in (0, 1): + parity ^= bool(value) + elif isinstance(value, str): + term = reference_term(value) + if not isinstance(term, Outcome) or term.index >= record_size: + raise ValueError( + f"condition {predicate!r} must reference a preceding circuit readout" + ) + indices.append(term.index) + else: + raise ValueError(f"condition {predicate!r} has no bit argument") + return indices, parity + + +def _apply_guarded_pauli( + engine: PropagationEngine | OutcomeCompleteSimulation, + pauli: Pauli, + indices: Sequence[int], + parity: bool, + rows: Sequence[int | None], +) -> None: + if indices: + selected = [rows[index] for index in indices] + if any(row is None for row in selected): + raise NotImplementedError( + "conditions on circuit flag bits are not simulated" + ) + engine.apply_conditional_pauli( + pauli, [row for row in selected if row is not None], parity + ) + elif not parity: + engine.apply_pauli(pauli) + + +def walk_program( + program: Circuit, + *, + simulation: OutcomeCompleteSimulation | None = None, + extra_engines: Sequence[PropagationEngine] = (), + input_stabilizers: Sequence[Pauli] = (), + output_stabilizers: Sequence[Pauli] = (), + on_instruction: Callable[[int], None] | None = None, +) -> WalkResult: + if simulation is None: + qubit_count = ProgramLayout.of(program).total_qubits + oracle = OutcomeCompleteSimulation.with_capacity(qubit_count, 100, 50) + oracle.reserve_qubits(qubit_count) + oracle.reserve_outcomes(50, 50) + else: + oracle = simulation + + hidden_count = 0 + for stabilizer in input_stabilizers: + oracle.measure(stabilizer) + for engine in extra_engines: + engine.measure(stabilizer) + hidden_count += 1 + + outcome_count = 0 + observe_rows: list[int] = [] + record_rows: list[int | None] = [] + engine_record_rows: list[list[int | None]] = [[] for _ in extra_engines] + layout = ProgramLayout.of(program) + for instruction_index, call in enumerate(program.calls()): + instruction = program.instruction_set.instructions[call.mnemonic] + qubit_map = layout.call_qubit_map(call) + + for action in instruction.action: + if isinstance(action, Stabilize): + for pauli_str in action.operators: + remapped = remap_pauli(pauli_str, qubit_map) + if not remapped.weight: + continue + correction = _eigenstate_correction(remapped) + outcome = oracle.measure(remapped) + oracle.apply_conditional_pauli(correction, [outcome]) + for engine in extra_engines: + engine_outcome = engine.measure(remapped) + engine.apply_conditional_pauli(correction, [engine_outcome]) + hidden_count += 1 + elif isinstance(action, CliffordAction): + qubits = sorted(set(qubit_map.values())) + local_map = {qubit: index for index, qubit in enumerate(qubits)} + images = build_clifford_images( + action.generators, + qubit_map, + local_map, + len(qubits), + ) + clifford = CliffordUnitary.from_images(images) + oracle.apply_clifford(clifford, qubits) + for engine in extra_engines: + engine.apply_clifford(clifford, qubits) + elif isinstance(action, PauliAction): + remapped = remap_pauli(action.operator, qubit_map) + if action.condition is None: + oracle.apply_pauli(remapped) + for engine in extra_engines: + engine.apply_pauli(remapped) + else: + indices, parity = _condition_indices( + action.condition, call.arguments, len(record_rows) + ) + _apply_guarded_pauli(oracle, remapped, indices, parity, record_rows) + for engine, rows in zip(extra_engines, engine_record_rows): + _apply_guarded_pauli(engine, remapped, indices, parity, rows) + elif isinstance(action, Observe): + for observable in action.observables: + remapped = remap_pauli(observable, qubit_map) + observe_rows.append(oracle.outcome_count) + record_rows.append(oracle.outcome_count) + oracle.measure(remapped) + for engine, rows in zip(extra_engines, engine_record_rows): + rows.append(engine.measure(remapped)) + outcome_count += 1 + else: + raise TypeError( + f"unrecognized action type {type(action).__name__!r} " + f"in instruction {call.mnemonic!r}" + ) + + record_rows.extend([None] * len(instruction.flags)) + for rows in engine_record_rows: + rows.extend([None] * len(instruction.flags)) + if on_instruction is not None: + on_instruction(instruction_index) + + output_count = 0 + for stabilizer in output_stabilizers: + oracle.measure(stabilizer) + for engine in extra_engines: + engine.measure(stabilizer) + output_count += 1 + + return WalkResult( + simulation=oracle, + hidden_count=hidden_count, + outcome_count=outcome_count, + output_stab_count=output_count, + observe_outcomes=tuple(observe_rows), + ) + + +def walk_for_outcome_code( + program: Circuit, + input_stabilizers: Sequence[Pauli] = (), + output_stabilizers: Sequence[Pauli] = (), +) -> WalkResult: + return walk_program( + program, + input_stabilizers=input_stabilizers, + output_stabilizers=output_stabilizers, + ) + + +def propagate_faults( + program: Circuit, + fault_basis: Sequence[FaultEvent], + residual_probes: Sequence[Pauli], + *, + residual_frames: Sequence[frozenset[int]] | None = None, +) -> tuple[BitMatrix, int, int]: + """Propagate quantum and recorded-bit errors in one batch. + + Readout flips change only Observe rows, not hidden reset outcomes or + physical residuals. Signed probes use those changed rows when evaluating + their circuit-readout frames. + """ + calls = program.calls() + readout_ranges = [] + readout_offset = 0 + for call in calls: + instruction = program.instruction_set.instructions[call.mnemonic] + if ( + call.select + or any( + getattr(action, "condition", None) is not None + for action in instruction.action + ) + ): + raise NotImplementedError( + "conditional or selected circuits are not supported by fault propagation" + ) + if instruction.flags: + raise NotImplementedError( + "circuit instruction flags are not supported by fault propagation" + ) + readout_count = observe_count_of(instruction) + readout_ranges.append(range(readout_offset, readout_offset + readout_count)) + readout_offset += readout_count + propagator = _FramePropagator(len(fault_basis)) + injections: dict[int, list[tuple[int, Pauli]]] = {} + readout_injections: list[tuple[int, int]] = [] + for fault_index, fault in enumerate(fault_basis): + for location in fault.locations: + instruction_index = location.after_call + pauli, readouts = location.error, location.readout_flips + if not 0 <= instruction_index < len(calls): + raise ValueError( + f"fault call index {instruction_index} is out of bounds for {len(calls)} calls" + ) + if pauli.weight: + injections.setdefault(instruction_index, []).append( + (fault_index, pauli) + ) + call_readouts = readout_ranges[instruction_index] + for readout in readouts: + if not 0 <= readout < len(call_readouts): + raise ValueError( + f"fault readout index {readout} is out of bounds for call {instruction_index} " + f"with {len(call_readouts)} readouts" + ) + readout_injections.append((fault_index, call_readouts[readout])) + + def inject_at(instruction_index: int) -> None: + for shot_index, pauli in injections.get(instruction_index, ()): + propagator.apply_pauli_to_shot(shot_index, pauli) + + result = walk_program( + program, + extra_engines=[propagator], + on_instruction=inject_at, + ) + for probe in residual_probes: + propagator.measure(probe) + deltas = propagator.outcome_deltas + for fault_index, readout in readout_injections: + deltas[result.observe_outcomes[readout], fault_index] ^= True + observed = set(result.observe_outcomes) + circuit_rows = result.hidden_count + result.outcome_count + if residual_frames is not None: + for probe, frame in zip( + range(len(residual_probes)), residual_frames, strict=True + ): + for readout in frame: + if not 0 <= readout < len(result.observe_outcomes): + raise ValueError(f"probe readout index {readout} is out of bounds") + outcome = result.observe_outcomes[readout] + for fault in range(len(fault_basis)): + deltas[circuit_rows + probe, fault] ^= deltas[outcome, fault] + row_order = [ + *(row for row in range(circuit_rows) if row not in observed), + *result.observe_outcomes, + *range(circuit_rows, circuit_rows + len(residual_probes)), + ] + if row_order != list(range(len(row_order))): + deltas = BitMatrix( + [ + [bool(deltas[row, fault]) for fault in range(len(fault_basis))] + for row in row_order + ] + ) + return deltas, result.hidden_count, result.outcome_count + + +def program_of(gadget: qc.Gadget) -> Circuit: + """The gadget's source-backed circuit.""" + return gadget.circuit + + +def propagate_input_paulis( + gadget: qc.Gadget, + paulis: Sequence[Pauli], + *, + residual_probes: Sequence[Pauli] = (), +) -> tuple[BitMatrix, int, int]: + program = program_of(gadget) + propagator = _FramePropagator(len(paulis)) + for shot_index, pauli in enumerate(paulis): + propagator.apply_pauli_to_shot(shot_index, pauli) + result = walk_program(program, extra_engines=[propagator]) + for probe in residual_probes: + propagator.measure(probe) + return propagator.outcome_deltas, result.hidden_count, result.outcome_count + + +__all__ = [ + "PropagationEngine", + "WalkResult", + "program_of", + "propagate_faults", + "propagate_input_paulis", + "walk_for_outcome_code", + "walk_program", +] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/isa_actions.py b/source/qdk_package/qdk/ec/_analysis/propagation/isa_actions.py new file mode 100644 index 00000000000..46b8eccfe6b --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/isa_actions.py @@ -0,0 +1,63 @@ +"""Remap ISA action operators onto a program's concrete qubits.""" + +from __future__ import annotations + +from typing import Mapping, TypeVar + +from paulimer import DensePauli + +from .pauli import Pauli, parse_term, relabel + +_PauliKey = TypeVar("_PauliKey", bound=str) + + +def remap_pauli(pauli_str: str, qubit_map: Mapping[int, int]) -> Pauli: + """The Pauli ``pauli_str`` names, each term placed through ``qubit_map``.""" + operator = Pauli(pauli_str) + placement = {qubit: qubit_map[qubit] for qubit in operator.support} + return relabel(operator, placement) + + +def build_clifford_images( + generators: Mapping[_PauliKey, str], + qubit_map: dict[int, int], + local_map: dict[int, int], + qubit_count: int, +) -> list[DensePauli]: + placement = {index: local_map[qubit] for index, qubit in qubit_map.items()} + images: dict[tuple[str, int], DensePauli] = {} + for lhs, rhs in generators.items(): + lhs_basis, lhs_index = parse_term(lhs.strip()) + if lhs_basis not in ("X", "Z"): + raise NotImplementedError( + f"Clifford key {lhs!r} is not an X or Z generator." + ) + key = (lhs_basis, placement[lhs_index]) + image = DensePauli.from_sparse(remap_pauli(rhs.strip(), placement), qubit_count) + if key in images and images[key] != image: + raise ValueError(f"Clifford generator {lhs!r} has conflicting images.") + images[key] = image + + result = [] + for qubit in range(qubit_count): + for basis in ("X", "Z"): + result.append( + images.get( + (basis, qubit), + DensePauli.from_sparse(Pauli({qubit: basis}), qubit_count), + ) + ) + for first, first_image in enumerate(result): + for second in range(first + 1, len(result)): + second_image = result[second] + should_commute = first // 2 != second // 2 + if first_image.commutes_with(second_image) != should_commute: + first_label = f"{'X' if first % 2 == 0 else 'Z'}_{first // 2}" + second_label = f"{'X' if second % 2 == 0 else 'Z'}_{second // 2}" + expected = "commute" if should_commute else "anticommute" + raise ValueError( + f"Images of {first_label} and {second_label} must {expected}: " + f"{first_label} -> {first_image}, {second_label} -> {second_image}. " + "Unlisted generators have identity images." + ) + return result diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/pauli.py b/source/qdk_package/qdk/ec/_analysis/propagation/pauli.py new file mode 100644 index 00000000000..9cda2419556 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/pauli.py @@ -0,0 +1,101 @@ +"""Pauli conveniences used by exact propagation and qodec profiling.""" + +from __future__ import annotations + +from typing import ( + Container, + Final, + Iterator, + Literal, + Mapping, + cast, + get_args, +) + +from paulimer import SparsePauli + +Pauli = SparsePauli +PauliCharacter = Literal["I", "X", "Y", "Z"] +pauli_characters: Final[frozenset[str]] = frozenset(get_args(PauliCharacter)) + +_PHASE_TO_EXPONENT: dict[complex, int] = { + 1 + 0j: 0, + 0 + 1j: 1, + -1 + 0j: 2, + 0 - 1j: 3, +} + + +def identity(phase: complex = 1) -> Pauli: + """Return the identity Pauli with an optional unit scalar phase.""" + try: + exponent = _PHASE_TO_EXPONENT[complex(phase)] + except KeyError as error: + raise ValueError(f"Unsupported phase: {phase!r}") from error + return SparsePauli({}, exponent=exponent) + + +def characters_of(pauli: Pauli) -> dict[int, PauliCharacter]: + """Return non-identity characters keyed by qubit index.""" + return { + qubit: cast(PauliCharacter, character) + for qubit, character in zip(pauli.support, pauli.characters) + } + + +def relabel(pauli: Pauli, mapping: Mapping[int, int]) -> Pauli: + """Return ``pauli`` with its qubits renamed, keeping its phase. + + Qubits absent from ``mapping`` keep their label. + """ + return Pauli( + {mapping.get(qubit, qubit): pauli[qubit] for qubit in pauli.support} + ) * identity(pauli.phase) + + +def restrict(pauli: Pauli, support: Container[int]) -> Pauli: + """Return the part of ``pauli`` acting on ``support``, keeping its phase.""" + return Pauli( + {qubit: pauli[qubit] for qubit in pauli.support if qubit in support} + ) * identity(pauli.phase) + + +def complex_conjugate_of(pauli: Pauli) -> Pauli: + """Return the complex conjugate of ``pauli``: a sign flip per ``Y``.""" + y_count = sum(character == "Y" for character in characters_of(pauli).values()) + return pauli * identity((-1) ** (y_count % 2)) + + +def as_literal(character: str) -> PauliCharacter: + if character not in pauli_characters: + raise ValueError(f"Invalid Pauli character: {character}") + return cast(PauliCharacter, character) + + +def as_literals(string: str) -> Iterator[PauliCharacter]: + yield from map(as_literal, string) + + +def parse_term(token: str) -> tuple[PauliCharacter, int]: + """Split one ``"_"`` operator token; a bare letter is qubit 0.""" + basis, _, index = token.partition("_") + return as_literal(basis), int(index) if index else 0 + + +def characters_of_string(text: str) -> dict[int, PauliCharacter]: + """Parse a ``"X_0 Z_2"`` operator string into ``{qubit: character}``.""" + characters: dict[int, PauliCharacter] = {} + for token in text.split(): + basis, index = parse_term(token) + characters[index] = basis + return characters + + +__all__ = [ + "Pauli", + "PauliCharacter", + "as_literal", + "as_literals", + "characters_of", + "identity", +] diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/pauli_remap.py b/source/qdk_package/qdk/ec/_analysis/propagation/pauli_remap.py new file mode 100644 index 00000000000..9fdef23ce26 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/pauli_remap.py @@ -0,0 +1,181 @@ +"""Remap encoded logical Paulis onto physical program qubits.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping, Sequence +from typing import Literal, TYPE_CHECKING + +import qodec as qc + +from .pauli import Pauli, characters_of_string, parse_term + +if TYPE_CHECKING: + from paulimer import PauliCharacter + +#: Which of a code's two logical operator lists to read. +Basis = Literal["X", "Z"] + + +def encoding_relocation(support: Sequence[int], num_code_qubits: int) -> dict[int, int]: + num_blocks = len(support) + if num_blocks == 0: + return {} + block_size, remainder = divmod(num_code_qubits, num_blocks) + if remainder != 0: + raise ValueError( + f"code qubit count {num_code_qubits} is not divisible by its " + f"{num_blocks} support blocks" + ) + operand_footprint: dict[int, int] = {} + for operand in support: + operand_footprint[operand] = operand_footprint.get(operand, 0) + block_size + relocation: dict[int, int] = {} + placed_in_operand: dict[int, int] = {} + for block_index, operand in enumerate(support): + placed = placed_in_operand.get(operand, 0) + base = operand * operand_footprint[operand] + for offset in range(block_size): + code_qubit = block_index * block_size + offset + relocation[code_qubit] = base + placed * block_size + offset + placed_in_operand[operand] = placed + 1 + return relocation + + +def code_qubit_count(code: qc.Code) -> int: + """One past the highest qubit index any of the code's operators mentions.""" + highest = -1 + for characters in _all_operator_chars(code): + if characters: + highest = max(highest, max(characters)) + return highest + 1 + + +def encoding_qubit_relocation(encoding: qc.gadgets.Encoding) -> dict[int, int]: + support = [int(qubit) for qubit in encoding.support] + return encoding_relocation(support, code_qubit_count(encoding.code)) + + +def remap_to_global( + characters: dict[int, "PauliCharacter"], + relocation: Mapping[int, int], +) -> Pauli: + return Pauli( + {relocation[index]: character for index, character in characters.items()} + ) + + +def flat_logical_paulis(encodings: Iterable[qc.gadgets.Encoding]) -> list[Pauli]: + paulis = [] + for encoding in encodings: + relocation = encoding_qubit_relocation(encoding) + for characters in _flat_logical_chars(encoding.code): + paulis.append(remap_to_global(characters, relocation)) + return paulis + + +def flat_logical_slots( + encodings: Iterable[qc.gadgets.Encoding], +) -> list[tuple[qc.gadgets.Encoding, int]]: + """``(encoding, local logical index)`` per logical qubit, in flat order. + + An action token ``X_`` names the ``t``-th entry of this list, so this is + how a flat token index resolves to the encoding that carries it. + """ + return [ + (encoding, local) + for encoding in encodings + for local in range(len(list(encoding.code.x))) + ] + + +def logical_chars(code: qc.Code, basis: Basis) -> list[dict[int, "PauliCharacter"]]: + """Characters of the code's logical operators in one basis, in order.""" + operators = code.x if basis == "X" else code.z + return [characters_of_string(str(operator)) for operator in operators] + + +def declared_pauli_of(encodings: Sequence[qc.gadgets.Encoding], declared: str) -> Pauli: + """The physical Pauli a declared logical operator names over ``encodings``. + + ``declared`` is an instruction action operand such as ``"X_0 Z_1"``. Its + token ``_`` names the ``t``-th entry of :func:`flat_logical_slots`. + """ + return logical_pauli_of( + encodings, [parse_term(token) for token in declared.split()] + ) + + +def logical_pauli_of( + encodings: Sequence[qc.gadgets.Encoding], + terms: Iterable[tuple[str, int]], +) -> Pauli: + """The physical Pauli named by ``(basis, flat logical qubit)`` terms. + + A ``Y`` term names the product of that logical qubit's X and Z + representatives; terms landing on the same physical qubit are multiplied. + """ + slots = flat_logical_slots(encodings) + characters: dict[int, "PauliCharacter"] = {} + for basis, flat_index in terms: + if flat_index >= len(slots): + raise ValueError( + f"logical qubit {flat_index} is beyond the {len(slots)} the " + f"gadget's encodings carry" + ) + encoding, local_index = slots[flat_index] + relocation = encoding_qubit_relocation(encoding) + for local, character in _representative_chars( + encoding.code, local_index, basis + ): + qubit = relocation[local] + characters[qubit] = _product(characters.get(qubit, "I"), character) + return Pauli( + { + qubit: character + for qubit, character in characters.items() + if character != "I" + } + ) + + +def _representative_chars( + code: qc.Code, local_index: int, basis: str +) -> Iterator[tuple[int, "PauliCharacter"]]: + """Characters of the representative(s) one declared basis letter selects.""" + if basis == "X": + operators = [list(code.x)[local_index]] + elif basis == "Z": + operators = [list(code.z)[local_index]] + elif basis == "Y": + operators = [list(code.x)[local_index], list(code.z)[local_index]] + else: + raise ValueError(f"unsupported declared Pauli basis {basis!r}") + for operator in operators: + for qubit, character in characters_of_string(str(operator)).items(): + if character != "I": + yield qubit, character + + +def _product(left: "PauliCharacter", right: "PauliCharacter") -> "PauliCharacter": + """The unsigned product of two Pauli characters.""" + if left == "I": + return right + if right == "I": + return left + if left == right: + return "I" + return next(item for item in ("X", "Y", "Z") if item not in (left, right)) + + +def _flat_logical_chars(code: qc.Code) -> Iterator[dict[int, "PauliCharacter"]]: + for x_characters, z_characters in zip( + logical_chars(code, "X"), logical_chars(code, "Z") + ): + yield x_characters + yield z_characters + + +def _all_operator_chars(code: qc.Code) -> Iterator[dict[int, "PauliCharacter"]]: + for group in (code.stabilizers, code.x, code.z): + for operator in group: + yield characters_of_string(str(operator)) diff --git a/source/qdk_package/qdk/ec/_analysis/propagation/stabilizer.py b/source/qdk_package/qdk/ec/_analysis/propagation/stabilizer.py new file mode 100644 index 00000000000..2466257a1a0 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/propagation/stabilizer.py @@ -0,0 +1,28 @@ +"""Stabilizer-state evaluation through qodec programs.""" + +from __future__ import annotations + +from paulimer import OutcomeCompleteSimulation + +from .frames import FrameGroup, PauliFrame +from .pauli import Pauli + + +def frame_group_of( + simulation: OutcomeCompleteSimulation, + *, + qubit_count: int | None = None, +) -> FrameGroup: + clifford = simulation.clifford + sign_rows = list(simulation.sign_matrix.rows) + count = simulation.qubit_count if qubit_count is None else qubit_count + return FrameGroup( + PauliFrame( + Pauli.from_dense(clifford.image_z(qubit)), + frozenset(sign_rows[qubit].support), + ) + for qubit in range(count) + ) + + +__all__ = ["frame_group_of"] diff --git a/source/qdk_package/qdk/ec/_analysis/separable_code.py b/source/qdk_package/qdk/ec/_analysis/separable_code.py new file mode 100644 index 00000000000..b4b97935b75 --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/separable_code.py @@ -0,0 +1,53 @@ +"""Internal tensor-product code view used by action profiling.""" + +from __future__ import annotations + +from itertools import chain +from typing import Mapping + +from .propagation.pauli import relabel +from .code_algebra import SubsystemCode +from .stabilizer_code import StabilizerCode + + +class SeparableCode(SubsystemCode): + @staticmethod + def by_stacking(*codes: SubsystemCode) -> "SeparableCode": + blocks = [] + offset = 0 + for code in codes: + mapping = { + qubit: offset + index + for index, qubit in enumerate(sorted(code.support)) + } + blocks.append(_relocate(code, by=mapping)) + offset += len(code.support) + return SeparableCode(*blocks) + + def __init__(self, *blocks: SubsystemCode): + if not _are_disjoint(*blocks): + raise ValueError("Code blocks are not disjoint.") + self._blocks = blocks + super().__init__( + tuple(chain(*(code.stabilizers for code in blocks))), + tuple(chain(*(code.logical_basis for code in blocks))), + ) + + @property + def blocks(self) -> tuple[SubsystemCode, ...]: + return self._blocks + + +def _are_disjoint(*blocks: SubsystemCode) -> bool: + supports = [block.support for block in blocks] + support = set(chain.from_iterable(supports)) + return len(support) == sum(map(len, supports)) + + +def _relocate(code: SubsystemCode, *, by: Mapping[int, int]) -> SubsystemCode: + generators = tuple(relabel(generator, by) for generator in code.stabilizers) + logicals = tuple(relabel(generator, by) for generator in code.logical_basis) + return StabilizerCode(generators, logical_basis=logicals) + + +__all__ = ["SeparableCode"] diff --git a/source/qdk_package/qdk/ec/_analysis/stabilizer_code.py b/source/qdk_package/qdk/ec/_analysis/stabilizer_code.py new file mode 100644 index 00000000000..125f44ec66e --- /dev/null +++ b/source/qdk_package/qdk/ec/_analysis/stabilizer_code.py @@ -0,0 +1,67 @@ +"""Internal stabilizer-code specialization used by profiling algorithms.""" + +from __future__ import annotations + +from typing import Iterable, Optional, Sequence + +from paulimer import PauliGroup + +from .propagation.pauli import Pauli +from .code_algebra import SubsystemCode, logical_basis_of + + +class StabilizerCode(SubsystemCode): + def __init__( + self, + generators: Sequence[Pauli], + also_supporting: Iterable[int] = (), + logical_basis: Optional[Sequence[Pauli]] = None, + ) -> None: + completed_basis = _make_logical_basis( + generators, logical_basis, also_supporting + ) + super().__init__(generators, logical_basis=completed_basis) + + +def _make_logical_basis( + generators: Sequence[Pauli], + preferred_basis: Optional[Sequence[Pauli]], + also_supporting: Iterable[int], +) -> Sequence[Pauli]: + group = PauliGroup(generators, all_commute=True) + additional_support = set(also_supporting) - set(group.support) + support = set(group.support) | additional_support + if preferred_basis is None: + logical_basis = tuple(logical_basis_of(group, supported_by=support)) + else: + preferred_support = set(PauliGroup(preferred_basis).support) + support |= preferred_support + additional_support -= preferred_support + logical_basis = tuple(preferred_basis) + tuple( + logical_basis_of(PauliGroup([]), supported_by=additional_support) + ) + _validate(generators, logical_basis, len(support)) + return logical_basis + + +def _validate( + generators: Sequence[Pauli], + logical_basis: Sequence[Pauli], + size: int, +) -> None: + if not _logical_ops_for_all_logical_qubits(logical_basis, generators, size): + raise ValueError( + "Two logical operators must be provided for each logical qubit." + ) + + +def _logical_ops_for_all_logical_qubits( + logical_basis: Sequence[Pauli], + generators: Sequence[Pauli], + support_size: int, +) -> bool: + logical_qubit_count = support_size - PauliGroup(generators).binary_rank + return len(logical_basis) == 2 * logical_qubit_count + + +__all__ = ["StabilizerCode"] diff --git a/source/qdk_package/qdk/ec/_audit/__init__.py b/source/qdk_package/qdk/ec/_audit/__init__.py new file mode 100644 index 00000000000..eabae6ff307 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/__init__.py @@ -0,0 +1,26 @@ +"""Evaluate audit rules to find authoring mistakes in a qodec. + +Where equivalence compares two artifacts, auditing inspects one and reports what +looks wrong. :func:`audit` runs the rule set over a whole qodec and returns a +:class:`Report` of errors, warnings, and informational notes. Each message is a +:class:`Diagnostic` naming the audit rule, the affected object, and the finding. + +Rules are ordered by phase: a structural failure suppresses the semantic rules +that depend on it, so a malformed gadget reports one root cause rather than a +cascade. +""" + +from ._auditor import Auditor, audit +from ._diagnostic import Diagnostic, Phase, Severity +from ._report import Report +from ._rule import Rule + +__all__ = [ + "Auditor", + "Diagnostic", + "Phase", + "Report", + "Rule", + "Severity", + "audit", +] diff --git a/source/qdk_package/qdk/ec/_audit/_auditor.py b/source/qdk_package/qdk/ec/_audit/_auditor.py new file mode 100644 index 00000000000..a468b3eda4e --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_auditor.py @@ -0,0 +1,333 @@ +"""Audit runner.""" + +from __future__ import annotations + +from collections.abc import Collection, Iterable, Iterator +from dataclasses import dataclass, replace +import json + +import qodec as qc + +from ._diagnostic import Diagnostic, Phase, Severity +from ._report import Report +from ._rule import Rule, filter_rules + + +@dataclass(frozen=True) +class _Target: + artifact: object + local: str = "" + where: str = "" + path: str = "" + dependencies: frozenset[str | int] = frozenset() + + def locate(self, diagnostic: Diagnostic, qodec: qc.Qodec) -> Diagnostic: + if self.where and diagnostic.where in (self.local, self.path): + diagnostic = replace(diagnostic, where=self.where) + if diagnostic.source_location is None and ( + self.path or isinstance(self.artifact, qc.Qodec) + ): + try: + node = qodec.resolve(self.path) + location = None + if diagnostic._path: + try: + location = node.resolve(diagnostic._path).source_location + except (LookupError, ValueError): + pass + if location is None: + location = node.source_location + diagnostic = replace(diagnostic, source_location=location) + except (LookupError, ValueError): + pass + return diagnostic + + @property + def key(self) -> str | int: + return ( + self.path + if self.path or isinstance(self.artifact, qc.Qodec) + else id(self.artifact) + ) + + +class Auditor: + def __init__( + self, + rules: Iterable[Rule] | None = None, + *, + disabled: Collection[str] = (), + include_informational: bool = False, + strict: bool = False, + ) -> None: + if rules is None: + from .rules import default_rules + + self._rules = tuple(default_rules()) + else: + self._rules = tuple(rules) + self._disabled = frozenset(disabled) + self._include_informational = include_informational + self._strict = strict + + @property + def rules(self) -> tuple[Rule, ...]: + return self._rules + + def audit(self, qodec: qc.Qodec) -> Report: + return self._run(qodec, self._qodec_targets(qodec)) + + def audit_code(self, code: qc.Code, *, qodec: qc.Qodec) -> Report: + return self._run(qodec, self._targets_for(code, qodec)) + + def audit_instruction_set( + self, + isa: qc.InstructionSet, + *, + qodec: qc.Qodec, + ) -> Report: + return self._run(qodec, self._targets_for(isa, qodec)) + + def audit_gadget( + self, + gadget: qc.Gadget, + *, + qodec: qc.Qodec, + ) -> Report: + return self._run(qodec, self._targets_for(gadget, qodec)) + + def audit_layer( + self, + layer: qc.Layer, + *, + qodec: qc.Qodec, + ) -> Report: + targets = [ + target + for artifact in (layer, *layer.gadgets.values()) + for target in self._targets_for(artifact, qodec) + ] + return self._run(qodec, targets) + + def _run( + self, + qodec: qc.Qodec, + targets: Iterable[_Target], + ) -> Report: + target_list = list(targets) + context = self._qodec_targets(qodec) + relevant = {target.path for target in target_list if target.path} + relevant.update( + path + for target in target_list + for path in target.dependencies + if isinstance(path, str) + ) + selected = {target.key for target in target_list} + target_list.extend( + target + for target in context + if target.path in relevant and target.key not in selected + ) + located = {target.path: target for target in context} + located.update({target.path: target for target in target_list if target.path}) + whole_model = any( + isinstance(target.artifact, qc.Qodec) for target in target_list + ) + structural_targets = target_list + if relevant and not whole_model: + structural_targets = [context[0], *target_list] + diagnostics: list[Diagnostic] = [] + blocked: set[str | int] = set() + for target, item in self._run_phase( + qodec, structural_targets, Phase.STRUCTURAL + ): + if ( + isinstance(target.artifact, qc.Qodec) + and item.rule == "qodec/invalid-structure" + ): + if item.where and not whole_model and item.where not in relevant: + continue + target = located.get(item.where, target) + diagnostic = self._apply_policy(target.locate(item, qodec)) + diagnostics.append(diagnostic) + if diagnostic.severity is Severity.ERROR: + blocked.add(target.key) + target_list.sort( + key=lambda target: ( + 0 if isinstance(target.artifact, (qc.Code, qc.InstructionSet)) else 1 + ) + ) + for target, item in self._run_phase( + qodec, target_list, Phase.SEMANTIC, blocked + ): + diagnostic = self._apply_policy(target.locate(item, qodec)) + diagnostics.append(diagnostic) + if diagnostic.severity is Severity.ERROR and ( + isinstance(target.artifact, (qc.Code, qc.InstructionSet)) + or item.rule == "instruction-set/invalid-clifford" + ): + blocked.add(target.key) + if self._include_informational: + diagnostics.extend( + self._apply_policy(target.locate(item, qodec)) + for target, item in self._run_phase( + qodec, target_list, Phase.INFORMATIONAL, blocked + ) + ) + return Report(tuple(diagnostics)) + + def _apply_policy(self, diagnostic: Diagnostic) -> Diagnostic: + if self._strict and diagnostic.severity is Severity.WARNING: + return replace(diagnostic, severity=Severity.ERROR) + return diagnostic + + def _run_phase( + self, + qodec: qc.Qodec, + targets: list[_Target], + phase: Phase, + blocked: Collection[str | int] = (), + ) -> Iterator[tuple[_Target, Diagnostic]]: + rules = filter_rules(self._rules, phase=phase, disabled=self._disabled) + for target in targets: + for rule in rules: + if rule.name == "qodec/invalid-structure" and target.path: + continue + if ( + rule.name == "instruction-set/invalid-clifford" + and target.path + and isinstance(target.artifact, qc.Gadget) + ): + continue + if not ( + "" in blocked + or target.key in blocked + or any(path in blocked for path in target.dependencies) + ) and isinstance(target.artifact, rule.target): + for diagnostic in rule(target.artifact, qodec=qodec): + yield target, diagnostic + + @staticmethod + def _qodec_targets(qodec: qc.Qodec) -> list[_Target]: + targets = [_Target(qodec, where="qodec")] + targets.extend( + _Target( + code, + f"code[{code.name!r}]", + f"codes[{code.name!r}]", + f"codes[{json.dumps(code.name, ensure_ascii=False)}]", + ) + for code in qodec.codes.values() + ) + layers = qodec.layers + instruction_set_paths: dict[str, frozenset[str | int]] = {} + for index, layer in enumerate(layers): + name = layer.instruction_set.name + instruction_set_paths[name] = instruction_set_paths.get( + name, frozenset() + ) | {f"layers[{index}].instruction_set"} + for index, layer in enumerate(layers): + source = layer.instruction_set.name + context = source + if index + 1 < len(layers): + context += f" -> {layers[index + 1].instruction_set.name}" + targets.append( + _Target( + layer.instruction_set, + f"isa[{source!r}]", + f"layers[{index}].instruction_set ({source})", + f"layers[{index}].instruction_set", + instruction_set_paths[source], + ) + ) + if index + 1 == len(layers): + continue + targets.append( + _Target( + layer, + f"layer[{source!r}]", + f"layers[{index}] ({context})", + f"layers[{index}]", + ) + ) + targets.extend( + _Target( + gadget, + f"gadget[{gadget.implements.mnemonic!r}]", + f"layers[{index}].gadgets[{mnemonic!r}] ({context})", + f"layers[{index}].gadgets[{json.dumps(mnemonic, ensure_ascii=False)}]", + instruction_set_paths[source] + | instruction_set_paths[layers[index + 1].instruction_set.name] + | frozenset( + f"codes[{json.dumps(encoding.code.name, ensure_ascii=False)}]" + for encoding in (*gadget.inputs, *gadget.outputs) + ), + ) + for mnemonic, gadget in layer.gadgets.items() + ) + return targets + + @classmethod + def _targets_for(cls, artifact: object, qodec: qc.Qodec) -> list[_Target]: + attached = [ + target + for target in cls._qodec_targets(qodec) + if target.artifact is artifact + ] + if attached: + return attached + if isinstance(artifact, qc.Gadget): + prerequisites = [ + target + for dependency in ( + artifact.circuit.instruction_set, + *( + encoding.code + for encoding in (*artifact.inputs, *artifact.outputs) + ), + ) + for target in cls._targets_for(dependency, qodec) + ] + prerequisites = list( + {target.key: target for target in prerequisites}.values() + ) + local = f"gadget[{artifact.implements.mnemonic!r}]" + return [ + *prerequisites, + _Target( + artifact, + local, + local, + dependencies=frozenset(target.key for target in prerequisites), + ), + ] + if isinstance(artifact, qc.Code): + local = f"code[{artifact.name!r}]" + return [_Target(artifact, local, local)] + if isinstance(artifact, qc.InstructionSet): + local = f"isa[{artifact.name!r}]" + return [_Target(artifact, local, local)] + return [_Target(artifact)] + + +def audit( + qodec: qc.Qodec, + *, + disabled: Collection[str] = (), + promote_warnings: bool = False, +) -> Report: + """Run every enabled audit rule over a whole qodec. + + The returned report carries every diagnostic the rules produced, including + informational ones; filtering is the caller's to do on read. + ``promote_warnings`` reclassifies warnings as errors, it does not filter. + """ + return Auditor( + disabled=disabled, + include_informational=True, + strict=promote_warnings, + ).audit(qodec) + + +__all__ = ["Auditor", "audit"] diff --git a/source/qdk_package/qdk/ec/_audit/_dependencies.py b/source/qdk_package/qdk/ec/_audit/_dependencies.py new file mode 100644 index 00000000000..3bad249d587 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_dependencies.py @@ -0,0 +1,25 @@ +"""Dependencies on earlier independent binary rows, retaining declaration indices.""" + +from collections.abc import Sequence + +from binar import BitMatrix, BitVector, solve + + +def row_dependencies(rows: Sequence[BitVector]) -> tuple[tuple[int, ...] | None, ...]: + basis: list[BitVector] = [] + indices: list[int] = [] + dependencies: list[tuple[int, ...] | None] = [] + for index, row in enumerate(rows): + if row.is_zero: + dependencies.append(()) + continue + factors = solve(BitMatrix(basis).T, row) if basis else None + if factors is None: + basis.append(row) + indices.append(index) + dependencies.append(None) + else: + dependencies.append( + tuple(indices[position] for position in factors.support) + ) + return tuple(dependencies) diff --git a/source/qdk_package/qdk/ec/_audit/_diagnostic.py b/source/qdk_package/qdk/ec/_audit/_diagnostic.py new file mode 100644 index 00000000000..1059012cec5 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_diagnostic.py @@ -0,0 +1,52 @@ +"""Audit diagnostic values and phases.""" + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from qodec import SourceLocation + + +class Phase(Enum): + STRUCTURAL = "structural" + SEMANTIC = "semantic" + INFORMATIONAL = "informational" + + +@dataclass(frozen=True) +class Diagnostic: + class Severity(Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + rule: str + severity: Severity + summary: str + where: str + detail: str = "" + source_location: SourceLocation | None = field( + default=None, kw_only=True, compare=False + ) + _path: str = field(default="", kw_only=True, repr=False, compare=False) + + def __str__(self) -> str: + lines = [f"[{self.severity.name}] {self.rule}"] + location = self.source_location + if location is not None: + display_path = str(location.path) + try: + relative = location.path.relative_to(Path.home()) + display_path = f"~/{relative.as_posix()}" + except (ValueError, RuntimeError): + pass + lines.append(f"{display_path}:{location.line}") + lines.extend((self.where, self.summary)) + lines.extend(f" {line}" for line in self.detail.splitlines()) + return "\n".join(lines) + + +Severity = Diagnostic.Severity + + +__all__ = ["Diagnostic", "Phase"] diff --git a/source/qdk_package/qdk/ec/_audit/_parity.py b/source/qdk_package/qdk/ec/_audit/_parity.py new file mode 100644 index 00000000000..b96a88908cd --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_parity.py @@ -0,0 +1,640 @@ +"""Exact affine parity evaluation with arbitrary incoming Pauli frames.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from functools import cached_property +import json + +from binar import BitMatrix, BitVector, solve +import qodec as qc +from qodec import Reference + +from .._analysis.channel_action import declared_action_of, realized_codes_of +from .._analysis.check_discovery import choi_prepare +from .._analysis.propagation.frames import FrameGroup, PauliFrame +from .._analysis.propagation.interpreter import _FramePropagator, walk_program +from .._analysis.propagation.isa_actions import remap_pauli +from .._analysis.propagation.pauli import Pauli, complex_conjugate_of, identity, relabel +from .._analysis.propagation.pauli_remap import ( + encoding_qubit_relocation, +) +from .._layout import ProgramLayout +from .._frames import FrameMap +from .._references import ReadoutSign, reference_term, reference_terms + + +def terms_of(equation: Iterable[Reference | int]) -> tuple[str, ...]: + terms: dict[str, None] = {} + for reference in equation: + if type(reference) is int: + if reference not in (0, 1): + raise ValueError("parity constants must be 0 or 1") + if reference == 1: + if "1" in terms: + del terms["1"] + else: + terms["1"] = None + continue + if not isinstance(reference, Reference): + raise ValueError("parity terms must be references or integer bits") + for atom in reference_terms(reference): + path = str(atom) + if path in terms: + del terms[path] + else: + terms[path] = None + return tuple(terms) + + +def _rows(rows: Iterable[BitVector], width: int) -> BitMatrix: + values = list(rows) + return BitMatrix(values) if values else BitMatrix.zeros(0, width) + + +def _unresolved_signs( + output_signs: list[str], equations: list[tuple[str, ...]] +) -> tuple[str, ...]: + unknowns = dict.fromkeys(output_signs) + for equation in equations: + for path in equation: + if path.startswith(("out[", "readouts[")): + unknowns[path] = None + positions = {path: index for index, path in enumerate(unknowns)} + matrix = _rows( + (BitVector(path in equation for path in unknowns) for equation in equations), + len(unknowns), + ) + return tuple( + path + for path in output_signs + if solve( + matrix.T, + BitVector(index == positions[path] for index in range(len(unknowns))), + ) + is None + ) + + +@dataclass(frozen=True) +class _ReadoutResolution: + values: dict[int, BitVector] + unresolved: frozenset[int] + conflicts: tuple[tuple[int, ...], ...] + blocked: frozenset[int] + + +class ParityAnalysis: + """Check parities against called instructions' noiseless, zero-flag contract. + + Each called instruction's implementation is audited separately. + """ + + def __init__(self, gadget: qc.Gadget) -> None: + self.gadget = gadget + self.checks = tuple(terms_of(check) for check in gadget.checks) + self.readouts = tuple(terms_of(readout.equation) for readout in gadget.readouts) + + def _rotation_invariants_only(self) -> bool: + """Replacing pure rotations by identity is exact for commuting stabilizers only.""" + program = self.gadget.circuit + if program.readouts or any( + path.startswith("out[") and "].stabilizers[" not in path + for equation in (*self.checks, *self.readouts) + for path in equation + ): + return False + layout = ProgramLayout.of(program) + axes = [] + for call in program.calls(): + instruction = program.instruction_set.instructions[call.mnemonic] + for action in instruction.action: + if ( + not isinstance(action, qc.actions.Rotate) + or action.condition is not None + ): + return False + axes.append(remap_pauli(action.pauli, layout.call_qubit_map(call))) + return bool(axes) and all( + axis.commutes_with( + relabel(Pauli(str(operator)), encoding_qubit_relocation(encoding)) + ) + for axis in axes + for encoding in self.gadget.outputs + for operator in encoding.code.stabilizers + ) + + @cached_property + def _simulation( + self, + ) -> tuple[dict[str, BitVector], dict[int, BitVector], dict[str, str]]: + gadget = self.gadget + program = gadget.circuit + for call in program.calls(): + instruction = program.instruction_set.instructions[call.mnemonic] + if any( + name not in instruction.flags + for pattern in call.select + for name in pattern + ): + raise ValueError("selection names an undeclared instruction flag") + if call.select and not any( + all(value == 0 for value in pattern.values()) for pattern in call.select + ): + raise NotImplementedError( + "selection rejects the noiseless zero-flag branch" + ) + if any( + getattr(action, "condition", None) is not None + and not isinstance(action, qc.actions.Pauli) + for action in instruction.action + ): + raise NotImplementedError( + "conditional circuit actions are not supported by parity verification" + ) + simulation = choi_prepare(gadget) + input_qubits = sorted( + { + qubit + for encoding in gadget.inputs + for qubit in encoding_qubit_relocation(encoding).values() + } + ) + auxiliary_origin = simulation.qubit_count - len(input_qubits) + partners = { + qubit: auxiliary_origin + offset + for offset, qubit in enumerate(input_qubits) + } + frame_basis = [ + Pauli({qubit: basis}) for qubit in input_qubits for basis in ("X", "Z") + ] + frames = _FramePropagator(len(frame_basis)) + for index, pauli in enumerate(frame_basis): + frames.apply_pauli_to_shot(index, pauli) + rows: dict[str, int] = {} + deltas: dict[str, list[bool]] = {} + unavailable: dict[str, str] = {} + initial_rows = [] + + def measure(pauli: Pauli) -> int: + row = simulation.outcome_count + simulation.measure(pauli) + return row + + for entry, encoding in enumerate(gadget.inputs): + relocation = encoding_qubit_relocation(encoding) + for basis, operators in ( + ("stabilizers", encoding.code.stabilizers), + ("x", encoding.code.x), + ("z", encoding.code.z), + ): + for index, operator in enumerate(operators): + path = f"in[{entry}].{basis}[{index}]" + pauli = relabel(Pauli(str(operator)), relocation) + deltas[path] = [ + not error.commutes_with(pauli) for error in frame_basis + ] + if basis == "stabilizers": + rows[path] = measure(pauli) + initial_rows.append(rows[path]) + + observe_outcomes = () + if not self._rotation_invariants_only(): + walk = walk_program(program, simulation=simulation, extra_engines=[frames]) + observe_outcomes = walk.observe_outcomes + if len(observe_outcomes) != sum( + not isinstance(readout, qc.gadgets.Flag) for readout in program.readouts + ): + raise NotImplementedError( + "circuit observations are not fully simulated by parity verification" + ) + outcome_rows = iter(observe_outcomes) + for position, readout in enumerate(program.readouts): + path = f"circuit.readouts[{position}]" + if isinstance(readout, qc.gadgets.Flag): + deltas[path] = [False] * len(frame_basis) + continue + row = next(outcome_rows) + rows[path] = row + deltas[path] = [ + bool(frames.outcome_deltas[row - len(initial_rows), index]) + for index in range(len(frame_basis)) + ] + + output_operators: dict[str, Pauli] = {} + for entry, encoding in enumerate(gadget.outputs): + relocation = encoding_qubit_relocation(encoding) + for index, operator in enumerate(encoding.code.stabilizers): + path = f"out[{entry}].stabilizers[{index}]" + pauli = relabel(Pauli(str(operator)), relocation) + output_operators[path] = pauli + rows[path] = measure(pauli) + + logical_offset = 0 + referenced = { + term for equation in (*self.checks, *self.readouts) for term in equation + } + objective = None + for entry, encoding in enumerate(gadget.outputs): + relocation = encoding_qubit_relocation(encoding) + for basis, operators in (("x", encoding.code.x), ("z", encoding.code.z)): + for index, operator in enumerate(operators): + path = f"out[{entry}].{basis}[{index}]" + if path not in referenced: + continue + pauli = relabel(Pauli(str(operator)), relocation) + try: + if any( + isinstance(action, qc.actions.Observe) + for action in gadget.implements.action + ): + raise NotImplementedError( + "output logical frames after logical measurements are not supported" + ) + if objective is None: + objective = declared_action_of(gadget) + if any( + item.frame for item in objective._stabilizers.generators + ): + raise NotImplementedError( + "outcome-dependent output logical frames are not supported" + ) + mapping = list(objective._mapping.items()) + images = FrameGroup( + [ + PauliFrame(image.pauli, frozenset({position})) + for position, (_, image) in enumerate(mapping) + ] + + [ + PauliFrame(item.pauli) + for item in objective._stabilizers.generators + ] + ) + factors = images.factorization_of( + Pauli( + {logical_offset + index: "X" if basis == "x" else "Z"} + ) + ) + if factors is None: + raise ValueError( + "instruction does not determine this output logical frame" + ) + anchor = Pauli.identity() + for factor in factors: + if not factor.pauli.weight: + anchor *= identity(factor.pauli.phase) + for position in factor.frame: + if position >= len(mapping): + raise NotImplementedError( + "outcome-dependent output frames are not supported" + ) + logical = mapping[position][0] + physical = realized_codes_of(gadget)[ + 0 + ].representative_of(logical) + anchor *= relabel( + complex_conjugate_of(physical), partners + ) + joint = pauli * anchor + if not simulation.is_stabilizer(joint, ignore_sign=True): + raise ValueError( + "circuit does not realize the declared output logical frame" + ) + rows[path] = measure(joint) + output_operators[path] = pauli + except ( + KeyError, + ValueError, + TypeError, + NotImplementedError, + ) as error: + unavailable[path] = str(error) + logical_offset += len(encoding.code.x) + + for path, pauli in output_operators.items(): + frame_row = frames.measure(pauli) + deltas[path] = [ + bool(frames.outcome_deltas[frame_row, index]) + for index in range(len(frame_basis)) + ] + + expected_rows = {} + observe_actions = [ + action + for action in gadget.implements.action + if isinstance(action, qc.actions.Observe) + ] + input_code = realized_codes_of(gadget)[0] + probes = [ + input_code.representative_of(Pauli(str(operator))) + for action in observe_actions + for operator in action.observables + ] + if any( + not isinstance(action, qc.actions.Observe) + for action in gadget.implements.action[: len(observe_actions)] + ): + unavailable["observables"] = ( + "readout verification of interleaved logical actions is not supported" + ) + elif any( + not left.commutes_with(right) + for index, left in enumerate(probes) + for right in probes[index + 1 :] + ): + unavailable["observables"] = ( + "noncommuting logical readouts require sequential verification" + ) + else: + for position, probe in enumerate(probes): + expected_rows[position] = measure( + relabel(complex_conjugate_of(probe), partners) + ) + + matrix = simulation.outcome_matrix + width = matrix.column_count + constraints = _rows( + ( + BitVector(matrix[row, column] for column in range(width)) + for row in initial_rows + ), + width, + ) + shift = BitVector(bool(simulation.outcome_shift[row]) for row in initial_rows) + particular = solve(constraints, shift) + if particular is None: + raise ValueError("input code has no noiseless +1 codeword") + kernel = list(constraints.kernel().rows) + + def signature(row: int | None, frame: list[bool]) -> BitVector: + linear = ( + BitVector.zeros(width) + if row is None + else BitVector(matrix[row, column] for column in range(width)) + ) + constant = False if row is None else bool(simulation.outcome_shift[row]) + return BitVector( + [ + *(linear.dot(vector) for vector in kernel), + *frame, + constant ^ linear.dot(particular), + ] + ) + + values = { + path: signature(rows.get(path), delta) for path, delta in deltas.items() + } + expected = { + position: signature(row, [False] * len(frame_basis)) + for position, row in expected_rows.items() + } + values["0"] = signature(None, [False] * len(frame_basis)) + values["1"] = values["0"].copy() + values["1"][len(values["1"]) - 1] = True + for path, correction in FrameMap(gadget).evaluate(values, values["0"]).items(): + if path in values: + values[path] = values[path] ^ correction + return values, expected, unavailable + + @property + def values(self) -> dict[str, BitVector]: + return self._simulation[0] + + @property + def expected(self) -> dict[int, BitVector]: + if "observables" in self._simulation[2]: + raise NotImplementedError(self._simulation[2]["observables"]) + return self._simulation[1] + + def external(self, path: str) -> BitVector: + if path in self._simulation[2]: + raise NotImplementedError(f"{path}: {self._simulation[2][path]}") + if path not in self.values: + raise ValueError(f"unresolved reference {path}") + return self.values[path] + + @cached_property + def resolution(self) -> _ReadoutResolution: + count = len(self.readouts) + matrix = BitMatrix.identity(count) + right = [] + for position, equation in enumerate(self.readouts): + value = self.values["0"].copy() + for path in equation: + if path == "1": + value = value ^ self.values["1"] + continue + reference = reference_term(path) + if isinstance(reference, ReadoutSign): + matrix[position, reference.index] = not matrix[ + position, reference.index + ] + else: + value = value ^ self.external(path) + right.append(value) + conflicts: list[set[int]] = [] + for dependency in matrix.T.kernel().rows: + value = self.values["0"].copy() + for index in dependency.support: + value = value ^ right[index] + if not value.is_zero: + conflicts.append(set(dependency.support)) + merged: list[set[int]] = [] + for conflict in conflicts: + overlapping = [group for group in merged if group & conflict] + merged = [group for group in merged if not group & conflict] + merged.append(conflict.union(*overlapping)) + blocked = set().union(*merged) + while True: + dependent = { + position + for position, equation in enumerate(self.readouts) + if any(f"readouts[{index}]" in equation for index in blocked) + } + if dependent <= blocked: + break + blocked.update(dependent) + retained = [position for position in range(count) if position not in blocked] + reduced = matrix + if blocked: + reduced = _rows( + ( + BitVector(matrix[row, column] for column in retained) + for row in retained + ), + len(retained), + ) + unresolved = frozenset( + retained[index] + for vector in reduced.kernel().rows + for index in vector.support + ) + columns = [ + solve(reduced, BitVector(right[position][column] for position in retained)) + for column in range(len(self.values["0"])) + ] + if any(column is None for column in columns): + raise ValueError("readout equations have no solution") + values = { + position: BitVector( + column[index] for column in columns if column is not None + ) + for index, position in enumerate(retained) + } + return _ReadoutResolution( + values, + unresolved, + tuple(sorted(tuple(sorted(group)) for group in merged)), + frozenset(blocked), + ) + + def value(self, equation: Iterable[str]) -> BitVector: + result = self.values["0"].copy() + for path in equation: + if path == "1": + result = result ^ self.values["1"] + continue + reference = reference_term(path) + if isinstance(reference, ReadoutSign): + resolution = self.resolution + if reference.index in resolution.blocked: + raise ValueError( + f"readouts[{reference.index}] depends on inconsistent readout equations" + ) + if reference.index in resolution.unresolved: + raise ValueError(f"readouts[{reference.index}] is undetermined") + result = result ^ resolution.values[reference.index] + else: + result = result ^ self.external(path) + return result + + def candidate(self, expected: BitVector) -> tuple[str, ...] | None: + paths = [ + path + for path in self.values + if path.startswith(("circuit.", "in[")) or path == "1" + ] + matrix = _rows((self.values[path] for path in paths), len(expected)) + solution = solve(matrix.T, expected) + return ( + None + if solution is None + else tuple(paths[index] for index in solution.support) + ) + + def readout_candidate(self, position: int) -> tuple[str, ...] | None: + """A verified observable equation, if analysis can derive one.""" + try: + return self.candidate(self.expected[position]) + except (KeyError, ValueError, TypeError, NotImplementedError): + return None + + def missing_checks(self) -> tuple[tuple[str, ...], ...]: + """Independent measurement checks absent from the declared relation span. + + Candidates use circuit bits and incoming stabilizer signs. Input-only + identities supply no measurement information; output-frame relations + are checked separately. Readout definitions permit substitution, while + verified zero-valued flags already expose their syndrome information. + """ + if not self.gadget.circuit.readouts: + return () + paths = [ + path + for path in self.values + if path.startswith("circuit.") + or (path.startswith("in[") and "].stabilizers[" in path) + ] + signatures = _rows((self.values[path] for path in paths), len(self.values["0"])) + candidates = [ + tuple(paths[index] for index in row.support) + for row in signatures.T.kernel().rows + ] + known = [] + for equation in self.checks: + try: + if self.value(equation).is_zero: + known.append(equation) + except (KeyError, ValueError, TypeError, NotImplementedError): + continue + if self.readouts: + resolution = self.resolution + for position, value in resolution.values.items(): + if position in resolution.unresolved: + continue + path = f"readouts[{position}]" + known.append( + terms_of( + 1 if term == "1" else Reference(term) + for term in (path, *self.readouts[position]) + ) + ) + if self.gadget.readouts[position].is_flag and value.is_zero: + known.append((path,)) + known.extend( + equation + for equation in candidates + if not any(path.startswith("circuit.") for path in equation) + ) + columns = tuple( + dict.fromkeys((*paths, *(path for equation in known for path in equation))) + ) + rows = [BitVector(path in equation for path in columns) for equation in known] + missing = [] + for equation in candidates: + if not any(path.startswith("circuit.") for path in equation): + continue + target = BitVector(path in equation for path in columns) + if solve(_rows(rows, len(columns)).T, target) is None: + missing.append(equation) + rows.append(target) + return tuple(missing) + + def unresolved_outputs(self) -> tuple[str, ...]: + output_signs = [ + f"out[{entry}].stabilizers[{index}]" + for entry, encoding in enumerate(self.gadget.outputs) + for index in range(len(encoding.code.stabilizers)) + ] + if not output_signs: + return () + if not any(self.checks) and not any(self.readouts): + return tuple(output_signs) + equations = [] + for equation in self.checks: + try: + if self.value(equation).is_zero: + equations.append(equation) + except (KeyError, ValueError, TypeError, NotImplementedError): + continue + if not _unresolved_signs(output_signs, equations): + return () + resolution = self.resolution + for position, value in resolution.values.items(): + if position in resolution.unresolved: + continue + expected = self.expected.get(position, self.values["0"]) + if value == expected: + equations.append( + terms_of( + 1 if path == "1" else Reference(path) + for path in (f"readouts[{position}]", *self.readouts[position]) + ) + ) + return _unresolved_signs(output_signs, equations) + + def witness(self, equation: Iterable[str], difference: BitVector) -> str: + assignment = BitVector.zeros(len(difference)) + assignment[len(difference) - 1] = True + if not difference[len(difference) - 1]: + assignment[ + next( + index for index in difference.support if index < len(difference) - 1 + ) + ] = True + values = { + path: int(self.value((path,)).dot(assignment)) + for path in dict.fromkeys(equation) + } + return json.dumps(values) diff --git a/source/qdk_package/qdk/ec/_audit/_readout_check.py b/source/qdk_package/qdk/ec/_audit/_readout_check.py new file mode 100644 index 00000000000..f81af328d24 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_readout_check.py @@ -0,0 +1,110 @@ +"""Verify complete readout equations against noiseless encoded execution.""" + +from __future__ import annotations + +from dataclasses import dataclass +import qodec as qc + +from .._readouts import observe_count_of +from ._diagnostic import Severity +from ._parity import ParityAnalysis + + +@dataclass(frozen=True) +class ReadoutMismatch: + positions: tuple[int, ...] + expected_equations: dict[int, tuple[str, ...]] + summary: str + reason: str + severity: Severity = Severity.ERROR + + +def readout_conflicts(analysis: ParityAnalysis) -> list[ReadoutMismatch]: + conflicts = [] + for positions in analysis.resolution.conflicts: + labels = ", ".join(f"readouts[{position}].equation" for position in positions) + verb = "is" if len(positions) == 1 else "are" + candidates = {} + for position in positions: + candidate = analysis.readout_candidate(position) + if candidate is not None: + candidates[position] = candidate + conflicts.append( + ReadoutMismatch( + positions, + candidates, + f"{labels} {verb} inconsistent", + "The defining equations cannot all hold for every noiseless circuit result.", + ) + ) + return conflicts + + +def readout_disagreements(analysis: ParityAnalysis) -> list[ReadoutMismatch]: + gadget = analysis.gadget + count = min(observe_count_of(gadget.implements), len(gadget.readouts)) + if not count: + return [] + resolution = analysis.resolution + observables = [ + observable + for action in gadget.implements.action + if isinstance(action, qc.actions.Observe) + for observable in action.observables + ] + mismatches = [ + conflict + for conflict in readout_conflicts(analysis) + if any(position < count for position in conflict.positions) + ] + conflicting = {position for group in resolution.conflicts for position in group} + for position in range(count): + if position in conflicting: + continue + required = f"logical {observables[position]} measurement" + if position in resolution.blocked: + candidate = analysis.readout_candidate(position) + mismatches.append( + ReadoutMismatch( + (position,), + {position: candidate} if candidate is not None else {}, + f"readouts[{position}]: the required {required} cannot be verified", + "This readout depends on inconsistent readout equations.", + Severity.WARNING, + ) + ) + continue + if position in resolution.unresolved: + candidate = analysis.readout_candidate(position) + mismatches.append( + ReadoutMismatch( + (position,), + {position: candidate} if candidate is not None else {}, + f"readouts[{position}] cannot report the required {required}: an undetermined readout", + "The defining equations allow both 0 and 1 for this readout; its value is not uniquely determined.", + ) + ) + continue + expected = analysis.expected[position] + difference = resolution.values[position] ^ expected + if difference.is_zero: + continue + candidate = analysis.candidate(expected) + summary = f"readouts[{position}] does not report the required {required}" + if candidate is not None: + reason = "" + else: + summary = f"The circuit does not provide the required {required} result for readouts[{position}]" + reason = "No readout formula using circuit bits and incoming frame signs can recover this result." + mismatches.append( + ReadoutMismatch( + (position,), + {position: candidate} if candidate is not None else {}, + summary, + reason, + ) + ) + return mismatches + + +__all__ = ["ReadoutMismatch", "readout_conflicts", "readout_disagreements"] diff --git a/source/qdk_package/qdk/ec/_audit/_report.py b/source/qdk_package/qdk/ec/_audit/_report.py new file mode 100644 index 00000000000..0a6fab6dc5a --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_report.py @@ -0,0 +1,61 @@ +"""Audit reports.""" + +from dataclasses import dataclass, field + +from ._diagnostic import Diagnostic, Severity + + +@dataclass(frozen=True) +class Report: + diagnostics: tuple[Diagnostic, ...] = field(default_factory=tuple) + + @property + def ok(self) -> bool: + return not self.errors + + @property + def errors(self) -> tuple[Diagnostic, ...]: + return tuple( + item for item in self.diagnostics if item.severity is Severity.ERROR + ) + + @property + def warnings(self) -> tuple[Diagnostic, ...]: + return tuple( + item for item in self.diagnostics if item.severity is Severity.WARNING + ) + + @property + def informational(self) -> tuple[Diagnostic, ...]: + return tuple( + item for item in self.diagnostics if item.severity is Severity.INFO + ) + + def by_rule(self) -> dict[str, tuple[Diagnostic, ...]]: + grouped: dict[str, list[Diagnostic]] = {} + for diagnostic in self.diagnostics: + grouped.setdefault(diagnostic.rule, []).append(diagnostic) + return {key: tuple(items) for key, items in grouped.items()} + + def by_artifact(self) -> dict[str, tuple[Diagnostic, ...]]: + grouped: dict[str, list[Diagnostic]] = {} + for diagnostic in self.diagnostics: + grouped.setdefault(diagnostic.where, []).append(diagnostic) + return {key: tuple(items) for key, items in grouped.items()} + + def __str__(self) -> str: + if not self.diagnostics: + return "audit: ok" + lines = [] + for diagnostic in (*self.errors, *self.warnings): + lines.append(str(diagnostic)) + lines.append("") + lines.append( + f"audit: {len(self.errors)} error(s), " + f"{len(self.warnings)} warning(s), " + f"{len(self.informational)} informational" + ) + return "\n".join(lines) + + +__all__ = ["Report"] diff --git a/source/qdk_package/qdk/ec/_audit/_rule.py b/source/qdk_package/qdk/ec/_audit/_rule.py new file mode 100644 index 00000000000..1b7ee170569 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_rule.py @@ -0,0 +1,48 @@ +"""Audit rule protocol and filtering.""" + +from collections.abc import Iterable, Iterator +from typing import Protocol, TYPE_CHECKING, runtime_checkable + +from ._diagnostic import Diagnostic, Phase, Severity + +if TYPE_CHECKING: + import qodec as qc + + +@runtime_checkable +class Rule(Protocol): + @property + def name(self) -> str: ... + + @property + def severity(self) -> Severity: ... + + @property + def phase(self) -> Phase: ... + + @property + def target(self) -> type: ... + + def __call__( + self, target: object, *, qodec: "qc.Qodec" + ) -> Iterator[Diagnostic]: ... + + +def filter_rules( + rules: Iterable[Rule], + *, + target: type | None = None, + phase: Phase | None = None, + disabled: Iterable[str] = (), +) -> list[Rule]: + disabled_set = frozenset(disabled) + return [ + rule + for rule in rules + if rule.name not in disabled_set + and (target is None or rule.target is target) + and (phase is None or rule.phase is phase) + ] + + +__all__ = ["Rule", "filter_rules"] diff --git a/source/qdk_package/qdk/ec/_audit/_structure.py b/source/qdk_package/qdk/ec/_audit/_structure.py new file mode 100644 index 00000000000..f0faac8d995 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/_structure.py @@ -0,0 +1,405 @@ +"""Check protocol declarations for consistency and valid references.""" + +from __future__ import annotations + +from collections.abc import Iterator +from itertools import chain +import json +import re + +import qodec as qc + +from .._analysis.propagation.pauli import Pauli +from .._readouts import observe_count_of +from .._references import ( + LogicalSign, + Outcome, + ReadoutSign, + reference_term, + reference_terms, +) + + +def _pauli_indices(text: str) -> set[int]: + return set(Pauli(text).support) + + +def _instruction_issues( + instruction: qc.Instruction, blocks: dict[str, int] | None +) -> Iterator[str]: + parameters = { + parameter.name: parameter.kind for parameter in instruction.parameters + } + if len(parameters) != len(instruction.parameters): + yield "duplicate parameter names" + kind = qc.instructions.Parameter.Kind + capacity = None + if blocks is not None: + operands = (*instruction.inputs, *instruction.outputs) + for operand in operands: + if operand.block not in blocks: + yield f"operand references undeclared block type {operand.block!r}" + if all( + not operand.is_variadic and operand.block in blocks for operand in operands + ): + capacity = max( + sum(blocks[operand.block] for operand in instruction.inputs), + sum(blocks[operand.block] for operand in instruction.outputs), + ) + if len(set(instruction.flags)) != len(instruction.flags): + yield "duplicate flag names" + temporary: set[int] = set() + outcomes = 0 + try: + actions = instruction.action + except ValueError as error: + yield f"action cannot be inspected: {error}" + return + for position, action in enumerate(actions): + label = f"action[{position}]" + condition = getattr(action, "condition", None) + if condition is not None: + for predicate in condition.predicates: + match = re.fullmatch(r"outcomes\[([0-9]+)\]", predicate) + if match: + if int(match[1]) >= outcomes: + yield f"{label}: guard {predicate!r} is out of bounds for {outcomes} preceding outcomes" + elif ( + parameters.get(predicate) != kind.BIT + or predicate in instruction.flags + ): + yield f"{label}: guard {predicate!r} must name a bit parameter or a preceding outcomes[i]" + if isinstance(action, qc.actions.Rotate) and isinstance(action.angle, str): + if parameters.get(action.angle) not in (kind.NUMBER, kind.INTEGER): + yield f"{label}: rotation angle {action.angle!r} must name a number or integer parameter" + if isinstance(action, qc.actions.Stabilize): + operators = action.operators + elif isinstance(action, qc.actions.Observe): + operators = action.observables + outcomes += len(operators) + elif isinstance(action, qc.actions.Clifford): + operators = [text for pair in action.generators.items() for text in pair] + elif isinstance(action, qc.actions.Pauli): + operators = [action.operator] + elif isinstance(action, qc.actions.Rotate): + operators = [action.pauli] + else: + continue + for operator in operators: + if parameters.get(operator) == kind.PAULI: + continue + try: + indices = _pauli_indices(operator) + except ValueError as error: + yield f"{label}: {error}" + continue + if capacity is None: + continue + unavailable = {index for index in indices if index >= capacity} - temporary + if unavailable: + if isinstance(action, qc.actions.Stabilize) and condition is None: + temporary.update(unavailable) + else: + yield f"{label}: logical indices {sorted(unavailable)} are outside the operand range 0..{capacity} and have not been introduced by unconditional stabilize" + + +def _same_instruction_set(first: qc.InstructionSet, second: qc.InstructionSet) -> bool: + return all( + getattr(first, field) == getattr(second, field) + for field in ("name", "description", "blocks", "instructions", "metadata") + ) + + +def _argument_issue( + value: object, + parameter: qc.instructions.Parameter, + gadget: qc.Gadget, + preceding: int, +) -> str | None: + kind = qc.instructions.Parameter.Kind + expected = parameter.kind + if isinstance(value, str): + try: + term = reference_term(value) + except ValueError: + term = None + if isinstance(term, Outcome): + if expected != kind.BIT: + return "a circuit readout can bind only a bit parameter" + if term.index >= preceding: + return f"{value} is out of bounds for {preceding} preceding readouts" + return None + for name, source_name in gadget.parameter_bindings.items(): + if value == source_name: + source = next( + ( + item + for item in gadget.implements.parameters + if item.name == name + ), + None, + ) + if source is not None and ( + source.kind == expected + or (source.kind == kind.INTEGER and expected == kind.NUMBER) + ): + return None + return f"forwarded parameter {name!r} has an incompatible type" + if expected == kind.BIT: + valid = type(value) is bool or (type(value) is int and value in (0, 1)) + elif expected in (kind.INTEGER, kind.NUMBER): + valid = type(value) is int or (expected == kind.NUMBER and type(value) is float) + valid |= isinstance(value, list) and all(type(item) is int for item in value) + elif expected == kind.BOOLEAN: + valid = type(value) is bool + elif expected == kind.STRING: + valid = isinstance(value, str) or ( + isinstance(value, list) and all(isinstance(item, str) for item in value) + ) + else: + valid = False + if isinstance(value, str) or ( + isinstance(value, list) and all(isinstance(item, str) for item in value) + ): + try: + _pauli_indices(value if isinstance(value, str) else " ".join(value)) + valid = True + except ValueError: + pass + return None if valid else f"expected {expected.name.lower()}, supplied {value!r}" + + +def _gadget_issues( + gadget: qc.Gadget, blocks: dict[str, int] | None = None +) -> Iterator[str]: + try: + declared = observe_count_of(gadget.implements) + len(gadget.implements.flags) + except ValueError: + declared = None + target_blocks = { + block.name: block.encodes for block in gadget.circuit.instruction_set.blocks + } + for side, encodings, operands in ( + ("in", gadget.inputs, gadget.implements.inputs), + ("out", gadget.outputs, gadget.implements.outputs), + ): + boundary_types: dict[str, str] = {} + boundary_support: dict[str, tuple[int, int]] = {} + if len(encodings) != len(operands): + yield f"{side}: {len(encodings)} encodings for {len(operands)} operands" + for entry, (encoding, operand) in enumerate(zip(encodings, operands)): + for position, label in enumerate(encoding.support): + previous = boundary_support.get(label) + if previous is not None: + previous_entry, previous_position = previous + yield f"{side}[{entry}].support[{position}] and {side}[{previous_entry}].support[{previous_position}] both use circuit label {label!r}" + else: + boundary_support[label] = (entry, position) + code = encoding.code + if ( + blocks is not None + and operand.block in blocks + and code.logical_count != blocks[operand.block] + ): + yield f"{side}[{entry}]: code {code.name!r} declares {code.logical_count} logical qubits; block {operand.block!r} requires {blocks[operand.block]}" + types = encoding.block_types + if not types and len(target_blocks) == 1: + types = [next(iter(target_blocks))] * len(encoding.support) + if len(types) != len(encoding.support): + yield f"{side}[{entry}]: support block types cannot be determined" + continue + if any(name not in target_blocks for name in types): + yield f"{side}[{entry}]: undeclared circuit block type" + continue + for label, block_type in zip(encoding.support, types): + if label in boundary_types and boundary_types[label] != block_type: + yield f"{side}[{entry}]: circuit label {label!r} has conflicting block types {boundary_types[label]!r} and {block_type!r}" + boundary_types[label] = block_type + capacity = sum(target_blocks[name] for name in types) + for property_name, operators in ( + ("stabilizers", code.stabilizers), + ("x", code.x), + ("z", code.z), + ): + for index, operator in enumerate(operators): + try: + invalid = sorted( + value + for value in _pauli_indices(operator) + if value >= capacity + ) + except ValueError as error: + yield f"{side}[{entry}].{property_name}[{index}]: {error}" + continue + if invalid: + yield f"{side}[{entry}].{property_name}[{index}]: code indices {invalid} are out of bounds for {capacity} support qubits" + if declared is not None and len(gadget.readouts) > declared: + yield f"readouts: {len(gadget.readouts)} entries supplied; expected {declared}" + parameters = {parameter.name for parameter in gadget.implements.parameters} + for name in gadget.parameter_bindings: + if name not in parameters: + yield f"parameter binding {name!r} is not declared by the implemented instruction" + circuit_count = None + try: + calls = gadget.circuit.calls() + except (ValueError, TypeError) as error: + missing_parser = ( + f"No source parser registered for '.{gadget.circuit.effective_format}'" + ) + if str(error) != missing_parser: + yield f"circuit: {error}" + else: + circuit_count = 0 + for position, call in enumerate(calls): + instruction = gadget.circuit.instruction_set.instructions[call.mnemonic] + parameter_map = { + parameter.name: parameter for parameter in instruction.parameters + } + for name, value in call.arguments.items(): + if name not in parameter_map: + yield f"circuit.calls[{position}]: argument {name!r} is not declared by {call.mnemonic!r}" + else: + issue = _argument_issue( + value, parameter_map[name], gadget, circuit_count + ) + if issue: + yield f"circuit.calls[{position}] argument {name!r}: {issue}" + for pattern in call.select: + for reference in pattern: + match = re.fullmatch(r"flags\[([0-9]+)\]", reference) + if ( + match is not None and int(match[1]) >= len(instruction.flags) + ) or (match is None and reference not in instruction.flags): + yield f"circuit.calls[{position}]: select references unknown flag {reference!r}" + try: + circuit_count += observe_count_of(instruction) + len(instruction.flags) + except ValueError as error: + yield f"circuit.calls[{position}]: {error}" + circuit_count = None + break + equations = [ + (f"checks[{index}]", equation) for index, equation in enumerate(gadget.checks) + ] + equations += [ + (f"readouts[{index}]", readout.equation) + for index, readout in enumerate(gadget.readouts) + ] + for label, equation in equations: + for reference in equation: + if isinstance(reference, int): + continue + capacity = None + terms = reference_terms(reference) + first = next(terms) + if isinstance(first, Outcome): + capacity = circuit_count + elif isinstance(first, ReadoutSign): + capacity = len(gadget.readouts) + else: + encodings = gadget.inputs if first.side == "in" else gadget.outputs + entry = first.entry + if entry >= len(encodings): + yield f"{label}: {reference} is out of bounds for {len(encodings)} {first.side} encodings" + continue + property_name = ( + first.basis if isinstance(first, LogicalSign) else "stabilizers" + ) + capacity = len(getattr(encodings[entry].code, property_name)) + if capacity is not None: + invalid = next( + ( + atom.index + for atom in chain((first,), terms) + if atom.index >= capacity + ), + None, + ) + if invalid is not None: + yield f"{label}: {reference} index {invalid} is out of bounds for {capacity} entries" + + +def structural_issues(target: object) -> Iterator[tuple[str, str]]: + if isinstance(target, qc.Code): + if len(target.x) != len(target.z): + yield "", f"logical operator counts disagree: x has {len(target.x)} entries, z has {len(target.z)}" + for kind, operators in ( + ("stabilizers", target.stabilizers), + ("x", target.x), + ("z", target.z), + ): + for index, operator in enumerate(operators): + try: + _pauli_indices(operator) + except ValueError as error: + yield "", f"{kind}[{index}]: {error}" + elif isinstance(target, qc.InstructionSet): + blocks = {block.name: block.encodes for block in target.blocks} + if len(blocks) != len(target.blocks): + yield "", "duplicate block declaration names" + for instruction in target.instructions.values(): + for issue in _instruction_issues(instruction, blocks): + yield "", f"instruction {instruction.mnemonic!r}: {issue}" + elif isinstance(target, qc.Gadget): + from .._frames import FrameMap + + try: + FrameMap(target) + except (ValueError, TypeError, KeyError) as error: + yield "frames", str(error) + for issue in _instruction_issues(target.implements, None): + yield "", issue + for issue in _gadget_issues(target): + yield "", issue + elif isinstance(target, qc.Qodec): + if len(target.layers) < 2: + yield "", f"protocol requires at least two layers, got {len(target.layers)}" + seen_sets: dict[str, qc.InstructionSet] = {} + seen_codes: dict[str, qc.Code] = {} + for index, layer in enumerate(target.layers): + instruction_set = layer.instruction_set + previous_set = seen_sets.get(instruction_set.name) + if previous_set is not None and not _same_instruction_set( + previous_set, instruction_set + ): + yield "", f"conflicting instruction sets named {instruction_set.name!r}" + seen_sets[instruction_set.name] = instruction_set + for _, issue in structural_issues(instruction_set): + yield f"layers[{index}].instruction_set", issue + blocks = {block.name: block.encodes for block in instruction_set.blocks} + bindings: dict[str, qc.Code] = {} + for mnemonic, gadget in layer.gadgets.items(): + path = f"layers[{index}].gadgets[{json.dumps(mnemonic, ensure_ascii=False)}]" + if index + 1 == len(target.layers): + yield "", "the bottom layer has gadgets but no target layer" + elif not _same_instruction_set( + gadget.circuit.instruction_set, + target.layers[index + 1].instruction_set, + ): + yield path, "circuit instruction set differs from the next layer's instruction set" + if mnemonic not in instruction_set.instructions: + yield path, f"instruction {mnemonic!r} is not declared by the layer" + elif gadget.implements != instruction_set.instructions[mnemonic]: + yield path, "implements differs from the layer's instruction" + for encoding in (*gadget.inputs, *gadget.outputs): + code = encoding.code + previous_code = seen_codes.get(code.name) + if previous_code is not None and previous_code != code: + yield "", f"conflicting codes named {code.name!r}" + elif previous_code is None: + for _, issue in structural_issues(code): + yield f"codes[{json.dumps(code.name, ensure_ascii=False)}]", issue + seen_codes[code.name] = code + for encodings, operands in ( + (gadget.inputs, gadget.implements.inputs), + (gadget.outputs, gadget.implements.outputs), + ): + for encoding, operand in zip(encodings, operands): + code = encoding.code + if ( + operand.block in bindings + and bindings[operand.block] != code + ): + yield path, f"block {operand.block!r} is bound to different codes in the layer" + bindings[operand.block] = code + for issue in _gadget_issues(gadget, blocks): + yield path, issue diff --git a/source/qdk_package/qdk/ec/_audit/rules/__init__.py b/source/qdk_package/qdk/ec/_audit/rules/__init__.py new file mode 100644 index 00000000000..9b1cc5d11bb --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/rules/__init__.py @@ -0,0 +1,19 @@ +"""Built-in audit rules grouped by qodec artifact.""" + +from collections.abc import Iterator + +from .._rule import Rule +from .code import RULES as CODE_RULES +from .gadget import RULES as GADGET_RULES +from .instruction_set import RULES as INSTRUCTION_SET_RULES +from .qodec import RULES as QODEC_RULES + + +def default_rules() -> Iterator[Rule]: + yield from INSTRUCTION_SET_RULES + yield from CODE_RULES + yield from GADGET_RULES + yield from QODEC_RULES + + +__all__ = ["default_rules"] diff --git a/source/qdk_package/qdk/ec/_audit/rules/code.py b/source/qdk_package/qdk/ec/_audit/rules/code.py new file mode 100644 index 00000000000..2b83bf3e120 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/rules/code.py @@ -0,0 +1,113 @@ +"""Mathematical consistency of declared code operators.""" + +from collections.abc import Iterator +from dataclasses import dataclass +from itertools import combinations +import json + +import qodec as qc + +from ..._analysis.code_algebra import subsystem_code_of, sparse_paulis_as_bitmatrix +from ..._analysis.propagation.pauli import Pauli +from .._dependencies import row_dependencies +from .._diagnostic import Diagnostic, Phase, Severity + +from .._rule import Rule + + +@dataclass(frozen=True) +class CodeAlgebraRule: + name: str = "code/invalid-algebra" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Code + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if not isinstance(target, qc.Code): + raise TypeError(f"expected qodec.Code, got {type(target).__name__}") + try: + subsystem_code_of(target) + except ValueError as error: + failure = str(error) + else: + return + operators = [ + (kind, index, text, Pauli(text)) + for kind, values in ( + ("stabilizers", target.stabilizers), + ("x", target.x), + ("z", target.z), + ) + for index, text in enumerate(values) + ] + reported = False + for first, second in combinations(operators, 2): + first_kind, first_index, first_text, first_pauli = first + second_kind, second_index, second_text, second_pauli = second + paired = {first_kind, second_kind} == { + "x", + "z", + } and first_index == second_index + if first_pauli.commutes_with(second_pauli) != (not paired): + expected = "anticommute" if paired else "commute" + yield Diagnostic( + self.name, + self.severity, + f"{first_kind}[{first_index}] and {second_kind}[{second_index}] must {expected}", + f"code[{target.name!r}]", + f"{first_kind}[{first_index}]: {first_text}\n{second_kind}[{second_index}]: {second_text}", + ) + reported = True + if not reported: + yield Diagnostic( + self.name, + self.severity, + "Code operators are algebraically inconsistent", + f"code[{target.name!r}]", + failure, + ) + + +@dataclass(frozen=True) +class RedundantStabilizerRule: + name: str = "code/redundant-stabilizer" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.Code + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if not isinstance(target, qc.Code): + raise TypeError(f"expected qodec.Code, got {type(target).__name__}") + try: + subsystem_code_of(target) + except ValueError: + return + operators = [Pauli(text) for text in target.stabilizers] + if not operators: + return + matrix, _ = sparse_paulis_as_bitmatrix(operators) + dependencies = row_dependencies(list(matrix.rows)) + rank = sum(dependency is None for dependency in dependencies) + for index, dependency in enumerate(dependencies): + if dependency is None: + continue + factors = [f"stabilizers[{position}]" for position in dependency] + product = ( + f"Product of generators: {json.dumps(factors)}" + if factors + else "The declared operator is +I." + ) + yield Diagnostic( + self.name, + self.severity, + f"stabilizers[{index}] adds no independent constraint", + f"code[{target.name!r}]", + f"Declared operator: {target.stabilizers[index]}\n{product}\n" + f"Independent stabilizer rank: {rank} of {len(operators)} declared generators.", + _path=f"stabilizers[{index}]", + ) + + +RULES: tuple[Rule, ...] = (CodeAlgebraRule(), RedundantStabilizerRule()) + +__all__ = ["CodeAlgebraRule", "RedundantStabilizerRule", "RULES"] diff --git a/source/qdk_package/qdk/ec/_audit/rules/gadget.py b/source/qdk_package/qdk/ec/_audit/rules/gadget.py new file mode 100644 index 00000000000..bbc1d179742 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/rules/gadget.py @@ -0,0 +1,516 @@ +"""Per-gadget audit rules.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +import json + +from binar import BitVector +import qodec as qc + +from ..._readouts import flag_slots, observe_count_of +from ..._references import StabilizerSign, reference_term +from ..._analysis.channel_action import ( + declared_action_of, + realized_action_of, +) +from ..._analysis.declaration_issues import declaration_issues +from .._dependencies import row_dependencies +from .._diagnostic import Diagnostic, Phase, Severity +from .._parity import ParityAnalysis, terms_of +from .._readout_check import ReadoutMismatch, readout_conflicts, readout_disagreements +from .._rule import Rule + + +def _where(gadget: qc.Gadget) -> str: + return f"gadget[{gadget.implements.mnemonic!r}]" + + +def _gadget(target: object) -> qc.Gadget: + if not isinstance(target, qc.Gadget): + raise TypeError(f"expected qodec.Gadget, got {type(target).__name__}") + return target + + +def _equation(terms: Iterable[object]) -> str: + return json.dumps( + [int(str(term)) if str(term) in ("0", "1") else str(term) for term in terms] + ) + + +def _observable(gadget: qc.Gadget, position: int) -> str: + return str( + [ + observable + for action in gadget.implements.action + if isinstance(action, qc.actions.Observe) + for observable in action.observables + ][position] + ) + + +@dataclass(frozen=True) +class VacuousCheckRule: + name: str = "gadget/vacuous-check" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + for index, equation in enumerate(gadget.checks): + if terms_of(equation): + continue + yield Diagnostic( + self.name, + self.severity, + f"checks[{index}] is identically zero and cannot detect a fault", + _where(gadget), + f"Declared equation: {_equation(equation)}\n" + "All terms cancel, or the equation is empty. This is valid but supplies no constraint.", + _path=f"checks[{index}]", + ) + + +@dataclass(frozen=True) +class RedundantCheckRule: + name: str = "gadget/redundant-check" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + equations = [frozenset(terms_of(check)) for check in gadget.checks] + columns = sorted(set().union(*equations)) + rows = [ + BitVector(path in equation for path in columns) for equation in equations + ] + for index, dependency in enumerate(row_dependencies(rows)): + if not dependency: + continue + yield Diagnostic( + self.name, + self.severity, + f"checks[{index}] is an XOR of earlier checks", + _where(gadget), + f"Declared equation: {_equation(gadget.checks[index])}\n" + f"XOR of checks: {_equation(f'checks[{position}]' for position in dependency)}\n" + "This formal identity holds for every assignment of the referenced bits and signs.", + _path=f"checks[{index}]", + ) + + +@dataclass(frozen=True) +class MissingCheckRule: + name: str = "gadget/missing-check" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + try: + missing = ParityAnalysis(gadget).missing_checks() + except (KeyError, ValueError, TypeError, NotImplementedError) as error: + yield Diagnostic( + self.name, + self.severity, + "Check completeness not checked: analysis unavailable", + _where(gadget), + f"{type(error).__name__}: {error}", + ) + return + for equation in missing: + yield Diagnostic( + self.name, + self.severity, + "An independent measurement check is undeclared", + _where(gadget), + f"Verified relation: {_equation(equation)}\n" + "Not implied by the declared checks, readout definitions, or zero-valued flags.", + ) + + +@dataclass(frozen=True) +class MissingObservableRule: + name: str = "gadget/missing-observable" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + analysis = ParityAnalysis(gadget) + for missing in declaration_issues(gadget).missing_observables: + candidate = analysis.readout_candidate(int(missing)) + yield Diagnostic( + self.name, + self.severity, + f"readouts[{missing}] has no equation for the required logical {_observable(gadget, int(missing))} measurement", + _where(gadget), + ( + f"Verified readout equation: {_equation(candidate)}" + if candidate is not None + else "" + ), + ) + + +@dataclass(frozen=True) +class MissingFlagRule: + name: str = "gadget/missing-flag" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + for missing in declaration_issues(gadget).missing_flags: + position = observe_count_of(gadget.implements) + list( + gadget.implements.flags + ).index(missing) + yield Diagnostic( + self.name, + self.severity, + f"readouts[{position}] has no equation for flag {missing!r}", + _where(gadget), + ) + + +@dataclass(frozen=True) +class UnsupportedActionStepRule: + name: str = "gadget/unsupported-action-step" + severity: Severity = Severity.WARNING + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + unsupported = set(declaration_issues(gadget).unsupported_steps) + for index, action in enumerate(gadget.implements.action): + step_name = type(action).__name__ + if step_name not in unsupported: + continue + if ( + isinstance(action, (qc.actions.Pauli, qc.actions.Clifford)) + and action.condition is None + ): + continue + yield Diagnostic( + self.name, + self.severity, + f"implements.action[{index}] ({step_name}) is not supported by the action verifier", + _where(gadget), + "Logical action not checked.", + _path=f"implements.action[{index}]", + ) + + +@dataclass(frozen=True) +class CheckMismatchRule: + name: str = "gadget/check-mismatch" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + analysis = ParityAnalysis(gadget) + for index, equation in enumerate(analysis.checks): + yield from _zero_parity_diagnostic( + self.name, + gadget, + analysis, + equation, + f"checks[{index}]", + _equation(gadget.checks[index]), + f"checks[{index}]", + ) + + +def _zero_parity_diagnostic( + rule: str, + gadget: qc.Gadget, + analysis: ParityAnalysis, + equation: tuple[str, ...], + label: str, + declared: str, + path: str, +) -> Iterator[Diagnostic]: + if not equation: + return + try: + value = analysis.value(equation) + if value.is_zero: + return + if not any(value[index] for index in range(len(value) - 1)): + summary = f"{label} always fires, even without a fault" + evidence = ( + "Declared equation always produces: 1\nRequired noiseless value: 0" + ) + else: + summary = f"{label} can fire without a fault" + evidence = ( + "Counterexample from noiseless execution with arbitrary incoming frames:\n" + f"Equation term values: {analysis.witness(equation, value)}\n" + "Declared equation produces: 1\nRequired noiseless value: 0" + ) + except (KeyError, ValueError, TypeError, NotImplementedError) as error: + yield Diagnostic( + rule, + Severity.WARNING, + f"{label}: parity not checked", + _where(gadget), + f"Declared equation: {declared}\n{type(error).__name__}: {error}", + _path=path, + ) + return + yield Diagnostic( + rule, + Severity.ERROR, + summary, + _where(gadget), + f"Declared equation: {declared}\n{evidence}", + _path=path, + ) + + +@dataclass(frozen=True) +class FlagMismatchRule: + name: str = "gadget/flag-mismatch" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + analysis = ParityAnalysis(gadget) + slots = [ + slot for slot in flag_slots(gadget) if analysis.readouts[slot.position] + ] + if not slots: + return + try: + resolution = analysis.resolution + conflicts = readout_conflicts(analysis) + except (KeyError, ValueError, TypeError, NotImplementedError) as error: + yield Diagnostic( + self.name, + Severity.WARNING, + "Flags not checked: analysis failed", + _where(gadget), + f"{type(error).__name__}: {error}", + ) + return + observable_count = observe_count_of(gadget.implements) + for conflict in conflicts: + if all(position >= observable_count for position in conflict.positions): + yield _readout_diagnostic(self.name, gadget, conflict) + conflicting = {position for group in resolution.conflicts for position in group} + for slot in slots: + if slot.position in conflicting: + continue + label = f"readouts[{slot.position}] (flag {slot.name!r})" + if slot.position in resolution.blocked: + yield Diagnostic( + self.name, + Severity.WARNING, + f"{label}: value not verified", + _where(gadget), + "This flag depends on inconsistent readout equations.", + _path=f"readouts[{slot.position}].equation", + ) + elif slot.position in resolution.unresolved: + yield Diagnostic( + self.name, + Severity.ERROR, + f"{label} has no well-defined value: equations allow either bit value", + _where(gadget), + f"Declared equation: {_equation(gadget.readouts[slot.position].equation)}\n" + f"readouts[{slot.position}] is not uniquely determined by the defining equations.", + _path=f"readouts[{slot.position}].equation", + ) + else: + yield from _zero_parity_diagnostic( + self.name, + gadget, + analysis, + analysis.readouts[slot.position], + label, + _equation(gadget.readouts[slot.position].equation), + f"readouts[{slot.position}].equation", + ) + + +@dataclass(frozen=True) +class ActionMismatchRule: + name: str = "gadget/action-mismatch" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + if declaration_issues(gadget).unsupported_steps: + return + try: + expected = declared_action_of(gadget) + actual = realized_action_of(gadget) + except (KeyError, ValueError, TypeError, NotImplementedError) as error: + yield Diagnostic( + self.name, + Severity.WARNING, + "Logical action not checked: analysis failed", + _where(gadget), + f"{type(error).__name__}: {error}", + ) + return + if expected.is_equivalent_to(actual): + return + yield Diagnostic( + self.name, + self.severity, + "circuit action differs from implements.action", + _where(gadget), + expected.why_not_equivalent_to(actual), + _path="circuit.source", + ) + + +@dataclass(frozen=True) +class ReadoutMismatchRule: + name: str = "gadget/readout-mismatch" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + try: + mismatches = readout_disagreements(ParityAnalysis(gadget)) + except (KeyError, ValueError, TypeError, NotImplementedError) as error: + yield Diagnostic( + self.name, + Severity.WARNING, + "Readouts not checked: analysis failed", + _where(gadget), + f"{type(error).__name__}: {error}", + ) + return + for mismatch in mismatches: + yield _readout_diagnostic(self.name, gadget, mismatch) + + +def _readout_diagnostic( + rule: str, gadget: qc.Gadget, mismatch: ReadoutMismatch +) -> Diagnostic: + multiple = len(mismatch.positions) > 1 + detail = [] + for position in mismatch.positions: + label = f"readouts[{position}] equation" if multiple else "Declared equation" + detail.append(f"{label}: {_equation(gadget.readouts[position].equation)}") + for position, equation in mismatch.expected_equations.items(): + suffix = f" for readouts[{position}]" if multiple else "" + detail.append(f"Verified readout equation{suffix}: {_equation(equation)}") + if mismatch.reason: + detail.append(mismatch.reason) + return Diagnostic( + rule, + mismatch.severity, + mismatch.summary, + _where(gadget), + "\n".join(detail), + _path=f"readouts[{mismatch.positions[0]}].equation", + ) + + +@dataclass(frozen=True) +class IncompleteOutputFrameRule: + name: str = "gadget/incomplete-output-frame" + severity: Severity = Severity.WARNING + phase: Phase = Phase.SEMANTIC + target: type = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + gadget = _gadget(target) + try: + analysis = ParityAnalysis(gadget) + missing = analysis.unresolved_outputs() + if missing: + _ = analysis.values + except ( + KeyError, + ValueError, + TypeError, + AttributeError, + NotImplementedError, + ) as error: + yield Diagnostic( + self.name, + Severity.WARNING, + "Output frames not checked: analysis failed", + _where(gadget), + f"{type(error).__name__}: {error}", + ) + return + if not missing: + return + for path in missing: + sign = reference_term(path) + assert isinstance(sign, StabilizerSign) + operand, index = sign.entry, sign.index + encoding = gadget.outputs[operand] + expected = analysis.external(path) + relation = analysis.candidate(expected) + offset = relation is None + if offset: + inverted = expected.copy() + inverted[len(inverted) - 1] = not inverted[len(inverted) - 1] + relation = analysis.candidate(inverted) + detail = "No noiseless relation was derived." + if relation is not None: + terms = _equation((path, *relation)) + detail = ( + f"Relation terms: {terms}\nParity: 1 (not a valid zero-parity check)." + if offset + else f"Verified relation: {terms}" + ) + yield Diagnostic( + self.name, + self.severity, + f"Declared relations do not determine {sign} ({encoding.code.stabilizers[index]})", + _where(gadget), + f"Code: {encoding.code.name}; circuit support: {list(encoding.support)}.\n{detail}", + ) + + +RULES: tuple[Rule, ...] = ( + VacuousCheckRule(), + RedundantCheckRule(), + MissingCheckRule(), + MissingObservableRule(), + MissingFlagRule(), + UnsupportedActionStepRule(), + CheckMismatchRule(), + FlagMismatchRule(), + ActionMismatchRule(), + ReadoutMismatchRule(), + IncompleteOutputFrameRule(), +) + +__all__ = [ + "ActionMismatchRule", + "CheckMismatchRule", + "FlagMismatchRule", + "IncompleteOutputFrameRule", + "MissingCheckRule", + "MissingFlagRule", + "MissingObservableRule", + "ReadoutMismatchRule", + "RedundantCheckRule", + "RULES", + "UnsupportedActionStepRule", + "VacuousCheckRule", +] diff --git a/source/qdk_package/qdk/ec/_audit/rules/instruction_set.py b/source/qdk_package/qdk/ec/_audit/rules/instruction_set.py new file mode 100644 index 00000000000..e95142b3e82 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/rules/instruction_set.py @@ -0,0 +1,98 @@ +"""Instruction-set audit rules.""" + +from collections.abc import Iterator +from dataclasses import dataclass + +import qodec as qc + +from ..._analysis.propagation.isa_actions import build_clifford_images +from ..._analysis.propagation.pauli import parse_term +from .._diagnostic import Diagnostic, Phase, Severity +from .._rule import Rule + + +@dataclass(frozen=True) +class UnreferencedBlockRule: + name: str = "instruction-set/unreferenced-block" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.InstructionSet + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if not isinstance(target, qc.InstructionSet): + raise TypeError( + f"expected qodec.InstructionSet, got {type(target).__name__}" + ) + referenced = { + operand.block + for instruction in target.instructions.values() + for operand in (*instruction.inputs, *instruction.outputs) + } + if not referenced: + return + for block in target.blocks: + if block.name not in referenced: + yield Diagnostic( + self.name, + self.severity, + f"block type {block.name!r} is unused by instruction inputs and outputs", + f"isa[{target.name!r}]", + f"Declared capacity: {block.encodes} qubit(s).", + ) + + +@dataclass(frozen=True) +class CliffordAlgebraRule: + name: str = "instruction-set/invalid-clifford" + severity: Severity = Severity.ERROR + phase: Phase = Phase.SEMANTIC + target: type = object + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if isinstance(target, qc.InstructionSet): + instructions = list(target.instructions.values()) + where = f"isa[{target.name!r}]" + elif isinstance(target, qc.Gadget): + instructions = [target.implements] + where = f"gadget[{target.implements.mnemonic!r}]" + else: + return + for instruction in instructions: + for position, action in enumerate(instruction.action): + if not isinstance(action, qc.actions.Clifford): + continue + try: + support = sorted( + { + parse_term(token)[1] + for pair in action.generators.items() + for text in pair + for token in text.split() + } + ) + qubit_map = {index: index for index in support} + local_map = {index: local for local, index in enumerate(support)} + build_clifford_images( + action.generators, qubit_map, local_map, len(support) + ) + except NotImplementedError as error: + yield Diagnostic( + self.name, + Severity.WARNING, + f"instruction {instruction.mnemonic!r} action[{position}]: Clifford algebra not checked", + where, + str(error), + ) + except (KeyError, ValueError) as error: + yield Diagnostic( + self.name, + self.severity, + f"instruction {instruction.mnemonic!r} action[{position}]: invalid Clifford map", + where, + str(error), + ) + + +RULES: tuple[Rule, ...] = (CliffordAlgebraRule(), UnreferencedBlockRule()) + +__all__ = ["CliffordAlgebraRule", "RULES", "UnreferencedBlockRule"] diff --git a/source/qdk_package/qdk/ec/_audit/rules/qodec.py b/source/qdk_package/qdk/ec/_audit/rules/qodec.py new file mode 100644 index 00000000000..c5694186171 --- /dev/null +++ b/source/qdk_package/qdk/ec/_audit/rules/qodec.py @@ -0,0 +1,55 @@ +"""Whole-qodec audit rules.""" + +from collections.abc import Iterator +from dataclasses import dataclass + +import qodec as qc + +from .._diagnostic import Diagnostic, Phase, Severity +from .._rule import Rule +from .._structure import structural_issues + + +@dataclass(frozen=True) +class StructuralValidationRule: + name: str = "qodec/invalid-structure" + severity: Severity = Severity.ERROR + phase: Phase = Phase.STRUCTURAL + target: type = object + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if not isinstance(target, (qc.Qodec, qc.Code, qc.InstructionSet, qc.Gadget)): + return + for path, message in structural_issues(target): + yield Diagnostic(self.name, self.severity, message, path) + + +@dataclass(frozen=True) +class MissingRealizationRule: + name: str = "gadget/missing-realization" + severity: Severity = Severity.INFO + phase: Phase = Phase.INFORMATIONAL + target: type = qc.Qodec + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if not isinstance(target, qc.Qodec): + raise TypeError(f"expected qodec.Qodec, got {type(target).__name__}") + for index, layer in enumerate(target.layers[:-1]): + for mnemonic in layer.instruction_set.instructions: + if mnemonic not in layer.gadgets: + yield Diagnostic( + self.name, + self.severity, + f"No explicit gadget for instruction {mnemonic!r}", + f"layers[{index}].instruction_set.instructions[{mnemonic!r}] " + f"({layer.instruction_set.name} -> {target.layers[index + 1].instruction_set.name})", + f"No entry: layers[{index}].gadgets[{mnemonic!r}]", + ) + + +RULES: tuple[Rule, ...] = ( + StructuralValidationRule(), + MissingRealizationRule(), +) + +__all__ = ["MissingRealizationRule", "StructuralValidationRule", "RULES"] diff --git a/source/qdk_package/qdk/ec/_build.py b/source/qdk_package/qdk/ec/_build.py new file mode 100644 index 00000000000..f8db83b9a85 --- /dev/null +++ b/source/qdk_package/qdk/ec/_build.py @@ -0,0 +1,715 @@ +"""Build a runnable qodec from a bare stabilizer code. + +A :class:`qodec.Code` is a *static* object: it says which Pauli operators +stabilize the codespace and which represent the logical qubits, but it says +nothing about how to prepare, preserve, or read out an encoded state. A +:class:`qodec.Qodec` is the *runnable* artifact: a layered pipeline whose +gadgets lower each logical instruction into a concrete circuit. + +:func:`build_qodec` bridges the two. Given a code, it emits a two-layer +qodec that lowers a logical ISA over the code's ``k`` logical qubits to a +physical stim ISA, with a textbook circuit for each logical instruction: + +================== =========================================================== +instruction built circuit +================== =========================================================== +``prepare_z_all`` reset all data to :math:`|0\\rangle`, then one syndrome round +``prepare_x_all`` reset all data, Hadamard all, then one syndrome round +``syndrome`` one syndrome-extraction round +``measure_z_all`` destructive transversal ``M`` +``measure_x_all`` transversal ``H`` then destructive ``M`` +``h_all`` ``H`` on every data qubit of one block +``cx_all`` ``CX`` between corresponding data qubits of two blocks +================== =========================================================== + +Logical Pauli operations are frame updates, not circuit candidates. The +transversal candidates must realize H on every logical qubit or CNOT between +corresponding logical qubits of the two blocks; verification rejects codes or +logical bases for which these circuits do not implement those actions. + +The flagged strategy adds flag qubits to syndrome extraction. Each stabilizer +gets a syndrome ancilla prepared in :math:`|+\\rangle` and coupled by a controlled +Pauli to every qubit of its support, plus ``t`` nested **flag qubits** that catch +the hook errors +that construction would otherwise admit (see :func:`_syndrome_round`). A single +uncaught ancilla fault would propagate onto several data qubits at once and cap +the circuit at distance 2 no matter how good the code is; the flags make every +such fault announce itself. This is the ``t``-flag construction of Chamberland & +Beverland (arXiv:1708.02246), whose ``t = 1`` case is Chao & Reichardt's +two-extra-qubit circuit for distance-3 codes (arXiv:1705.02329). + +The default ``t`` is ``(d - 1) // 2`` for a code of distance ``d``, which is the +fault-tolerant answer for ``strategy="flagged-css/v1"``. The alternative +``strategy="bare-css/v1"`` sets ``flags=0`` and builds the non-fault-tolerant +circuit without flag qubits or the distance computation. + +Checks and readouts are *not* hand-derived: each built gadget is a draft +that :func:`~qdk.ec._fill.complete_gadget` finishes by exact +simulation. Every finished gadget is then verified with +the internal gadget-action comparison, so an instruction +survives only if its circuit provably realizes the action it declares. See +:ref:`unsupported-instructions` below. + +.. _unsupported-instructions: + +Instructions that cannot be built +--------------------------------------- +Not every logical instruction is available for every code. Some omissions are +mathematical: ``prepare_z_all`` prepares :math:`|0\\rangle^{\\otimes n}` and projects +into the codespace, which pins the logical state only when the code's logical Z +operators are Z-type. The five-qubit code, as conventionally written, declares a +logical Z with X components, so no ``prepare_z_all`` (nor transversal ``measure_z_all``) +exists for that basis, even though an equivalent all-Z representative lives in +the same coset. + +Others are limitations of the surrounding tooling rather than of the code. The +observable-discovery pass that completion relies on is sensitive to the choice +of logical basis: the [[4,2,2]] code admits ``measure_z_all`` when its logical Z +operators are written ``Z_0 Z_2, Z_0 Z_1`` but not when the same code is written +``Z_1 Z_3, Z_2 Z_3``, though the two bases are equally valid. + +A separate gap affects codes whose stabilizers are not all X-type or Z-type. +``measure_z_all`` reads the logical Z operators out of a transversal Z-basis +measurement, and for a CSS code those same outcomes also reconstruct the Z +stabilizers, so the final measurement is self-checking. A non-CSS code's mixed +stabilizers cannot be recovered that way, leaving the last layer of the circuit +unprotected; such codes will not reach their code distance through this +construction even with flags. + +:func:`build_qodec` refuses to guess which case applies: by default an +instruction whose gadget does not complete *and* verify raises. Pass +``strict=False`` instead to keep only the +instructions that survive and record every omission with its reason under the +returned qodec's ``metadata["qdk.ec"]["build"]["omitted"]``. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Optional + +import qodec as qc +from qodec.actions import Clifford, Observe, Stabilize +from qodec.gadgets import Circuit, Encoding +from qodec.instructions import Block, BlockOperand, Instruction, InstructionSet + +from ._analysis.channel_action import gadget_action_mismatch +from ._distance import code_distance_of +from ._analysis.propagation.pauli import Pauli, characters_of +from ._analysis.propagation.pauli_remap import code_qubit_count +from ._fill import complete_gadget +from ._readouts import as_readout +from ._references import as_references + +if TYPE_CHECKING: + from ._code_profile import CodeProfile + +#: Name given to the built physical instruction set. +_PHYSICAL_ISA_NAME = "stim" + +#: Key under which build notes are recorded in the qodec's metadata. +_METADATA_KEY = "qdk.ec" + + +def _characters(text: qc.PauliString) -> dict[int, str]: + """The ``{qubit: character}`` map of a qodec Pauli string.""" + return dict(characters_of(Pauli(str(text)))) + + +def _reject_y_components(code: qc.Code) -> None: + """Raise if any operator has a Y component. + + Y components would need ``S`` / ``S_DAG`` in the physical ISA, whose sign + conventions are not covered by this builder. Every operator is reported + at once so a caller sees the full picture rather than the first offender. + """ + offenders = [ + str(text) + for group in (code.stabilizers, code.x, code.z) + for text in group + if "Y" in set(_characters(text).values()) + ] + if offenders: + raise NotImplementedError( + "build_qodec cannot build circuits for operators with Y " + f"components: {', '.join(sorted(offenders))}. Re-express the code " + "in an X/Z basis, or author the gadgets by hand." + ) + + +def _physical_isa() -> InstructionSet: + """The stim ISA the built gadget circuits target. + + Deliberately small: reset, Hadamard, the two controlled Paulis syndrome + extraction needs, and destructive measurement. Each carries the action + that makes it simulable by + :mod:`qdk.ec._analysis.propagation`. + """ + + def operand() -> BlockOperand: + return BlockOperand("qubit") + + return InstructionSet( + name=_PHYSICAL_ISA_NAME, + blocks=[Block("qubit", encodes=1)], + instructions=[ + Instruction( + "R", + description="Reset to |0⟩.", + outputs=[operand()], + action=[Stabilize(["Z_0"])], + ), + Instruction( + "H", + description="Hadamard.", + inputs=[operand()], + outputs=[operand()], + action=[Clifford({"X_0": "Z_0", "Z_0": "X_0"})], + ), + Instruction( + "CX", + description="Controlled-X.", + inputs=[operand(), operand()], + outputs=[operand(), operand()], + action=[Clifford({"X_0": "X_0 X_1", "Z_1": "Z_0 Z_1"})], + ), + Instruction( + "CZ", + description="Controlled-Z.", + inputs=[operand(), operand()], + outputs=[operand(), operand()], + action=[Clifford({"X_0": "X_0 Z_1", "X_1": "Z_0 X_1"})], + ), + Instruction( + "M", + description="Destructive Z-basis measurement.", + inputs=[operand()], + action=[Observe(["Z_0"])], + ), + ], + ) + + +def _targets(qubits: Iterable[int]) -> str: + return " ".join(str(qubit) for qubit in qubits) + + +def _flag_capacity(weight: int) -> int: + """How many nested flag brackets a weight-``weight`` stabilizer can host. + + Flag ``j`` opens before the ``j``-th coupling and closes after the + ``(w - j)``-th, so the brackets stay properly nested only while + ``j < w - j``. + """ + return max(0, (weight - 1) // 2) + + +def _syndrome_round( + stabilizers: Sequence[qc.PauliString], data_width: int, flags: int +) -> list[str]: + """Stim lines measuring every stabilizer once, with optional flag qubits. + + Each stabilizer gets a syndrome ancilla prepared in :math:`|+\\rangle`, + coupled by a controlled Pauli to every qubit of its support, then rotated + back and measured. Its outcome is the stabilizer's eigenvalue, and no data + qubit is disturbed. + + On its own that circuit is *not* fault tolerant. An X fault on the syndrome + ancilla after the ``i``-th coupling propagates through the remaining + ``w - i`` couplings, leaving a weight-``(w - i)`` **hook error** on the data + from a single fault; the worst case is weight ``⌈w/2⌉``, which drags the + circuit distance down to 2 for essentially any code with weight-4 or larger + stabilizers (Dennis et al. 2002; Chao & Reichardt, arXiv:1705.02329). + + ``flags`` nested flag qubits per stabilizer fix that. Flag ``j`` is a qubit + in :math:`|0\\rangle` linked to the syndrome ancilla by a ``CX`` before the + ``j``-th coupling and another after the ``(w - j)``-th. The pair cancels in + the fault-free case, leaving the flag in :math:`|0\\rangle` and the syndrome + ancilla undisturbed; but an X fault on the ancilla *between* the two + brackets propagates through only the closing ``CX``, flipping the flag. So + every fault that would produce a hook error of weight ≥ 2 also raises a + flag, and the flag outcome is a deterministic bit that the decoder can + condition on as a check. This is the ``t``-flag construction of Chamberland & + Beverland (arXiv:1708.02246, §3.3), of which Chao & Reichardt's + two-extra-qubit ``d = 3`` circuit is the ``t = 1`` case. + + Faults outside the brackets are harmless by construction: one before the + opening ``CX`` propagates onto the stabilizer's whole support, which acts + trivially on the codespace, and one after the closing ``CX`` leaves the data + untouched and only flips the syndrome bit. + """ + lines: list[str] = [] + syndrome_qubits: list[int] = [] + all_flags: list[int] = [] + next_qubit = data_width + for stabilizer in stabilizers: + characters = _characters(stabilizer) + support = sorted(characters) + weight = len(support) + if weight == 0: + continue + flag_count = min(flags, _flag_capacity(weight)) + + syndrome = next_qubit + next_qubit += 1 + flag_qubits = list(range(next_qubit, next_qubit + flag_count)) + next_qubit += flag_count + syndrome_qubits.append(syndrome) + all_flags.extend(flag_qubits) + + # Flag j (1-indexed) brackets the couplings that could leave a hook + # error of weight >= 2 behind. + opens = {index: flag_qubits[index - 1] for index in range(1, flag_count + 1)} + closes = { + weight - index: flag_qubits[index - 1] for index in range(1, flag_count + 1) + } + + lines.append(f"R {syndrome}") + lines.append(f"H {syndrome}") + if flag_qubits: + lines.append(f"R {_targets(flag_qubits)}") + for position, qubit in enumerate(support, start=1): + if position in opens: + lines.append(f"CX {syndrome} {opens[position]}") + gate = "CX" if characters[qubit] == "X" else "CZ" + lines.append(f"{gate} {syndrome} {qubit}") + if position in closes: + lines.append(f"CX {syndrome} {closes[position]}") + lines.append(f"H {syndrome}") + + # Measure the syndrome ancillas first, in stabilizer order, then the flags. + # Keeping the two groups contiguous makes the measurement-record layout + # independent of which stabilizers happen to carry flags, so the record + # index of stabilizer i is always i. + if syndrome_qubits: + lines.append(f"M {_targets(syndrome_qubits)}") + if all_flags: + lines.append(f"M {_targets(all_flags)}") + return lines + + +class _Candidate: + """One logical instruction plus the circuit that is meant to realize it.""" + + def __init__( + self, + instruction: Instruction, + source_lines: list[str], + ) -> None: + self.instruction = instruction + self.source = "\n".join(source_lines) + "\n" if source_lines else "\n" + + @property + def mnemonic(self) -> str: + return self.instruction.mnemonic + + +@dataclass(frozen=True) +class _BuildFailure: + stage: Literal["completion", "verification"] + kind: str + message: str + + def as_metadata(self) -> dict[str, str]: + return { + "stage": self.stage, + "kind": self.kind, + "message": self.message, + } + + def __str__(self) -> str: + return f"{self.stage} {self.kind}: {self.message}" + + +def _candidates( + code: qc.Code, + block: str, + logical_count: int, + data_width: int, + flags: int, +) -> list[_Candidate]: + """Every logical instruction this builder knows how to attempt. + + ``flags`` is the number of nested flag qubits per stabilizer (see + :func:`_syndrome_round`). + """ + + def operand() -> BlockOperand: + return BlockOperand(block) + + stabilizers = list(code.stabilizers) + syndrome = _syndrome_round(stabilizers, data_width, flags) + all_data = _targets(range(data_width)) + order = range(logical_count) + + z_tokens: list[qc.PauliLike] = [f"Z_{index}" for index in order] + x_tokens: list[qc.PauliLike] = [f"X_{index}" for index in order] + z_observables = list(z_tokens) + x_observables = list(x_tokens) + + hadamard_images: dict[qc.PauliLike, qc.PauliLike] = {} + cnot_images: dict[qc.PauliLike, qc.PauliLike] = {} + for index in order: + hadamard_images[f"X_{index}"] = f"Z_{index}" + hadamard_images[f"Z_{index}"] = f"X_{index}" + target = logical_count + index + cnot_images[f"X_{index}"] = f"X_{index} X_{target}" + cnot_images[f"Z_{target}"] = f"Z_{index} Z_{target}" + + return [ + _Candidate( + Instruction( + "prepare_z_all", + description=f"Prepare all {logical_count} logical qubit(s) in |0⟩.", + outputs=[operand()], + action=[Stabilize(z_tokens)], + ), + [f"R {all_data}", *syndrome], + ), + _Candidate( + Instruction( + "prepare_x_all", + description=f"Prepare all {logical_count} logical qubit(s) in |+⟩.", + outputs=[operand()], + action=[Stabilize(x_tokens)], + ), + [f"R {all_data}", f"H {all_data}", *syndrome], + ), + _Candidate( + Instruction( + "syndrome", + description="Measure every stabilizer while preserving the encoded state.", + inputs=[operand()], + outputs=[operand()], + ), + list(syndrome), + ), + _Candidate( + Instruction( + "measure_z_all", + description="Destructively measure every logical qubit in Z.", + inputs=[operand()], + action=[Observe(z_observables)], + ), + [f"M {all_data}"], + ), + _Candidate( + Instruction( + "measure_x_all", + description="Destructively measure every logical qubit in X.", + inputs=[operand()], + action=[Observe(x_observables)], + ), + [f"H {all_data}", f"M {all_data}"], + ), + _Candidate( + Instruction( + "h_all", + description="Hadamard on every logical qubit.", + inputs=[operand()], + outputs=[operand()], + action=[Clifford(hadamard_images)], + ), + [f"H {all_data}"], + ), + _Candidate( + Instruction( + "cx_all", + description="CNOT from the first block to the second, pairing logical qubits.", + inputs=[operand(), operand()], + outputs=[operand(), operand()], + action=[Clifford(cnot_images)], + ), + [f"CX {qubit} {data_width + qubit}" for qubit in range(data_width)], + ), + ] + + +def _draft( + candidate: _Candidate, + instruction: Instruction, + code: qc.Code, + physical: InstructionSet, + data_width: int, +) -> qc.Gadget: + def encodings(operands: Sequence[BlockOperand]) -> list[Encoding]: + return [ + Encoding( + code, + support=[ + str(qubit) + for qubit in range(index * data_width, (index + 1) * data_width) + ], + block_types=["qubit"] * data_width, + ) + for index in range(len(operands)) + ] + + return qc.Gadget( + instruction, + Circuit(physical, candidate.source, format="stim"), + inputs=encodings(instruction.inputs), + outputs=encodings(instruction.outputs), + ) + + +def _rebound(gadget: qc.Gadget, instruction: Instruction) -> qc.Gadget: + """``gadget`` re-pointed at ``instruction``, keeping its completed surface.""" + return qc.Gadget( + instruction, + gadget.circuit, + inputs=list(gadget.inputs), + outputs=list(gadget.outputs), + checks=[as_references(check) for check in gadget.checks], + readouts=[as_readout(entry) for entry in gadget.readouts], + frames=gadget.frames, + parameter_bindings=dict(gadget.parameter_bindings), + metadata=dict(gadget.metadata), + ) + + +def _attempt_candidate( + candidate: _Candidate, + instruction: Instruction, + code: qc.Code, + physical: InstructionSet, + data_width: int, +) -> qc.Gadget | _BuildFailure: + draft = _draft(candidate, instruction, code, physical, data_width) + try: + mismatch = gadget_action_mismatch(draft) + except (KeyError, ValueError, NotImplementedError) as error: + return _BuildFailure( + "verification", + type(error).__name__, + str(error), + ) + if mismatch is not None: + return _BuildFailure("verification", "ActionMismatch", mismatch) + try: + return complete_gadget(draft) + except (KeyError, ValueError, NotImplementedError) as error: + return _BuildFailure( + "completion", + type(error).__name__, + str(error), + ) + + +def _build( + code: qc.Code, + *, + name: Optional[str] = None, + description: Optional[str] = None, + flags: Optional[int] = None, + strict: bool = False, +) -> qc.Qodec: + """Build a runnable qodec that implements ``code``. + + Returns a two-layer qodec: a logical ISA over the code's ``k`` logical + qubits, lowering to a physical stim ISA, with one completed gadget per + logical instruction. See the module docstring for the instruction menu and + the circuit used for each. + + Parameters + ---------- + code: + The stabilizer code to build around. Its stabilizers and logical + operators must be free of Y components. + name: + Name for the resulting qodec and its logical ISA. Defaults to the + code's own name. + description: + Description for the resulting qodec. A summary of the code's parameters + is generated when omitted. + flags: + Nested flag qubits per stabilizer, which is what makes syndrome + extraction fault tolerant (see :func:`_syndrome_round`). Defaults to + ``(d - 1) // 2`` for a code of distance ``d``, the value + Chamberland & Beverland's ``t``-flag construction calls for; this costs + one distance computation. Pass ``0`` for the naive, non-fault-tolerant + circuit, or an explicit count to skip the distance computation. + strict: + When ``True``, raise if any instruction's gadget fails to complete or + to verify. When ``False`` (the default) such instructions are omitted + from the logical ISA and recorded in the qodec's metadata. + + Raises + ------ + NotImplementedError + If any stabilizer or logical operator has a Y component. + ValueError + If the code declares no logical qubits, or if any instruction could not + be built when ``strict=True``. + """ + _reject_y_components(code) + + logical_count = len(list(code.x)) + if logical_count == 0: + raise ValueError( + f"code {code.name!r} declares no logical qubits; there is nothing " + "for a qodec to compute with" + ) + + data_width = code_qubit_count(code) + resolved_name = name or code.name + if not resolved_name: + raise ValueError("code has no name; pass name= explicitly") + + if flags is None: + code_distance, _ = code_distance_of(code) + flags = max(0, (code_distance - 1) // 2) + elif flags < 0: + raise ValueError(f"flags must be non-negative; got {flags}") + + physical = _physical_isa() + block = Block(resolved_name, encodes=logical_count) + candidates = _candidates(code, resolved_name, logical_count, data_width, flags) + + # First pass: draft every candidate against a provisional ISA, then let + # completion and the declared-vs-realized action check decide which + # circuits genuinely implement their instruction. + provisional = InstructionSet( + name=resolved_name, + blocks=[block], + instructions=[candidate.instruction for candidate in candidates], + ) + + completed: list[tuple[_Candidate, qc.Gadget]] = [] + omitted: dict[str, dict[str, str]] = {} + + for candidate in candidates: + attempt = _attempt_candidate( + candidate, + provisional.instructions[candidate.mnemonic], + code, + physical, + data_width, + ) + if isinstance(attempt, _BuildFailure): + if strict: + raise ValueError( + f"could not build {candidate.mnemonic!r} for code " + f"{resolved_name!r}: {attempt}" + ) + omitted[candidate.mnemonic] = attempt.as_metadata() + continue + completed.append((candidate, attempt)) + + if not completed: + raise ValueError( + f"no instruction could be built for code {resolved_name!r}; " + f"reasons: {omitted}" + ) + + # Second pass: rebuild the ISA from the survivors only, so the qodec never + # advertises an instruction it cannot lower. + logical = InstructionSet( + name=resolved_name, + blocks=[Block(resolved_name, encodes=logical_count)], + instructions=[candidate.instruction for candidate, _ in completed], + ) + gadgets = [ + _rebound(gadget, logical.instructions[candidate.mnemonic]) + for candidate, gadget in completed + ] + + metadata: dict[str, object] = { + _METADATA_KEY: { + "build": { + "source": "qdk.ec.build_qodec", + "code": code.name, + "physical_qubits": data_width, + "logical_qubits": logical_count, + "flags_per_stabilizer": flags, + "omitted": omitted, + } + } + } + + built = qc.Qodec( + [qc.Layer(logical, gadgets=gadgets), qc.Layer(physical)], + name=resolved_name, + description=( + description + if description is not None + else ( + f"Built from the {code.name!r} stabilizer code " + f"([[{data_width}, {logical_count}]])." + ) + ), + metadata=metadata, + ) + + from ._audit import audit + + report = audit(built) + if report.diagnostics: + raise ValueError(f"built qodec {resolved_name!r} did not pass audit:\n{report}") + return built + + +def build_qodec( + code: qc.Code | CodeProfile, + *, + name: str | None = None, + description: str | None = None, + strategy: str = "flagged-css/v1", + strict: bool = True, +) -> qc.Qodec: + """Build a two-layer qodec from a bare stabilizer code. + + ``strict`` defaults to ``True``: an instruction whose gadget does not + complete and verify raises rather than being silently omitted. + The default audit reports no errors or warnings for any returned qodec, + even with ``strict=False``. A failed final audit raises ValueError rather + than returning inconsistent declarations. Passing this audit does not + establish fault tolerance; evaluate circuit fault distance separately. + + ``strategy="flagged-css/v1"`` (the default) uses flag qubits during syndrome + extraction. ``strategy="bare-css/v1"`` omits those flags and is not fault + tolerant; it also skips the distance computation used to choose the flag + count. Both strategies attempt the same instructions and verify their + logical actions. The strategy is named in the default qodec description. + + The seven candidate mnemonics are ``prepare_z_all``, ``prepare_x_all``, + ``syndrome``, ``measure_z_all``, ``measure_x_all``, ``h_all``, and ``cx_all``. + ``_all`` applies to all logical qubits in a block; ``cx_all`` pairs + corresponding logical qubits in two blocks. Names describe logical actions, + not circuit implementations. ``syndrome`` measures all stabilizers while + preserving the logical state. + """ + from ._analysis.code_algebra import as_qodec_code + from ._code_profile import CodeProfile + + if strategy not in ("flagged-css/v1", "bare-css/v1"): + raise ValueError(f"unknown qodec construction strategy {strategy!r}") + materialized = ( + as_qodec_code(code._algebra, name or "code") + if isinstance(code, CodeProfile) + else code + ) + return _build( + materialized, + name=name, + description=( + description + if description is not None + else _default_description(materialized, strategy) + ), + flags=0 if strategy == "bare-css/v1" else None, + strict=strict, + ) + + +def _default_description(code: qc.Code, strategy: str) -> str: + physical = code_qubit_count(code) + logical = len(list(code.x)) + return ( + f"Built from the {code.name!r} stabilizer code " + f"([[{physical}, {logical}]]). Strategy: {strategy}." + ) + + +__all__ = ["build_qodec"] diff --git a/source/qdk_package/qdk/ec/_checks.py b/source/qdk_package/qdk/ec/_checks.py new file mode 100644 index 00000000000..bd2dda301f0 --- /dev/null +++ b/source/qdk_package/qdk/ec/_checks.py @@ -0,0 +1,95 @@ +"""Internal deterministic parity analysis for measurement outcomes.""" + +from __future__ import annotations + +from binar import BitMatrix, BitVector +from paulimer import PauliGroup +from qodec.gadgets import Circuit + +from ._analysis.check_discovery import Profile, checks_of, profile_of +from ._analysis.essential_checks import essential_checks_of +from ._analysis.propagation.interpreter import walk_for_outcome_code + + +class OutcomeCode: + """A program's deterministic outcome parities as a classical check matrix.""" + + def __init__(self, check_matrix: BitMatrix) -> None: + self._matrix = check_matrix + + @property + def check_matrix(self) -> BitMatrix: + return self._matrix + + @property + def check_count(self) -> int: + return self._matrix.row_count + + @property + def measurement_count(self) -> int: + return self._matrix.column_count + + def checks(self) -> list[frozenset[int]]: + return [ + frozenset(index for index in range(self._matrix.column_count) if row[index]) + for row in self._matrix.rows + ] + + def __len__(self) -> int: + return self._matrix.row_count + + def __repr__(self) -> str: + return f"OutcomeCode({self.checks()})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, OutcomeCode): + return NotImplemented + return self.checks() == other.checks() + + +def outcome_code_of( + program: Circuit, + input_stabilizers: PauliGroup | None = None, +) -> OutcomeCode: + """Return the classical code formed by ``program``'s deterministic outcomes.""" + stabilizers = ( + list(input_stabilizers.generators) if input_stabilizers is not None else () + ) + result = walk_for_outcome_code(program, stabilizers) + simulation = result.simulation + matrix = simulation.outcome_matrix + total_measurements = matrix.row_count + offset = result.hidden_count + random_indicator = simulation.random_outcome_indicator + measurement_count = result.outcome_count + rank_profile = [ + index for index in range(total_measurements) if random_indicator[index] + ] + if not rank_profile: + return OutcomeCode(BitMatrix.identity(measurement_count)) + deterministic_rows = [ + index + for index in range(offset, total_measurements) + if not random_indicator[index] + ] + rows = [] + for row in deterministic_rows: + bits = [False] * measurement_count + bits[row - offset] = True + for column, measurement in enumerate(rank_profile): + if matrix[row, column] and measurement >= offset: + bits[measurement - offset] = True + rows.append(BitVector(bits)) + if not rows: + return OutcomeCode(BitMatrix.zeros(0, measurement_count)) + return OutcomeCode(BitMatrix(rows)) + + +__all__ = [ + "OutcomeCode", + "Profile", + "checks_of", + "essential_checks_of", + "outcome_code_of", + "profile_of", +] diff --git a/source/qdk_package/qdk/ec/_code_profile.py b/source/qdk_package/qdk/ec/_code_profile.py new file mode 100644 index 00000000000..2589e6c5023 --- /dev/null +++ b/source/qdk_package/qdk/ec/_code_profile.py @@ -0,0 +1,288 @@ +"""Snapshot-based algebraic analysis of qodec code definitions.""" + +from __future__ import annotations + +from typing import Sequence, TYPE_CHECKING + +import qodec as qc +from paulimer import CliffordUnitary + +from ._analysis.code_algebra import ( + _are_equivalent, + _validate_error_support, + subsystem_code_of, +) +from ._analysis.propagation.pauli import Pauli +from ._distance_result import Distance + +if TYPE_CHECKING: + from ._analysis.distance_solvers import BoundsSolver as _BoundsSolver + from ._analysis.distance_solvers import ExactSolver as _ExactSolver + + +class CodeProfile: + """Algebraic analysis of a qodec code's operators. + + The operators are snapshotted at construction. Later changes to the code + do not affect this profile. Derived groups are computed on first access + and cached; distance searches run when requested. The code's name, + description, and persistence remain with qodec. + + Operator collections are tuples of independent Pauli copies. Equality + compares the ordered operator snapshot, not code names or metadata. + Profiles are not hashable; use is_equivalent_to for algebraic equivalence. + X and Z name logical or gauge axes, not the physical Pauli factors. + """ + + def __init__(self, code: qc.Code) -> None: + self._algebra = subsystem_code_of(code) + + @property + def stabilizers(self) -> tuple[Pauli, ...]: + """Physical generators in the snapshotted code.stabilizers order.""" + return tuple(pauli.copy() for pauli in self._algebra.stabilizers) + + @property + def x(self) -> tuple[Pauli, ...]: + """Physical logical-X representatives, indexed by logical qubit as in code.x.""" + return tuple(pauli.copy() for pauli in self._algebra.logical_basis[0::2]) + + @property + def z(self) -> tuple[Pauli, ...]: + """Physical logical-Z representatives, indexed by logical qubit as in code.z.""" + return tuple(pauli.copy() for pauli in self._algebra.logical_basis[1::2]) + + @property + def gauge_x(self) -> tuple[Pauli, ...]: + """Physical gauge-X generators, paired with gauge_z at the same index. + + Indexes identify gauge qubits, not physical labels. The derived basis + is fixed within the snapshot but is not canonical. + """ + return tuple(pauli.copy() for pauli in self._algebra.gauge_basis[0::2]) + + @property + def gauge_z(self) -> tuple[Pauli, ...]: + """Physical gauge-Z generators, paired with gauge_x at the same index. + + Indexes identify gauge qubits, not physical labels. The derived basis + is fixed within the snapshot but is not canonical. + """ + return tuple(pauli.copy() for pauli in self._algebra.gauge_basis[1::2]) + + @property + def support(self) -> frozenset[int]: + """Physical qubit labels in the analyzed operators, not a dense array extent.""" + return self._algebra.support + + @property + def length(self) -> int: + """Number of physical qubits in support, not the largest label plus one.""" + return self._algebra.length + + @property + def logical_qubit_count(self) -> int: + """Number of logical X/Z pairs, not the number of basis generators.""" + return self._algebra.logical_qubit_count + + def syndrome_of(self, error: Pauli) -> frozenset[int]: + """Zero-based positions in ``stabilizers`` that anticommute with a physical error. + + Raise ValueError when the error acts outside support. + """ + _validate_error_support(error, self.support) + return self._algebra.syndrome_of(error) + + def logical_effect_of( + self, error: Pauli, *, including_phase: bool = True + ) -> Pauli: + """Map a physical error to a Pauli on zero-based logical-qubit indexes. + + By default, require a signed logical action: the error must differ from + its logical representative only by a stabilizer and scalar phase. + Nonzero syndrome or a nontrivial gauge component raises ValueError. + + With including_phase=False, return the phase-free component defined by + commutation with the logical basis, even for detectable or gauge errors. + This does not imply a logical failure or assume any recovery. + Errors outside support always raise ValueError. + """ + if including_phase: + return self._algebra.logical_action_of(error, require_phase=True) + return self._algebra.unsigned_logical_action_of(error) + + def is_logical(self, error: Pauli) -> bool: + """Whether a physical error has zero syndrome and a nonidentity logical effect. + + Identity, stabilizers, gauge-only changes, and detectable errors return + False. Global phase is ignored. This is stricter than merely preserving + the code space. Errors outside support raise ValueError. + """ + _validate_error_support(error, self.support) + return self._algebra.is_non_trivial_logical_error(error) + + def distance( + self, + *, + errors: "str | Sequence[Pauli]" = "XYZ", + logical_observable: Pauli | None = None, + upper_bound: int | None = None, + solver: "_ExactSolver | None" = None, + ) -> Distance[Pauli]: + """Return the minimum number of allowed errors and their witness factors. + + The default counts each single-qubit X, Y, or Z error once, giving + ordinary Pauli-weight distance. A string restricts the allowed + single-qubit errors; a sequence supplies explicit errors, including + correlated multi-qubit Paulis, each counted once. The witness remains + a selection of factors, accessible through result.witness.factors; + result.witness.product is their combined Pauli. Select solver="highs" + (the default), "enumeration", or "mwpf". HiGHS is included in qdk[ec]; + MWPF requires a separate pip install mwpf. + A cutoff or an unresolved bound gap raises RuntimeError. A + result with both bounds None means no allowed logical error exists. + + logical_observable restricts failure to flipping that nonidentity, + Hermitian Pauli on zero-based logical-qubit indexes; for example Z_0 + selects logical X_0 or Y_0 errors. None permits any logical failure. + Out-of-support errors and invalid observables raise ValueError. + upper_bound is a search cutoff, not a certified bound; MWPF ignores it. + """ + from ._analysis.distance_solvers import HighsSolverOptions + from ._distance import CodeDistanceData, _pauli_product, distance_result_of + + data = CodeDistanceData.of( + self._algebra, errors, logical_observable=logical_observable + ) + return distance_result_of( + data.odd_cycles, + data.errors, + solver=HighsSolverOptions() if solver is None else solver, + upper_bound=upper_bound, + exact=True, + product=_pauli_product, + copy=Pauli.copy, + ) + + def distance_bounds( + self, + *, + errors: "str | Sequence[Pauli]" = "XYZ", + logical_observable: Pauli | None = None, + upper_bound: int | None = None, + solver: "_BoundsSolver | None" = None, + ) -> Distance[Pauli]: + """Return lower and upper distance bounds and witness factors. + + Uses the same error counting as :meth:`distance`: X, Y, and Z each + cost one by default; an explicit error sequence can include correlated + Paulis. result.witness.factors retains the selection establishing the + upper bound; result.witness.product is its combined Pauli. With no + finite upper bound, result.witness raises LookupError. + Select solver="highs" (the default), "enumeration", or "mwpf". + HiGHS is included in qdk[ec]; MWPF requires a separate pip install mwpf. + None represents infinity in either + bound; both bounds None proves impossibility. Enumeration and HiGHS use + upper_bound as a search cutoff; MWPF ignores it. Backend failures, + invalid witnesses, or unavailable bound certificates + raise RuntimeError rather than returning a partial or uncertified bound. + logical_observable has the same logical-qubit indexes, validation, and + failure meaning as in distance. Errors outside support raise ValueError. + """ + from ._analysis.distance_solvers import HighsSolverOptions + from ._distance import CodeDistanceData, _pauli_product, distance_result_of + + data = CodeDistanceData.of( + self._algebra, errors, logical_observable=logical_observable + ) + return distance_result_of( + data.odd_cycles, + data.errors, + solver=HighsSolverOptions() if solver is None else solver, + upper_bound=upper_bound, + product=_pauli_product, + copy=Pauli.copy, + ) + + def encoding_clifford( + self, *, supported_by: Sequence[int] | None = None + ) -> CliffordUnitary: + """Return the encoding Clifford with physical support mapped to dense indices. + + ``supported_by`` lists every physical qubit label in ``support`` once; + its order assigns positions 0, 1, ... in the returned Clifford. The + default is increasing label order. Repeated, incomplete, or different + support raises ``ValueError``. + """ + if supported_by is not None and ( + len(supported_by) != len(self.support) + or frozenset(supported_by) != self.support + ): + raise ValueError("supported_by must list every physical support label once") + return self._algebra.encoding_clifford(supported_by=supported_by) + + def representative_of(self, pauli: Pauli) -> Pauli: + """Expand a Pauli on zero-based logical-qubit indexes to physical support. + + Use the chosen logical basis and preserve phase. Invalid logical-qubit + indexes raise ValueError. + """ + return self._algebra.representative_of(pauli) + + def is_equivalent_to( + self, + other: "CodeProfile", + *, + including_signs: bool = True, + strict_basis: bool = True, + ) -> bool: + """Compare stabilizer and gauge groups and the chosen logical basis. + + By default signs and logical-basis order matter. Set strict_basis=False + to compare logical groups instead, allowing another basis. + """ + return not self.why_not_equivalent_to( + other, + including_signs=including_signs, + strict_basis=strict_basis, + ) + + def why_not_equivalent_to( + self, + other: "CodeProfile", + *, + including_signs: bool = True, + strict_basis: bool = True, + ) -> str: + """Return the first difference, or "" exactly when is_equivalent_to is True.""" + if self.support != other.support: + return f"Code supports differ: {self.support!r} vs {other.support!r}." + for name, left_group, right_group in ( + ("Stabilizer", self._algebra.stabilizer, other._algebra.stabilizer), + ("Gauge", self._algebra.gauge, other._algebra.gauge), + ): + if not _are_equivalent( + left_group, + right_group, + including_signs=including_signs, + ): + return f"{name} groups differ." + if strict_basis: + left, right = self._algebra.logical_basis, other._algebra.logical_basis + if not including_signs: + left, right = tuple(map(abs, left)), tuple(map(abs, right)) + if left != right: + return "Logical bases differ." + elif not _are_equivalent( + self._algebra.logical, + other._algebra.logical, + including_signs=including_signs, + ): + return "Logical groups differ." + return "" + + def __eq__(self, other: object) -> bool: + return isinstance(other, CodeProfile) and self._algebra == other._algebra + + +__all__ = ["CodeProfile"] diff --git a/source/qdk_package/qdk/ec/_distance.py b/source/qdk_package/qdk/ec/_distance.py new file mode 100644 index 00000000000..80873e7154a --- /dev/null +++ b/source/qdk_package/qdk/ec/_distance.py @@ -0,0 +1,247 @@ +"""Internal code and circuit-fault distance adapters.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from copy import deepcopy +from dataclasses import dataclass +from functools import reduce +from operator import mul +from typing import Optional, Sequence, TypeVar, Union + +import qodec as qc + +from ._analysis.code_algebra import ( + SubsystemCode, + _validate_error_support, + anti_commutation_indicator_of, + one_qubit_errors_on_support, + subsystem_code_of, + syndrome_indicators_of, +) +from ._analysis.distance_solvers import ( + BoundsSolver, + CustomBoundsSolver, + CustomExactSolver, + ExactSolver, + EnumerationSolverOptions, + HighsSolverOptions, + MwpfSolverOptions, +) +from ._analysis.odd_cycles import OddCycles, cycle_labels +from ._analysis.propagation.pauli import Pauli +from ._code_profile import CodeProfile +from ._distance_result import Distance +from ._faults import FaultEffect, FaultEvent + +Errors = Union[str, Sequence[Pauli]] +Factor = TypeVar("Factor") + + +def distance_result_of( + problem: OddCycles, + factors: Sequence[Factor], + *, + solver: ExactSolver, + upper_bound: int | None = None, + coset_indicator: frozenset[int] | None = None, + exact: bool = False, + product: Callable[[tuple[Factor, ...]], Factor], + copy: Callable[[Factor], Factor] = deepcopy, +) -> Distance[Factor]: + snapshot = tuple(copy(factor) for factor in factors) + lower, upper, cycle = problem.bounds(upper_bound, coset_indicator, solver) + if exact and lower != upper: + raise RuntimeError( + f"solver did not prove exact distance: bounds are {lower} and {upper}" + ) + if not cycle: + return Distance._create(None if lower == upper else lower, None) + selected = tuple(sorted(cycle)) + witness = Distance.Witness._create( + tuple(snapshot[index] for index in selected), product=product, copy=copy + ) + + def alternatives() -> Iterator[Distance.Witness[Factor]]: + for selection in problem.witnesses(upper, coset_indicator): + if selection != selected: + yield Distance.Witness._create( + tuple(snapshot[index] for index in selection), + product=product, + copy=copy, + ) + + return Distance._create(lower, upper, witness=witness, alternatives=alternatives) + + +def _pauli_product(factors: tuple[Pauli, ...]) -> Pauli: + return reduce(mul, factors, Pauli.identity()) + + +def _fault_product(factors: tuple[FaultEvent, ...]) -> FaultEvent: + return reduce(mul, factors, FaultEvent()) + + +def _copy_fault(fault: FaultEvent) -> FaultEvent: + """Retain an immutable event snapshot without copying its locations.""" + return fault + + +@dataclass +class _FaultDistanceData: + faults: tuple[FaultEvent, ...] + odd_cycles: OddCycles + + @classmethod + def of( + cls, + faults: tuple[FaultEvent, ...], + effects: Sequence[FaultEffect], + indicators: Sequence[frozenset[int]], + *, + flag_positions: frozenset[int] = frozenset(), + ) -> _FaultDistanceData: + flags = {qc.Reference(f"readouts[{index}]") for index in flag_positions} + references = { + reference for effect in effects for reference in effect._references + } + positions: dict[qc.Reference, int] = {} + for reference in references: + segments = reference.segments + if ( + segments[0] == qc.Reference.Field("checks") + or reference in flags + or segments[-2] == qc.Reference.Field("stabilizers") + ): + positions[reference] = len(positions) + constraints = [ + frozenset( + positions[reference] + for reference in effect._references + if reference in positions + ) + for effect in effects + ] + return cls(faults, OddCycles(constraints, indicators)) + + +def _code_view(code: qc.Code | CodeProfile | SubsystemCode) -> SubsystemCode: + if isinstance(code, qc.Code): + return subsystem_code_of(code) + if isinstance(code, CodeProfile): + return code._algebra + if isinstance(code, SubsystemCode): + return code + raise TypeError(f"expected qodec.Code, got {type(code).__name__}") + + +def _errors_of(code: SubsystemCode, errors: Errors) -> list[Pauli]: + return ( + one_qubit_errors_on_support(code, errors) + if isinstance(errors, str) + else list(errors) + ) + + +@dataclass +class CodeDistanceData: + code: SubsystemCode + errors: list[Pauli] + odd_cycles: OddCycles + + @staticmethod + def of( + code: qc.Code | CodeProfile | SubsystemCode, + errors: Errors = "XYZ", + *, + logical_observable: Pauli | None = None, + ) -> "CodeDistanceData": + view = _code_view(code) + error_paulis = _errors_of(view, errors) + for error in error_paulis: + _validate_error_support(error, view.support) + observables = view.logical_basis + if logical_observable is not None: + if not isinstance(logical_observable, Pauli): + raise TypeError( + f"expected Pauli, got {type(logical_observable).__name__}" + ) + if ( + not logical_observable.weight + or logical_observable.phase not in (1, -1) + ): + raise ValueError( + "logical_observable must be nonidentity and Hermitian" + ) + observables = (view.representative_of(logical_observable),) + return CodeDistanceData( + view, + error_paulis, + OddCycles( + syndrome_indicators_of(view, error_paulis), + [ + anti_commutation_indicator_of(error, observables) + for error in error_paulis + ], + ), + ) + + def parity_indicator(self, operator: Optional[Pauli]) -> Optional[frozenset[int]]: + if operator is None: + return None + return frozenset( + index + for index, logical in enumerate(self.code.logical_basis) + if not logical.commutes_with(operator) + ) + + +def code_distance_of( + code: qc.Code | CodeProfile | SubsystemCode, + *, + errors: Errors = "XYZ", + distance_upper_bound: Optional[int] = None, + coset_representative: Optional[Pauli] = None, + solver: Optional[ExactSolver] = None, +) -> tuple[int, list[Pauli]]: + """Return the minimum allowed-error count and its list of Pauli factors.""" + data = CodeDistanceData.of(code, errors) + size, cycle = data.odd_cycles.shortest( + HighsSolverOptions() if solver is None else solver, + coset_indicator=data.parity_indicator(coset_representative), + cycle_size_upper_bound=distance_upper_bound, + ) + return size, cycle_labels(cycle, data.errors) + + +def code_distance_bounds_of( + code: qc.Code | CodeProfile | SubsystemCode, + *, + errors: Errors = "XYZ", + distance_upper_bound: Optional[int] = None, + coset_representative: Optional[Pauli] = None, + solver: Optional[BoundsSolver] = None, +) -> tuple[int, int, list[Pauli]]: + """Bound the allowed-error count and return factors witnessing the upper bound.""" + data = CodeDistanceData.of(code, errors) + lower, upper, cycle = data.odd_cycles.bounds( + odd_cycle_length_upper_bound=distance_upper_bound, + coset_indicator=data.parity_indicator(coset_representative), + solver=HighsSolverOptions() if solver is None else solver, + ) + return lower, upper, cycle_labels(cycle, data.errors) + + +__all__ = [ + "BoundsSolver", + "CodeDistanceData", + "CustomBoundsSolver", + "CustomExactSolver", + "ExactSolver", + "EnumerationSolverOptions", + "MwpfSolverOptions", + "OddCycles", + "CodeProfile", + "code_distance_bounds_of", + "code_distance_of", +] diff --git a/source/qdk_package/qdk/ec/_distance_result.py b/source/qdk_package/qdk/ec/_distance_result.py new file mode 100644 index 00000000000..d716eda5c2a --- /dev/null +++ b/source/qdk_package/qdk/ec/_distance_result.py @@ -0,0 +1,237 @@ +"""Bounded distance values and lazy, replayable failure witnesses.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from copy import deepcopy +from dataclasses import dataclass +from typing import Generic, TypeVar + +Factor = TypeVar("Factor") +WitnessFactor = TypeVar("WitnessFactor") + + +@dataclass(frozen=True, init=False, repr=False, eq=False, slots=True) +class Distance(Generic[Factor]): + """Certified bounds with witnesses establishing the finite upper bound. + + Both bounds use None for positive infinity. Equal bounds are exact, + including (None, None), which proves no allowed logical failure exists. + Comparisons with integers or other distances use only these bounds and + raise ValueError when unresolved. Comparisons never run a search. + + Obtain results from CodeProfile or GadgetProfile. The search problem is + snapshotted; consuming witnesses does not refine the bounds. + """ + + _lower_bound: int | None + _upper_bound: int | None + _witness: Distance.Witness[Factor] | None + _alternatives: Callable[[], Iterator[Distance.Witness[Factor]]] + + def __new__(cls) -> Distance[Factor]: + raise TypeError("Distance is returned by profile distance searches") + + @classmethod + def _create( + cls, + lower_bound: int | None, + upper_bound: int | None, + *, + witness: Distance.Witness[Factor] | None = None, + alternatives: Callable[[], Iterator[Distance.Witness[Factor]]] = lambda: iter( + () + ), + ) -> Distance[Factor]: + for bound in (lower_bound, upper_bound): + if bound is not None and ( + not isinstance(bound, int) or isinstance(bound, bool) or bound < 0 + ): + raise ValueError("distance bounds must be nonnegative integers or None") + if upper_bound is not None and ( + lower_bound is None or lower_bound > upper_bound + ): + raise ValueError("distance lower bound exceeds its upper bound") + if (upper_bound is None) != (witness is None): + raise ValueError("a finite upper bound requires a witness") + if witness is not None and len(witness._factors) != upper_bound: + raise ValueError("witness factor count differs from the upper bound") + result = object.__new__(cls) + object.__setattr__(result, "_lower_bound", lower_bound) + object.__setattr__(result, "_upper_bound", upper_bound) + object.__setattr__(result, "_witness", witness) + object.__setattr__(result, "_alternatives", alternatives) + return result + + @property + def lower_bound(self) -> int | None: + """Certified lower bound; None means proven infinite distance.""" + return self._lower_bound + + @property + def upper_bound(self) -> int | None: + """Witness factor count, or None when no finite bound is established.""" + return self._upper_bound + + @property + def is_exact(self) -> bool: + return self._lower_bound == self._upper_bound + + @property + def value(self) -> int | None: + """Exact distance, with None for infinity; raise ValueError if unresolved.""" + if not self.is_exact: + raise ValueError(f"distance is not exact: {self}") + return self._lower_bound + + @property + def witness(self) -> Distance.Witness[Factor]: + """Return a retained witness without searching, or raise LookupError.""" + if self._witness is None: + raise LookupError("no finite-distance witness is available") + return self._witness + + @property + def witnesses(self) -> Iterator[Distance.Witness[Factor]]: + """A fresh iterator, yielding the retained witness before lazy alternatives. + + Every selection has the reported upper-bound cost. Selections differ + by original allowed-factor positions, not their order or product. + Exhaustion means all such selections were enumerated; failures raise. + An infinite upper bound gives an empty iterator. Enumeration can be + combinatorially expensive and never changes the result's bounds. + """ + if self._witness is not None: + yield self._witness + yield from self._alternatives() + + def __str__(self) -> str: + lower = "\u221e" if self._lower_bound is None else str(self._lower_bound) + upper = "\u221e" if self._upper_bound is None else str(self._upper_bound) + return lower if self.is_exact else f"[{lower}, {upper}]" + + def __repr__(self) -> str: + return ( + f"Distance(lower_bound={self._lower_bound!r}, " + f"upper_bound={self._upper_bound!r}, witness={self._witness!r})" + ) + + def __format__(self, format_spec: str) -> str: + return format(str(self), format_spec) + + def __bool__(self) -> bool: + raise TypeError("use is_exact or an explicit distance comparison") + + def _compare(self, other: object, operation: str) -> bool: + if isinstance(other, Distance): + other_lower, other_upper = other.lower_bound, other.upper_bound + elif isinstance(other, int) and not isinstance(other, bool): + other_lower = other_upper = other + else: + return NotImplemented + lower, upper = self._lower_bound, self._upper_bound + if operation == "eq": + if self.is_exact and other_lower == other_upper: + return lower == other_lower + if _less(upper, other_lower) or _less(other_upper, lower): + return False + elif operation == "lt": + if _less(upper, other_lower): + return True + if not _less(lower, other_upper): + return False + elif operation == "le": + if not _less(other_lower, upper): + return True + if _less(other_upper, lower): + return False + raise ValueError(f"comparison is unresolved for {self} and {other}") + + def __eq__(self, other: object) -> bool: + return self._compare(other, "eq") + + def __ne__(self, other: object) -> bool: + equal = self._compare(other, "eq") + return NotImplemented if equal is NotImplemented else not equal + + def __lt__(self, other: object) -> bool: + return self._compare(other, "lt") + + def __le__(self, other: object) -> bool: + return self._compare(other, "le") + + def __gt__(self, other: object) -> bool: + less_equal = self._compare(other, "le") + return NotImplemented if less_equal is NotImplemented else not less_equal + + def __ge__(self, other: object) -> bool: + less = self._compare(other, "lt") + return NotImplemented if less is NotImplemented else not less + + @dataclass(frozen=True, init=False, repr=False, eq=False, slots=True) + class Witness(Generic[WitnessFactor]): + """A selection of allowed factors establishing the distance's upper bound. + + Factors are ordered for multiplication. Equality and hashing use that + ordered tuple, not just the product. Neither proves minimality. + Returned factors and products are copies of the retained snapshot. + ``str`` joins the readable factors with semicolons, without repeating + the product; an empty selection is displayed as ``1``. + """ + + _factors: tuple[WitnessFactor, ...] + _product: WitnessFactor + _copy: Callable[[WitnessFactor], WitnessFactor] + + def __new__(cls) -> Distance.Witness[WitnessFactor]: + raise TypeError("Distance.Witness is returned by distance results") + + @classmethod + def _create( + cls, + factors: tuple[WitnessFactor, ...], + *, + product: Callable[[tuple[WitnessFactor, ...]], WitnessFactor], + copy: Callable[[WitnessFactor], WitnessFactor] = deepcopy, + ) -> Distance.Witness[WitnessFactor]: + snapshot = tuple(copy(factor) for factor in factors) + witness = object.__new__(cls) + object.__setattr__(witness, "_factors", snapshot) + object.__setattr__( + witness, "_product", product(tuple(copy(factor) for factor in snapshot)) + ) + object.__setattr__(witness, "_copy", copy) + return witness + + @property + def factors(self) -> tuple[WitnessFactor, ...]: + return tuple(self._copy(factor) for factor in self._factors) + + @property + def product(self) -> WitnessFactor: + return self._copy(self._product) + + def __str__(self) -> str: + return "; ".join(map(str, self._factors)) or "1" + + def __repr__(self) -> str: + return f"Distance.Witness(factors={self._factors!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Distance.Witness): + return NotImplemented + return self._factors == other._factors + + def __ne__(self, other: object) -> bool: + equal = self.__eq__(other) + return NotImplemented if equal is NotImplemented else not equal + + def __hash__(self) -> int: + return hash(self._factors) + + +def _less(left: int | None, right: int | None) -> bool: + return left is not None and (right is None or left < right) + + +__all__ = ["Distance"] diff --git a/source/qdk_package/qdk/ec/_faults.py b/source/qdk_package/qdk/ec/_faults.py new file mode 100644 index 00000000000..ef68f097a75 --- /dev/null +++ b/source/qdk_package/qdk/ec/_faults.py @@ -0,0 +1,563 @@ +"""Internal intrinsic Pauli and readout-fault effects of qodec gadgets.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass + +from binar import BitMatrix, BitVector +import qodec as qc + +from ._analysis.propagation.interpreter import program_of, propagate_faults +from ._analysis.propagation.frames import FrameGroup +from ._frames import FrameMap +from ._references import LogicalSign, ReadoutSign, StabilizerSign, reference_terms +from ._analysis.propagation.pauli import Pauli, relabel +from ._analysis.propagation.pauli_remap import ( + Basis, + encoding_qubit_relocation, + logical_chars, + remap_to_global, +) + + +@dataclass(frozen=True, init=False, repr=False) +class FaultEvent: + """One immutable, deterministic event affecting circuit-call outputs. + + Use ``after`` for quantum errors, readout errors, or both. Multiply events + to combine their changes, including across calls. One event costs one in a + distance search regardless of its weight. The representation is private; + ``repr`` shows equivalent constructor expressions using call-local indexes. + ``str`` uses sparse Paulis and explicit call-local readouts, for example + ``X_0 after call 2`` and ``flip call 2 readout 0``. Parentheses group mixed + events and events spanning multiple calls. + + ``FaultEvent()`` is the identity event. The optional ``locations`` mapping + constructs post-call Pauli errors, with the same call indexes as ``after``. + Input Paulis are copied. The ``locations`` property returns an ordered tuple + of ``FaultEvent.Location`` values describing the combined change at each + affected call, not the history of fault occurrences. Locations are stored + in call-index order; multiplication merges them without sorting again. + """ + + _locations: tuple[FaultEvent.Location, ...] + + def __init__( + self, + locations: Mapping[int, Pauli] | None = None, + ) -> None: + normalized = { + int(location): error + for location, error in (locations or {}).items() + if error.weight + } + object.__setattr__( + self, + "_locations", + tuple( + self.Location._create(call, error, frozenset()) + for call, error in sorted(normalized.items()) + ), + ) + + @classmethod + def _from_sorted_locations( + cls, locations: tuple[FaultEvent.Location, ...] + ) -> "FaultEvent": + event = cls() + object.__setattr__(event, "_locations", locations) + return event + + @classmethod + def after( + cls, + instruction: int, + error: Pauli | None = None, + *, + readout_flips: int | Sequence[int] = (), + ) -> "FaultEvent": + """Affect the outputs of the call at a zero-based Circuit.calls index. + + ``error`` is a post-call Pauli on circuit qubits. ``readout_flips`` is + one index or a sequence of indexes into this call's own readouts, starting + at zero, not the full circuit record or gadget logical readouts. A bit + flip changes the reported result without changing the quantum state. + Booleans are not indexes. Call and readout bounds are checked on replay. + """ + if not isinstance(instruction, int) or isinstance(instruction, bool): + raise TypeError("fault call index must be an integer") + flips = ( + (readout_flips,) if isinstance(readout_flips, int) else tuple(readout_flips) + ) + if any( + not isinstance(index, int) or isinstance(index, bool) for index in flips + ): + raise TypeError("readout indexes must be integers") + pauli = Pauli.identity() if error is None else error + if not pauli.weight and not flips: + return cls() + return cls._from_sorted_locations( + (cls.Location._create(instruction, pauli, frozenset(flips)),) + ) + + @property + def locations(self) -> tuple[FaultEvent.Location, ...]: + """Combined changes ordered by zero-based ``Circuit.calls()`` index. + + Each affected call appears once. Canceled changes are omitted; the + identity event returns an empty tuple. Locations are immutable snapshots + and their ``error`` properties return copies. + """ + return self._locations + + @property + def weight(self) -> int: + """Sum of Pauli weights and recorded-bit flips, not distance-search cost.""" + return sum( + location._error.weight + len(location.readout_flips) + for location in self._locations + ) + + def __mul__(self, other: "FaultEvent") -> "FaultEvent": + if not isinstance(other, FaultEvent): + return NotImplemented + combined = [] + left_index = right_index = 0 + while left_index < len(self._locations) and right_index < len(other._locations): + left = self._locations[left_index] + right = other._locations[right_index] + if left.after_call < right.after_call: + combined.append(left) + left_index += 1 + elif right.after_call < left.after_call: + combined.append(right) + right_index += 1 + else: + error = left._error * right._error + flips = left.readout_flips ^ right.readout_flips + if error.weight or flips: + combined.append( + self.Location._create(left.after_call, error, flips) + ) + left_index += 1 + right_index += 1 + combined.extend(self._locations[left_index:]) + combined.extend(other._locations[right_index:]) + return type(self)._from_sorted_locations(tuple(combined)) + + def __hash__(self) -> int: + return hash(self._locations) + + def __str__(self) -> str: + parts = [] + for location in self._locations: + error, flips = location._error, location.readout_flips + changes = ( + [f"{error:sparse,ascii} after call {location.after_call}"] + if error.weight + else [] + ) + if flips: + label = "readout" if len(flips) == 1 else "readouts" + changes.append( + f"flip call {location.after_call} {label} {', '.join(map(str, sorted(flips)))}" + ) + description = "; ".join(changes) + if len(changes) > 1: + description = f"({description})" + parts.append(description) + if not parts: + return "no fault" + description = "; ".join(parts) + return f"({description})" if len(parts) > 1 else description + + def __repr__(self) -> str: + name = type(self).__name__ + parts = [] + for location in self._locations: + error, flips = location._error, location.readout_flips + arguments = [str(location.after_call)] + if error.weight: + arguments.append(f"Pauli({str(error)!r})") + if flips: + readouts = sorted(flips) + arguments.append( + f"readout_flips={readouts[0] if len(readouts) == 1 else readouts}" + ) + parts.append(f"{name}.after({', '.join(arguments)})") + return " * ".join(parts) if parts else f"{name}()" + + @dataclass(frozen=True, init=False, repr=False, slots=True) + class Location: + """The combined Pauli error and readout flips after one circuit call. + + Obtain locations from ``FaultEvent.locations``. Equality and hashing + compare the call index, Pauli error, and readout flips, not circuit + ownership. Mutating a returned Pauli does not change this location. + """ + + _after_call: int + _error: Pauli + _readout_flips: frozenset[int] + + def __new__(cls) -> FaultEvent.Location: + raise TypeError("FaultEvent.Location is returned by FaultEvent.locations") + + @classmethod + def _create( + cls, after_call: int, error: Pauli, readout_flips: frozenset[int] + ) -> FaultEvent.Location: + location = object.__new__(cls) + object.__setattr__(location, "_after_call", after_call) + object.__setattr__(location, "_error", error.copy()) + object.__setattr__(location, "_readout_flips", readout_flips) + return location + + @property + def after_call(self) -> int: + """Zero-based ``Circuit.calls()`` index after which this fault is applied.""" + return self._after_call + + @property + def error(self) -> Pauli: + """Copy of the post-call Pauli on circuit qubits; identity for readout-only faults.""" + return self._error.copy() + + @property + def readout_flips(self) -> frozenset[int]: + """Flipped indexes within this call's own readouts, starting at zero.""" + return self._readout_flips + + def __repr__(self) -> str: + return ( + f"FaultEvent.Location(after_call={self.after_call}, " + f"error=Pauli({str(self._error)!r}), " + f"readout_flips={self.readout_flips!r})" + ) + + +@dataclass(frozen=True, init=False, repr=False, slots=True) +class FaultEffect: + """An immutable set of changed checks, readouts, and output signs. + + References are relative to the analyzed gadget snapshot: ``checks[i]``, + ``readouts[i]`` (including flags), and ``out[k].{x,z,stabilizers}[i]``. + Output signs include declared frame corrections. They describe changes + from fault-free execution, not changes to the gadget's equations. + + Construction expands final selectors and deduplicates canonical targets; + bounds require a gadget and are checked by analysis or resolution. Iteration + yields References in numeric order: checks, readouts, then outputs by block + and x/z/stabilizer index. Membership requires one selected target. + + XOR combines effects against the same target layout. Equality and hashing + compare targets only, not gadget ownership. An empty effect means no recorded + change, not no fault; a nonempty effect need not be a logical failure. + """ + + _references: frozenset[qc.Reference] + + def __init__(self, references: Iterable[qc.ReferenceLike] = ()) -> None: + if isinstance(references, (str, qc.Reference)): + raise TypeError("expected an iterable of references, not one reference") + object.__setattr__( + self, + "_references", + frozenset( + expanded + for reference in references + for expanded in _effect_references(reference) + ), + ) + + @classmethod + def _from_references(cls, references: Iterable[qc.Reference]) -> FaultEffect: + effect = cls() + object.__setattr__(effect, "_references", frozenset(references)) + return effect + + @property + def checks(self) -> tuple[qc.Reference, ...]: + """Changed check references, in increasing check-index order.""" + return tuple( + reference + for reference in self + if reference.segments[0] == qc.Reference.Field("checks") + ) + + @property + def readouts(self) -> tuple[qc.Reference, ...]: + """Changed readout references, including flags, in increasing index order.""" + return tuple( + reference + for reference in self + if reference.segments[0] == qc.Reference.Field("readouts") + ) + + @property + def frames(self) -> tuple[qc.Reference, ...]: + """Changed output signs, ordered by block, x/z/stabilizers, then index.""" + return tuple( + reference + for reference in self + if reference.segments[0] == qc.Reference.Field("out") + ) + + def __contains__(self, reference: qc.ReferenceLike) -> bool: + references = _effect_references(reference) + first = next(references) + if next(references, None) is not None: + raise ValueError("membership requires a single target") + return first in self._references + + def __iter__(self) -> Iterator[qc.Reference]: + return iter(sorted(self._references, key=_effect_order)) + + def __len__(self) -> int: + return len(self._references) + + def __xor__(self, other: FaultEffect) -> FaultEffect: + if not isinstance(other, FaultEffect): + return NotImplemented + return type(self)._from_references(self._references ^ other._references) + + def __str__(self) -> str: + return repr([reference.path for reference in self]) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self})" + + +def _effect_references(reference: qc.ReferenceLike) -> Iterator[qc.Reference]: + """Validate effect targets and let qodec expand and canonicalize their paths.""" + parsed = ( + reference if isinstance(reference, qc.Reference) else qc.Reference(reference) + ) + segments = parsed.segments + match segments[:-1]: + case (qc.Reference.Field("checks" | "readouts"),): + pass + case ( + qc.Reference.Field("out"), + qc.Reference.Index(), + qc.Reference.Field("x" | "z" | "stabilizers"), + ) | ( + qc.Reference.Field("out"), + qc.Reference.Index(), + qc.Reference.Field("code"), + qc.Reference.Field("x" | "z" | "stabilizers"), + ): + pass + case _: + raise ValueError(f"unsupported fault-effect target {parsed.path!r}") + match segments[-1]: + case qc.Reference.Index() | qc.Reference.Slice() | qc.Reference.Union(): + return iter(parsed.expand()) + case _: + raise ValueError(f"fault-effect target {parsed.path!r} requires an index") + + +def _effect_order(reference: qc.Reference) -> tuple[int, int, int, int]: + match reference.segments: + case ( + qc.Reference.Field(("checks" | "readouts") as field), + qc.Reference.Index(index), + ): + return (0 if field == "checks" else 1, 0, 0, index) + case ( + qc.Reference.Field("out"), + qc.Reference.Index(entry), + qc.Reference.Field(("x" | "z" | "stabilizers") as field), + qc.Reference.Index(index), + ): + return (2, entry, ("x", "z", "stabilizers").index(field), index) + case _: + raise ValueError(f"unsupported fault-effect target {reference.path!r}") + + +def fault_effects_of( + gadget: qc.Gadget, basis: Sequence[FaultEvent] +) -> tuple[FaultEffect, ...]: + """Map an explicit Pauli/readout fault basis to probability-free effects. + + Positionally aligned with ``basis``. The whole basis is evaluated in one + simulation, which is why there is no single-fault entry point. + """ + return _gadget_fault_data(gadget, basis)[0] + + +def _gadget_fault_data( + gadget: qc.Gadget, + basis: Sequence[FaultEvent], + *, + observables: FrameGroup = FrameGroup(()), +) -> tuple[tuple[FaultEffect, ...], tuple[frozenset[int], ...]]: + """Return effects and extra probe flips from one propagation.""" + fault_basis = tuple(basis) + if not fault_basis: + return (), () + + program = program_of(gadget) + z_probes, z_layout = _build_basis_probes(gadget.outputs, "Z") + x_probes, x_layout = _build_basis_probes(gadget.outputs, "X") + stabilizer_probes = [] + stabilizer_paths = [] + for entry, encoding in enumerate(gadget.outputs): + relocation = encoding_qubit_relocation(encoding) + for index, operator in enumerate(encoding.code.stabilizers): + stabilizer_probes.append(relabel(Pauli(operator), relocation)) + stabilizer_paths.append(f"out[{entry}].stabilizers[{index}]") + probes = z_probes + x_probes + stabilizer_probes + deltas, hidden_count, outcome_count = propagate_faults( + program, + fault_basis, + probes + [item.pauli for item in observables.generators], + residual_frames=[frozenset() for _ in probes] + + [item.frame for item in observables.generators], + ) + paths = [ + *(f"circuit.readouts[{index}]" for index in range(outcome_count)), + *(f"out[{entry}].z[{index}]" for entry, index in z_layout), + *(f"out[{entry}].x[{index}]" for entry, index in x_layout), + *stabilizer_paths, + ] + values = { + path: BitVector( + bool(deltas[hidden_count + row, fault]) for fault in range(len(fault_basis)) + ) + for row, path in enumerate(paths) + } + frames = FrameMap(gadget) + zero = BitVector.zeros(len(fault_basis)) + values["1"] = zero + frame_values = frames.evaluate(values, zero) + for target, correction in frame_values.items(): + values[target] = values[target] ^ correction + for index, path in enumerate(paths[outcome_count:]): + for fault in range(len(fault_basis)): + deltas[hidden_count + outcome_count + index, fault] = values[path][fault] + for index, observable in enumerate(observables.generators): + correction = frames.for_probe(observable.pauli, values, zero) + for fault in correction.support: + deltas[hidden_count + outcome_count + len(probes) + index, fault] ^= True + checks, readouts = _parity_effects(gadget, values, len(fault_basis)) + columns = ( + [ + (qc.Reference(f"checks[{index}]"), value) + for index, value in enumerate(checks) + ] + + [ + (qc.Reference(f"readouts[{index}]"), value) + for index, value in enumerate(readouts) + ] + + [(qc.Reference(path), values[path]) for path in paths[outcome_count:]] + ) + effects = tuple( + FaultEffect._from_references( + reference for reference, value in columns if value[fault_index] + ) + for fault_index in range(len(fault_basis)) + ) + indicators = _probe_flips( + deltas, + hidden_count + outcome_count + len(probes), + len(observables.generators), + len(fault_basis), + ) + return effects, indicators + + +def _probe_flips( + deltas: BitMatrix, offset: int, probe_count: int, fault_count: int +) -> tuple[frozenset[int], ...]: + return tuple( + frozenset( + probe for probe in range(probe_count) if deltas[offset + probe, fault] + ) + for fault in range(fault_count) + ) + + +def _parity_effects( + gadget: qc.Gadget, values: Mapping[str, BitVector], fault_count: int +) -> tuple[list[BitVector], list[BitVector]]: + count = len(gadget.readouts) + + def equation( + references: Sequence[qc.Reference | int], + ) -> tuple[BitVector, BitVector]: + external = BitVector.zeros(fault_count) + readouts = BitVector.zeros(count) + for reference in references: + if isinstance(reference, int): + continue + for term in reference_terms(reference): + if isinstance(term, ReadoutSign): + if term.index >= count: + raise ValueError(f"readout reference {term} is out of bounds") + readouts[term.index] = not readouts[term.index] + continue + if isinstance(term, (LogicalSign, StabilizerSign)): + encodings = gadget.inputs if term.side == "in" else gadget.outputs + entry = term.entry + field = ( + term.basis if isinstance(term, LogicalSign) else "stabilizers" + ) + if entry >= len(encodings) or term.index >= len( + getattr(encodings[entry].code, field) + ): + raise ValueError(f"encoding reference {term} is out of bounds") + if term.side == "in": + continue + path = str(term) + if path not in values: + raise ValueError(f"circuit reference {term} is out of bounds") + external = external ^ values[path] + return external, readouts + + definitions = [equation(readout.equation) for readout in gadget.readouts] + readout_values = [value for value, _ in definitions] + if any(dependencies.weight for _, dependencies in definitions): + matrix = BitMatrix.zeros(count, count + fault_count) + for index, (value, dependencies) in enumerate(definitions): + for dependency in dependencies.support: + matrix[index, dependency] = True + matrix[index, index] = not matrix[index, index] + for fault in range(fault_count): + matrix[index, count + fault] = value[fault] + if matrix.echelonize() != list(range(count)): + raise ValueError( + "readout equations do not uniquely determine fault effects" + ) + readout_values = list( + matrix.submatrix( + list(range(count)), list(range(count, count + fault_count)) + ).rows + ) + checks = [] + for check in gadget.checks: + value, dependencies = equation(check) + for index in dependencies.support: + value = value ^ readout_values[index] + checks.append(value) + return checks, readout_values + + +def _build_basis_probes( + encodings: Sequence[qc.gadgets.Encoding], basis: Basis +) -> tuple[list[Pauli], list[tuple[int, int]]]: + probes = [] + layout = [] + for entry, encoding in enumerate(encodings): + relocation = encoding_qubit_relocation(encoding) + for index, characters in enumerate(logical_chars(encoding.code, basis)): + probes.append(remap_to_global(characters, relocation)) + layout.append((entry, index)) + return probes, layout + + +__all__ = [ + "FaultEffect", + "FaultEvent", +] diff --git a/source/qdk_package/qdk/ec/_fill.py b/source/qdk_package/qdk/ec/_fill.py new file mode 100644 index 00000000000..09c675fc490 --- /dev/null +++ b/source/qdk_package/qdk/ec/_fill.py @@ -0,0 +1,164 @@ +"""Fill in checks and readout equations for draft qodec gadgets.""" + +from __future__ import annotations + +from typing import overload + +import qodec as qc + +from ._audit._parity import ParityAnalysis, terms_of +from ._readouts import as_readout, set_gadget_readouts +from ._references import as_references +from ._checks import profile_of + + +def complete_gadget(gadget: qc.Gadget) -> qc.Gadget: + """Return a copy of ``gadget`` with discovered checks and readouts. + + Pauli-bearing instruction outputs and output stabilizer relations are + derived with arbitrary incoming Pauli frames. Flag bindings cannot be + inferred and are preserved from the draft. The input gadget and all objects + it references are left unchanged. + Verified authored equations without readout aliases are retained, including + incoming-frame corrections and input-to-output stabilizer relations. + """ + discovered = profile_of(gadget) + completed = qc.Gadget( + gadget.implements, + gadget.circuit, + inputs=list(gadget.inputs), + outputs=list(gadget.outputs), + checks=[as_references(check) for check in discovered.checks], + readouts=[as_readout(value) for value in gadget.readouts], + frames=gadget.frames, + parameter_bindings=dict(gadget.parameter_bindings), + metadata=dict(gadget.metadata), + ) + set_gadget_readouts(completed, discovered.readouts) + _preserve_verified_equations(gadget, completed) + _complete_frame_equations(completed) + return completed + + +def _complete_frame_equations(gadget: qc.Gadget) -> None: + analysis = ParityAnalysis(gadget) + readouts = [as_readout(readout) for readout in gadget.readouts] + for position, expected in analysis.expected.items(): + if analysis.value(analysis.readouts[position]) == expected: + continue + equation = analysis.candidate(expected) + if equation is None: + raise ValueError(f"readouts[{position}] has no frame-aware equation") + readouts[position] = [ + 1 if term == "1" else qc.Reference(term) for term in equation + ] + gadget.readouts = readouts + + analysis = ParityAnalysis(gadget) + checks = list(gadget.checks) + for path in analysis.unresolved_outputs(): + equation = analysis.candidate(analysis.external(path)) + if equation is None: + raise ValueError(f"{path} has no frame-aware equation") + checks.append( + tuple( + 1 if term == "1" else qc.Reference(term) for term in (path, *equation) + ) + ) + gadget.checks = checks + + +def _preserve_verified_equations(gadget: qc.Gadget, completed: qc.Gadget) -> None: + analysis = ParityAnalysis(completed) + checks = list(completed.checks) + seen = {frozenset(terms_of(check)) for check in checks} + for check in gadget.checks: + terms = terms_of(check) + if any(term.startswith("readouts[") for term in terms): + continue + try: + verified = analysis.value(terms).is_zero + except (ValueError, NotImplementedError): + continue + if verified and frozenset(terms) not in seen: + checks.append(check) + seen.add(frozenset(terms)) + completed.checks = checks + readouts = [as_readout(readout) for readout in completed.readouts] + for index, readout in enumerate(gadget.readouts): + if readout.is_flag or index >= len(readouts): + continue + terms = terms_of(readout.equation) + if any(term.startswith("readouts[") for term in terms): + continue + try: + expected = analysis.expected.get(index) + verified = expected is not None and analysis.value(terms) == expected + except (ValueError, NotImplementedError): + continue + if verified: + readouts[index] = as_readout(readout) + completed.readouts = readouts + + +def complete_qodec(qodec: qc.Qodec) -> qc.Qodec: + """Return a copy of ``qodec`` with every gadget completed. + + Applies :func:`complete_gadget` to each gadget of each layer, so the + returned qodec carries the checks and observable bindings that exact + simulation can derive. Layers whose gadgets all fail to complete are left + untouched; a gadget whose circuit cannot be simulated is re-raised with its + mnemonic attached so the offending draft is easy to find. + + The input qodec and every object it references are left unchanged. + """ + layers = [] + for index, layer in enumerate(qodec.layers): + completed: list[qc.Gadget] = [] + for mnemonic, gadget in layer.gadgets.items(): + completed.append(_try_complete_gadget(gadget, index, mnemonic)) + layers.append(qc.Layer(layer.instruction_set, gadgets=completed)) + return qc.Qodec( + layers, + name=qodec.name, + description=qodec.description, + schema_version=qodec.schema_version, + metadata=dict(qodec.metadata), + ) + + +@overload +def filled(target: qc.Gadget) -> qc.Gadget: ... + + +@overload +def filled(target: qc.Qodec) -> qc.Qodec: ... + + +def filled(target: qc.Gadget | qc.Qodec) -> qc.Gadget | qc.Qodec: + """Return a new artifact with derived checks and readout bindings. + + The input and all objects it references are left unchanged. Checks and + observable readout equations may be recomputed, including supplied + equations. Flag bindings are preserved, not inferred. + """ + if isinstance(target, qc.Gadget): + return complete_gadget(target) + if isinstance(target, qc.Qodec): + return complete_qodec(target) + raise TypeError( + f"expected qodec.Gadget or qodec.Qodec, got {type(target).__name__}" + ) + + +def _try_complete_gadget(gadget: qc.Gadget, index: int, mnemonic: str) -> qc.Gadget: + """Enrich a gadget completion error with its location within a qodec.""" + try: + return complete_gadget(gadget) + except Exception as error: # noqa: BLE001 - preserve the original as the cause + raise RuntimeError( + f"failed to fill layer {index} gadget {mnemonic!r}" + ) from error + + +__all__ = ["filled"] diff --git a/source/qdk_package/qdk/ec/_frames.py b/source/qdk_package/qdk/ec/_frames.py new file mode 100644 index 00000000000..cf4f2bb6f75 --- /dev/null +++ b/source/qdk_package/qdk/ec/_frames.py @@ -0,0 +1,126 @@ +"""Explicit additional output-sign corrections shared by action and fault analysis.""" + +from collections.abc import Mapping + +from binar import BitVector +import qodec as qc + +from ._analysis.propagation.pauli import Pauli, relabel +from ._analysis.propagation.pauli_remap import encoding_qubit_relocation +from ._references import ( + LogicalSign, + Outcome, + ReadoutSign, + reference_term, + reference_terms, +) + + +class FrameMap: + """Resolve additional output-sign corrections from circuit readouts and + integer bits 0 or 1, directly or through readout aliases resolving entirely + to those terms. The circuit determines normal frame transport. + """ + + def __init__(self, gadget: qc.Gadget) -> None: + self.equations: dict[str, tuple[str, ...]] = {} + self.paulis: dict[str, Pauli] = {} + for target, equation in gadget.frames.items(): + reference = reference_term(target) + if not isinstance(reference, LogicalSign) or reference.side != "out": + raise ValueError( + f"frames[{target!r}]: target must be one output logical X/Z sign" + ) + entry, index = reference.entry, reference.index + if entry >= len(gadget.outputs): + raise ValueError(f"frames[{target!r}]: output entry is out of bounds") + encoding = gadget.outputs[entry] + operators = getattr(encoding.code, reference.basis) + if index >= len(operators): + raise ValueError(f"frames[{target!r}]: logical index is out of bounds") + path = str(reference) + if path in self.equations: + raise ValueError(f"frames[{target!r}]: duplicate output sign {path}") + terms: dict[str, None] = {} + for term in equation: + for resolved in self._terms(gadget, term, frozenset()): + if resolved in terms: + del terms[resolved] + else: + terms[resolved] = None + self.equations[path] = tuple(terms) + dual = encoding.code.z if reference.basis == "x" else encoding.code.x + if index >= len(dual): + raise ValueError( + f"frames[{target!r}]: code has no conjugate logical operator" + ) + self.paulis[path] = relabel( + Pauli(str(dual[index])), encoding_qubit_relocation(encoding) + ) + + @staticmethod + def _terms( + gadget: qc.Gadget, + reference: qc.Reference | int, + visiting: frozenset[int], + ): + if type(reference) is int: + if reference not in (0, 1): + raise ValueError("frame constants must be integer bits 0 or 1") + if reference: + yield "1" + return + if not isinstance(reference, qc.Reference): + raise ValueError("frame terms must be references or integer bits 0 or 1") + for term in reference_terms(reference): + if isinstance(term, ReadoutSign): + if term.index >= len(gadget.readouts): + raise ValueError( + f"frame reference {term}: readout is out of bounds" + ) + if term.index in visiting: + raise ValueError( + f"frame reference {term}: cyclic readout definition" + ) + for dependency in gadget.readouts[term.index].equation: + yield from FrameMap._terms( + gadget, dependency, visiting | {term.index} + ) + elif isinstance(term, Outcome): + if term.index >= len(gadget.circuit.readouts): + raise ValueError( + f"frame reference {term}: circuit readout is out of bounds" + ) + yield str(term) + elif term.side == "in": + raise ValueError( + f"frame reference {term}: incoming signs are not allowed in frame deltas" + ) + else: + raise ValueError( + f"frame reference {term}: output signs are not available classical bits" + ) + + def evaluate( + self, values: Mapping[str, BitVector], zero: BitVector + ) -> dict[str, BitVector]: + result = {} + for target, terms in self.equations.items(): + value = zero.copy() + for term in terms: + if term not in values: + raise NotImplementedError( + f"frame input {term} is unavailable for this analysis" + ) + value = value ^ values[term] + result[target] = value + return result + + def for_probe( + self, probe: Pauli, values: Mapping[str, BitVector], zero: BitVector + ) -> BitVector: + result = zero.copy() + for target, value in self.evaluate(values, zero).items(): + if not probe.commutes_with(self.paulis[target]): + result = result ^ value + return result diff --git a/source/qdk_package/qdk/ec/_layout.py b/source/qdk_package/qdk/ec/_layout.py new file mode 100644 index 00000000000..f5690cb81d5 --- /dev/null +++ b/source/qdk_package/qdk/ec/_layout.py @@ -0,0 +1,115 @@ +"""Circuit-level placement of symbolic block instances onto logical qubits.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import qodec as qc +from qodec.gadgets import Circuit + +from ._operands import QubitLabel, qubit_labels + + +@dataclass(frozen=True) +class ProgramLayout: + """Stable logical-qubit ranges for the block instances in a program.""" + + program: Circuit + instance_bases: dict[QubitLabel, int] + total_qubits: int + + @classmethod + def of(cls, program: Circuit) -> "ProgramLayout": + blocks = {block.name: block for block in program.instruction_set.blocks} + bindings: list[tuple[QubitLabel, int]] = [] + for call in program.calls(): + instruction = program.instruction_set.instructions[call.mnemonic] + pairs = [ + *cls._bound_operands(instruction.inputs, call.operands), + *cls._bound_operands(instruction.outputs, call.operands), + ] + for operand, value in pairs: + try: + block = blocks[operand.block] + except KeyError as error: + raise ValueError( + f"call {call.mnemonic!r} uses operand block " + f"{operand.block!r}; ISA has blocks {sorted(blocks)}" + ) from error + bindings.extend( + (instance, int(block.encodes)) for instance in qubit_labels(value) + ) + + widths: dict[QubitLabel, int] = {} + for instance, width in bindings: + previous = widths.setdefault(instance, width) + if previous != width: + raise ValueError( + f"block instance {instance!r} is used with widths " + f"{previous} and {width}" + ) + + instance_bases: dict[QubitLabel, int] = {} + for instance, width in widths.items(): + if isinstance(instance, int): + instance_bases[instance] = instance * width + next_qubit = max( + (base + widths[instance] for instance, base in instance_bases.items()), + default=0, + ) + for instance, width in bindings: + if instance in instance_bases: + continue + instance_bases[instance] = next_qubit + next_qubit += width + return cls(program, instance_bases, next_qubit) + + def call_qubit_map(self, call: qc.instructions.InstructionCall) -> dict[int, int]: + """Map one call's flat action indices to program logical qubits.""" + instruction = self.program.instruction_set.instructions[call.mnemonic] + inputs = self._bound_operands(instruction.inputs, call.operands) + outputs = self._bound_operands(instruction.outputs, call.operands) + pairs = inputs if len(inputs) >= len(outputs) else outputs + blocks = {block.name: block for block in self.program.instruction_set.blocks} + result: dict[int, int] = {} + flat_index = 0 + for operand, value in pairs: + block = blocks[operand.block] + for instance in qubit_labels(value): + base = self.instance_bases[instance] + for offset in range(int(block.encodes)): + result[flat_index] = base + offset + flat_index += 1 + return result + + @staticmethod + def _bound_operands( + operands: Sequence[qc.instructions.BlockOperand], values: Sequence[int | str] + ) -> list[tuple[qc.instructions.BlockOperand, int | str]]: + variadic_count = sum(operand.is_variadic for operand in operands) + if variadic_count > 1: + raise ValueError("cannot bind more than one variadic operand per boundary") + fixed_count = len(operands) - variadic_count + if len(values) < fixed_count: + raise ValueError("call supplies fewer blocks than its instruction declares") + expanded = [ + operand + for operand in operands + for _ in range(len(values) - fixed_count if operand.is_variadic else 1) + ] + return list(zip(expanded, values)) + + def qubit_of(self, call: qc.instructions.InstructionCall, flat_index: int) -> int: + """Resolve one flat action index for ``call``.""" + mapping = self.call_qubit_map(call) + try: + return mapping[flat_index] + except KeyError as error: + raise ValueError( + f"call {call.mnemonic!r}: flat logical index {flat_index} is " + f"out of range (operands cover {len(mapping)})" + ) from error + + +__all__ = ["ProgramLayout"] diff --git a/source/qdk_package/qdk/ec/_operands.py b/source/qdk_package/qdk/ec/_operands.py new file mode 100644 index 00000000000..0eec1bd413a --- /dev/null +++ b/source/qdk_package/qdk/ec/_operands.py @@ -0,0 +1,48 @@ +"""The block labels carried by a qodec instruction-call operand. + +Each positional operand names one block, as an integer or a string. Variadic +operands occupy successive entries in the same flat operand list. + +A :data:`QubitLabel` is an ``int`` (an authored qubit index) or a ``str`` (a +symbolic label such as the namespaced ``"alice.0"`` that lowering emits). A +label's identity does not depend on the wire form it arrived in: the operand +``3`` and the operand ``"3"`` both name qubit ``3``. + +Consumers match on the label type using ``isinstance(label, int)`` rather +than re-parsing text. +""" + +from __future__ import annotations + +from typing import Union + +#: One qubit named by a block operand: an authored index or a symbolic label. +QubitLabel = Union[int, str] + + +def _as_label(item: object) -> QubitLabel: + """Normalize one operand element to a label. + + Text that renders an integer exactly becomes that integer, so ``"3"`` and + ``3`` are the same label. Text that would not survive the round trip (an + ``"007"``, a ``"+3"``) is kept verbatim. + """ + if isinstance(item, int) and not isinstance(item, bool): + return item + text = str(item) + try: + number = int(text) + except ValueError: + return text + return number if str(number) == text else text + + +def qubit_labels(value: int | str) -> list[QubitLabel]: + """The single block label this positional operand names.""" + return [_as_label(value)] + + +__all__ = [ + "QubitLabel", + "qubit_labels", +] diff --git a/source/qdk_package/qdk/ec/_profile.py b/source/qdk_package/qdk/ec/_profile.py new file mode 100644 index 00000000000..50bc3188787 --- /dev/null +++ b/source/qdk_package/qdk/ec/_profile.py @@ -0,0 +1,456 @@ +"""Lazy, snapshot-based semantic profiles for gadgets and bare circuits.""" + +from __future__ import annotations + +from copy import deepcopy +from functools import cached_property +from itertools import product +from typing import TYPE_CHECKING, Sequence + +import qodec as qc +from qodec.gadgets import Circuit + +from ._analysis.check_discovery import checks_of, profile_of +from ._analysis.channel_action import ( + ChannelAction, + _identity_codes_over, + action_of, + declared_action_of, + input_qubits_of, + realized_action_of, + realized_codes_of, +) +from ._analysis.equivalence import gadgets_equivalent, why_not_equivalent +from ._analysis.propagation.frames import FrameGroup, PauliFrame +from ._analysis.propagation.interpreter import propagate_faults +from ._analysis.propagation.pauli import Pauli +from ._layout import ProgramLayout +from ._readouts import observe_count_of +from ._references import outcomes_of +from ._checks import OutcomeCode, outcome_code_of +from ._faults import FaultEffect, FaultEvent, _probe_flips, fault_effects_of +from ._distance_result import Distance + +if TYPE_CHECKING: + from ._analysis.distance_solvers import BoundsSolver, ExactSolver + from ._distance import _FaultDistanceData + + +class GadgetProfile: + """What exact simulation says a gadget or bare circuit does. + + A bare :class:`qodec.gadgets.Circuit` is treated as a gadget whose inputs + and outputs are identity-encoded on the qubits it does not prepare, so it + has an action, checks, readouts, and fault effects like any other. Only + :attr:`objective` is undefined there, because a circuit implements no + instruction and deriving one from the circuit would make the comparison + vacuous. + + Members are computed on first access and cached, but do not share one + simulation. The target is snapshotted at construction, so a profile + describes the gadget as it was then. + """ + + def __init__(self, target: qc.Gadget | Circuit) -> None: + if not isinstance(target, (qc.Gadget, Circuit)): + raise TypeError( + "expected qodec.Gadget or qodec.gadgets.Circuit, got " + f"{type(target).__name__}" + ) + self._target = deepcopy(target) + + @cached_property + def action(self) -> ChannelAction: + """The circuit's logical action, including declared output-frame corrections.""" + if isinstance(self._target, qc.Gadget): + return realized_action_of(self._target) + return action_of(self._target) + + @cached_property + def objective(self) -> ChannelAction | None: + """What the implemented instruction demands, or ``None`` for a circuit. + + ``objective`` names the concept here, not the retired + ``gadget.objective`` field that proposal 0025 replaced with + ``gadget.implements``. + """ + if isinstance(self._target, qc.Gadget): + return declared_action_of(self._target) + return None + + @cached_property + def checks(self) -> tuple[frozenset[int], ...]: + """One parity per check, over positions in the measurement record. + + The full discovered set, not the essential reduction. + """ + if isinstance(self._target, qc.Gadget): + return tuple( + frozenset(outcomes_of(equation)) for equation in checks_of(self._target) + ) + return tuple(self._outcome_code.checks()) + + @cached_property + def readouts(self) -> tuple[frozenset[int], ...]: + """One parity per readout, over positions in the measurement record. + + For a gadget these are ``gadget.readouts`` in order: observe outcomes + first, then flags. For a bare circuit, whose readouts are the + measurements themselves, each record position is its own readout. + """ + if isinstance(self._target, qc.Gadget): + discovered = profile_of(self._target).readouts + names = [ + *( + str(index) + for index in range(observe_count_of(self._target.implements)) + ), + *self._target.implements.flags, + ] + return tuple(frozenset(discovered[name]) for name in names) + return tuple( + frozenset({position}) + for position in range(self._outcome_code.measurement_count) + ) + + @cached_property + def fault_effects(self) -> tuple[tuple[FaultEvent, FaultEffect], ...]: + """Effects over the canonical fault basis, paired with their cause. + + The canonical basis is one X and one Z fault after every instruction on + every qubit it touches, plus one recorded-bit flip per circuit readout. + Products span the Pauli/readout fault model, and effects are linear over + GF(2). This compact propagation basis is not the unit-cost circuit fault + set used by distance and distance_bounds. + """ + basis = self._canonical_fault_basis() + return tuple(zip(basis, self.effects_of(basis))) + + def effects_of(self, faults: Sequence[FaultEvent]) -> tuple[FaultEffect, ...]: + """Effects of an explicit fault basis, positionally aligned with it. + + Plural because the whole basis is evaluated in one simulation. + For gadgets, evaluate the complete declared checks and readouts, + including output signs and uniquely defined readout dependencies. + Incoming signs have zero change for circuit-internal faults. + FaultEvent.after(..., readout_flips=...) indexes the selected call's + own readouts, starting at zero, whereas + ``readouts[i]`` in a FaultEffect indexes the gadget's declared readouts, + including logical measurements and flags. ``checks[i]`` indexes its + declared checks; ``out[k].{x,z,stabilizers}[i]`` names an output sign. + Output signs include declared frame corrections. + For a bare circuit, checks are discovered, readouts are circuit outcomes, + and output blocks are single-qubit identity encodings for all circuit + layout slots not prepared by the circuit, in increasing slot order + (including unused slots). ``out[k]`` indexes that sequence, not the + physical qubit label; its logical index is always zero. + Effects refer to this profile's snapshot, not later edits to the source. + Recorded-bit flips do not themselves change the surviving quantum state. + Invalid references or ambiguous readouts raise ValueError. + Conditional/selected calls and circuit instruction flags are unsupported. + """ + if isinstance(self._target, qc.Gadget): + return fault_effects_of(self._target, faults) + return self._circuit_fault_data(tuple(faults))[0] + + def distance( + self, + *, + faults: Sequence[FaultEvent] | None = None, + upper_bound: int | None = None, + solver: ExactSolver | None = None, + ) -> Distance[FaultEvent]: + """Minimum fault count causing logical failure with all checks and flags zero. + + By default, allow every combination of a post-call Pauli on a call's + qubits and flips of its recorded readout bits, except the identity event. + A call with n qubits and r readouts contributes 4**n * 2**r - 1 events. + Calls without readouts retain 3 one-qubit or 15 two-qubit Pauli faults. + Each event costs one, including correlated quantum/readout errors at + one call; this is not FaultEvent.weight. A pure readout flip changes + only the reported bit, not the quantum state after measurement. + Pass an explicit sequence to replace that fault set, including []. + An explicit event may span several call positions and still costs one. + + The combined fault must leave every declared check and flag zero and + commute with every output-code stabilizer: a nonzero output + syndrome is not a logical error. Failure means changing the realized + logical action: its prepared-state stabilizers, preserved logical + mappings, or logical measurement signs. A logical Z on a prepared + logical zero is harmless. Output errors and measurement-dependent + signs are evaluated together and may cancel. Individual factors may leave + the codespace as long as their combined output syndromes cancel. + Output stabilizer targets remain distinct from declared check targets. + Individual factors may raise flags as long as their combined flag flips + cancel. A flag alone is not a logical failure. A bare circuit + uses its discovered checks and identity encodings on the qubits it + does not prepare. No decoder or additional output recovery is assumed. + + No logical measurement is required: a readout-free gadget is assessed + through its output encodings. Audit noiseless validity separately with + ec.audit(protocol). Distance retains only calculation preconditions; + the action must be interpretable against the boundary codes, and + declared but unbound logical readouts or flags cannot silently be ignored. + + The result's witness contains the selected factors and their product. + Replay the combined fault with effects_of([result.witness.product]). + Select solver="highs" (the default), "enumeration", or "mwpf". + HiGHS is included in qdk[ec]; MWPF requires a separate pip install mwpf. + Both bounds are None only when no logical failure is possible. A cutoff + or an open bound gap raises RuntimeError rather than claiming exactness. + FaultEvent.after selects a zero-based Circuit.calls index; its readout + indexes are local to that call, excluding hidden reset outcomes. + Invalid indices, references, or unbound readouts raise ValueError. Propagation + restrictions are the same as for effects_of. The fault set grows + exponentially with call support; exact search is also combinatorial. + """ + from ._analysis.distance_solvers import HighsSolverOptions + from ._distance import _copy_fault, _fault_product, distance_result_of + + data = self._distance_data(faults) + return distance_result_of( + data.odd_cycles, + data.faults, + solver=HighsSolverOptions() if solver is None else solver, + upper_bound=upper_bound, + exact=True, + product=_fault_product, + copy=_copy_fault, + ) + + def distance_bounds( + self, + *, + faults: Sequence[FaultEvent] | None = None, + upper_bound: int | None = None, + solver: BoundsSolver | None = None, + ) -> Distance[FaultEvent]: + """Bound the undetected fault count and return an upper-bound witness. + + Faults and failure have the same meaning as in distance, with all checks + and flags zero for the combined fault. + Select solver="highs" (the default), "enumeration", or "mwpf". + HiGHS is included in qdk[ec]; MWPF requires a separate pip install mwpf. + Enumeration and HiGHS searches use + upper_bound as a search cutoff; MWPF does not use it. An upper bound of + None means no finite bound is established; both bounds being None + proves no allowed failure exists. Limits may leave a gap between bounds. + Backend failures, invalid witnesses, or unavailable bound certificates + raise RuntimeError rather than returning a partial or uncertified bound. + """ + from ._analysis.distance_solvers import HighsSolverOptions + from ._distance import _copy_fault, _fault_product, distance_result_of + + data = self._distance_data(faults) + return distance_result_of( + data.odd_cycles, + data.faults, + solver=HighsSolverOptions() if solver is None else solver, + upper_bound=upper_bound, + product=_fault_product, + copy=_copy_fault, + ) + + def _distance_data(self, faults: Sequence[FaultEvent] | None) -> _FaultDistanceData: + from ._distance import _FaultDistanceData + from ._faults import _gadget_fault_data + + allowed = self._circuit_faults() if faults is None else tuple(faults) + observable_count = ( + observe_count_of(self._target.implements) + if isinstance(self._target, qc.Gadget) + else len(self.readouts) + ) + if ( + isinstance(self._target, qc.Gadget) + and len(self._target.readouts) < observable_count + ): + raise ValueError( + "distance requires every logical measurement readout to be bound" + ) + observables = self._fault_probes if allowed else FrameGroup(()) + flag_positions = frozenset() + if isinstance(self._target, qc.Gadget): + if len(self._target.readouts) < observable_count + len( + self._target.implements.flags + ): + raise ValueError("distance requires every flag readout to be bound") + flag_positions = frozenset( + position + for position, readout in enumerate(self._target.readouts) + if readout.is_flag + ) + effects, indicators = _gadget_fault_data( + self._target, allowed, observables=observables + ) + else: + effects, indicators = self._circuit_fault_data( + allowed, observables=observables + ) + return _FaultDistanceData.of( + allowed, effects, indicators, flag_positions=flag_positions + ) + + @cached_property + def _fault_probes(self) -> FrameGroup: + """Physical action probes with signs indexed by circuit readouts.""" + if isinstance(self._target, qc.Gadget): + _, output_code = realized_codes_of(self._target) + action = self.action + else: + input_code = output_code = _identity_codes_over(self._circuit_outputs) + action = action_of(self._circuit, with_respect_to=(input_code, output_code)) + observables = _fault_observables(action).generators + return FrameGroup( + PauliFrame( + abs(output_code.representative_of(observable.pauli)), + observable.frame, + ) + for observable in observables + ) + + def is_equivalent_to(self, other: "GadgetProfile") -> bool: + if isinstance(self._target, qc.Gadget) and isinstance(other._target, qc.Gadget): + return gadgets_equivalent(self._target, other._target) + return self.action.is_equivalent_to(other.action) + + def why_not_equivalent_to(self, other: "GadgetProfile") -> str: + """One sentence naming the first difference, or ``""`` if equivalent.""" + if isinstance(self._target, qc.Gadget) and isinstance(other._target, qc.Gadget): + return why_not_equivalent(self._target, other._target) + return self.action.why_not_equivalent_to(other.action) + + @property + def _circuit(self) -> Circuit: + return ( + self._target.circuit + if isinstance(self._target, qc.Gadget) + else self._target + ) + + @cached_property + def _outcome_code(self) -> OutcomeCode: + return outcome_code_of(self._circuit) + + @cached_property + def _circuit_outputs(self) -> tuple[int, ...]: + """The qubits a bare circuit carries through: those it does not prepare.""" + return tuple(sorted(input_qubits_of(self._circuit))) + + def _circuit_fault_data( + self, basis: tuple[FaultEvent, ...], *, observables: FrameGroup = FrameGroup(()) + ) -> tuple[tuple[FaultEffect, ...], tuple[frozenset[int], ...]]: + if not basis: + return (), () + outputs = self._circuit_outputs + z_probes = [Pauli({qubit: "Z"}) for qubit in outputs] + x_probes = [Pauli({qubit: "X"}) for qubit in outputs] + deltas, hidden_count, outcome_count = propagate_faults( + self._circuit, + basis, + z_probes + x_probes + [item.pauli for item in observables.generators], + residual_frames=[frozenset() for _ in z_probes + x_probes] + + [item.frame for item in observables.generators], + ) + z_offset = hidden_count + outcome_count + x_offset = z_offset + len(z_probes) + checks = self.checks + check_references = [ + qc.Reference(f"checks[{index}]") for index in range(len(checks)) + ] + readout_references = [ + qc.Reference(f"readouts[{index}]") for index in range(outcome_count) + ] + output_references = [ + (qc.Reference(f"out[{entry}].{field}[0]"), offset + entry) + for entry in range(len(outputs)) + for field, offset in (("x", x_offset), ("z", z_offset)) + ] + effects = [] + for index in range(len(basis)): + flipped = frozenset( + outcome + for outcome in range(outcome_count) + if deltas[hidden_count + outcome, index] + ) + effects.append( + FaultEffect._from_references( + [ + check_references[position] + for position, check in enumerate(checks) + if len(check & flipped) % 2 + ] + + [readout_references[position] for position in flipped] + + [ + reference + for reference, row in output_references + if deltas[row, index] + ] + ) + ) + indicators = _probe_flips( + deltas, x_offset + len(x_probes), len(observables.generators), len(basis) + ) + return tuple(effects), indicators + + def _canonical_fault_basis(self) -> tuple[FaultEvent, ...]: + program = self._circuit + layout = ProgramLayout.of(program) + calls = program.calls() + quantum = tuple( + FaultEvent.after(index, Pauli({qubit: basis})) + for index, call in enumerate(calls) + for qubit in sorted(set(layout.call_qubit_map(call).values())) + for basis in ("X", "Z") + ) + return quantum + tuple( + FaultEvent.after(index, readout_flips=readout) + for index, call in enumerate(calls) + for readout in range( + observe_count_of(program.instruction_set.instructions[call.mnemonic]) + ) + ) + + def _circuit_faults(self) -> tuple[FaultEvent, ...]: + program = self._circuit + layout = ProgramLayout.of(program) + faults = [] + for index, call in enumerate(program.calls()): + support = sorted(set(layout.call_qubit_map(call).values())) + readout_count = observe_count_of( + program.instruction_set.instructions[call.mnemonic] + ) + for characters in product(("I", "X", "Y", "Z"), repeat=len(support)): + error = Pauli( + { + qubit: character + for qubit, character in zip(support, characters) + if character != "I" + } + ) + for flipped in product((False, True), repeat=readout_count): + readouts = tuple( + position for position, flip in enumerate(flipped) if flip + ) + if error.weight or readouts: + faults.append( + FaultEvent.after(index, error, readout_flips=readouts) + ) + return tuple(faults) + + +__all__ = ["GadgetProfile"] + + +def _fault_observables(action: ChannelAction) -> FrameGroup: + """Distance indicators with signs indexed by circuit readouts.""" + return FrameGroup( + ( + *action._stabilizers.generators, + *action._mapping.values(), + *( + PauliFrame(Pauli.identity(), observable.frame) + for observable in action._observables.generators + ), + ) + ) diff --git a/source/qdk_package/qdk/ec/_readouts.py b/source/qdk_package/qdk/ec/_readouts.py new file mode 100644 index 00000000000..c17e65e4af7 --- /dev/null +++ b/source/qdk_package/qdk/ec/_readouts.py @@ -0,0 +1,175 @@ +"""What a gadget's ``readouts`` list is, entry by entry. + +``Gadget.readouts`` is one positional list holding two kinds of thing: the +implemented instruction's ``observe`` outcomes first, then its ``flags:`` flags. +The boundary between them is fixed by the *instruction*, not by the gadget, so +finding it means reading ``gadget.implements``. Every consumer that wants +one kind has to re-derive the split to get it. + +:func:`readout_slots` derives it once. Each :class:`ReadoutSlot` says which kind +an entry is, what it is called, and what it equates to; consumers filter that +value instead of re-slicing the list. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass + +import qodec as qc +from qodec import ReferenceLike +from qodec.gadgets import Readout, ReadoutLike + +from ._references import ( + Equation, + as_references, + outcome_equation, + outcomes_of, + parse_equation, +) + + +def observe_count_of(instruction: qc.Instruction) -> int: + """Number of ``observe`` outcome bits an instruction declares.""" + return sum( + len(action.observables) + for action in instruction.action + if isinstance(action, qc.actions.Observe) + ) + + +def readout_equation(entry: Readout) -> Equation: + """The parsed parity equation of one ``gadget.readouts`` entry. + + qodec has already resolved its position, name, and flag role. + """ + return parse_equation(entry.equation) + + +def as_readout( + entry: Readout | Sequence[ReferenceLike] | Mapping[str, Sequence[ReferenceLike]], +) -> ReadoutLike: + """One readout entry in the shape qodec's setters accept.""" + if isinstance(entry, Readout): + equation = as_references(entry.equation) + return equation if entry.name is None else {entry.name: equation} + if isinstance(entry, Mapping): + return {name: as_references(equation) for name, equation in entry.items()} + return as_references(entry) + + +@dataclass(frozen=True) +class ReadoutSlot: + """One bound entry of ``gadget.readouts``. + + ``name`` is the positional name (``"0"``, ``"1"``, ...) for an observable and + the declared flag name for a flag. An entry past everything the instruction + declares falls back to its positional name. + """ + + position: int + name: str + is_flag: bool + equation: Equation + + +@dataclass(frozen=True) +class GadgetReadouts: + """Every bound entry of ``gadget.readouts``, already split by kind. + + A gadget may bind fewer entries than its instruction declares; only the + entries actually present are reported, which is what lets the auditor see an + unbound observable as a missing slot rather than crash on it. + """ + + slots: tuple[ReadoutSlot, ...] + observables: tuple[ReadoutSlot, ...] + flags: tuple[ReadoutSlot, ...] + + +def readouts_of(gadget: qc.Gadget) -> GadgetReadouts: + """Bind ``gadget.readouts`` to its slots: observables first, then flags. + + This is the one place the observable/flag boundary is derived. Consumers + that want both kinds take this value once rather than deriving it per view. + """ + observe = observe_count_of(gadget.implements) + flags = list(gadget.implements.flags) + slots = [] + for position, entry in enumerate(gadget.readouts): + flag_index = position - observe + if 0 <= flag_index < len(flags): + name = flags[flag_index] + else: + name = str(position) + slots.append( + ReadoutSlot(position, name, flag_index >= 0, readout_equation(entry)) + ) + return GadgetReadouts( + tuple(slots), + tuple(slot for slot in slots if not slot.is_flag), + tuple(slot for slot in slots if slot.is_flag), + ) + + +def readout_slots(gadget: qc.Gadget) -> tuple[ReadoutSlot, ...]: + """Every bound entry of ``gadget.readouts``: observables first, then flags.""" + return readouts_of(gadget).slots + + +def observable_slots(gadget: qc.Gadget) -> tuple[ReadoutSlot, ...]: + """The gadget's bound Pauli-bearing readouts.""" + return readouts_of(gadget).observables + + +def flag_slots(gadget: qc.Gadget) -> tuple[ReadoutSlot, ...]: + """The gadget's bound flags (decoder-blind side-channel bits).""" + return readouts_of(gadget).flags + + +def observables_as_xor_map(gadget: qc.Gadget) -> dict[str, list[int]]: + """Gadget observables: positional name → measurement-record XOR.""" + return { + slot.name: outcomes_of(slot.equation) + for slot in readouts_of(gadget).observables + } + + +def set_gadget_readouts( + gadget: qc.Gadget, named_xor: Mapping[str, Iterable[int]] +) -> None: + """Set the observable entries of ``gadget.readouts`` from an XOR map. + + ``named_xor`` is a position-keyed observable-XOR map (decimal-string keys + ``"0"``, ``"1"``, ...); each becomes one parity equation, in positional + order. Non-positional (flag-named) keys are ignored. + + Any pre-authored trailing flag entries are preserved: flags carry no Pauli + expectation, so they are authored by hand rather than discovered, and + re-deriving the observables must not drop them. + """ + positional: dict[int, Equation] = {} + for name, indices in named_xor.items(): + if str(name).isdigit(): + positional[int(name)] = outcome_equation(indices) + readouts: list[ReadoutLike] = [ + as_references(positional[index]) for index in sorted(positional) + ] + authored = list(gadget.readouts)[len(observable_slots(gadget)) :] + readouts.extend(as_readout(entry) for entry in authored) + gadget.readouts = readouts + + +__all__ = [ + "GadgetReadouts", + "ReadoutSlot", + "as_readout", + "flag_slots", + "observable_slots", + "observables_as_xor_map", + "observe_count_of", + "readout_equation", + "readout_slots", + "readouts_of", + "set_gadget_readouts", +] diff --git a/source/qdk_package/qdk/ec/_references.py b/source/qdk_package/qdk/ec/_references.py new file mode 100644 index 00000000000..da15a106867 --- /dev/null +++ b/source/qdk_package/qdk/ec/_references.py @@ -0,0 +1,215 @@ +"""Interpret gadget-local parity references as circuit outcomes, declared +readouts, or encoding signs. Selectors produce terms in order, preserving duplicates. + +=================================== ========================= +reference text atom +=================================== ========================= +``circuit.readouts[]`` :class:`Outcome` +``readouts[]`` :class:`ReadoutSign` +``(in|out)[].stabilizers[]`` :class:`StabilizerSign` +``(in|out)[].(x|z)[]`` :class:`LogicalSign` +=================================== ========================= + +```` is a single index, a stop-exclusive slice (``N:M``, ``N:M:K``), or a +union (``N,M,P``); a selector addressing several records parses to one +:class:`Outcome` per record. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import Literal, Union + +from qodec import Reference, ReferenceLike + +#: Which side of a gadget boundary an encoding reference names. +Side = Literal["in", "out"] + +#: Which operator list of a boundary encoding a sign reference names. +Basis = Literal["x", "z"] + + +@dataclass(frozen=True) +class Outcome: + """One measurement record of the gadget's own circuit.""" + + index: int + + def __str__(self) -> str: + return f"circuit.readouts[{self.index}]" + + +@dataclass(frozen=True) +class ReadoutSign: + """One declared gadget readout, before substituting its equation.""" + + index: int + + def __str__(self) -> str: + return f"readouts[{self.index}]" + + +@dataclass(frozen=True) +class StabilizerSign: + """The sign of one stabilizer of a boundary encoding. + + ``entry`` is the positional index into the gadget's ``inputs`` / ``outputs`` + encoding list; ``index`` selects a generator of that encoding's code. + """ + + side: Side + entry: int + index: int + + @property + def key(self) -> tuple[int, int]: + """This stabilizer's side-independent identity. + + A sign one gadget writes as ``out[...]`` the next gadget reads as + ``in[...]``, so anything carrying signs across gadgets keys on this. + """ + return (self.entry, self.index) + + def __str__(self) -> str: + return f"{self.side}[{self.entry}].stabilizers[{self.index}]" + + +@dataclass(frozen=True) +class LogicalSign: + """The sign of one logical operator of a boundary encoding.""" + + side: Side + entry: int + basis: Basis + index: int + + @property + def key(self) -> tuple[int, Basis, int]: + """This logical operator's side-independent identity.""" + return (self.entry, self.basis, self.index) + + def __str__(self) -> str: + return f"{self.side}[{self.entry}].{self.basis}[{self.index}]" + + +Atom = Union[Outcome, ReadoutSign, StabilizerSign, LogicalSign, Literal[0, 1]] + +#: One parity equation, parsed. +Equation = tuple[Atom, ...] + + +def reference_terms( + reference: ReferenceLike, +) -> Iterator[Outcome | ReadoutSign | StabilizerSign | LogicalSign]: + """Check gadget-local syntax immediately and produce selected terms lazily.""" + parsed = reference if isinstance(reference, Reference) else Reference(reference) + segments = parsed.segments + if not segments: + raise ValueError(f"model address {parsed.path!r} is not a parity reference") + match segments[-1]: + case Reference.Index(index): + indices = (index,) + case Reference.Slice(start, stop, step): + indices = range(start, stop, step) + case Reference.Union(values): + indices = values + case _: + raise ValueError(f"model address {parsed.path!r} is not a parity reference") + match segments[:-1]: + case (Reference.Field("circuit"), Reference.Field("readouts")): + return (Outcome(index) for index in indices) + case (Reference.Field("readouts"),): + return (ReadoutSign(index) for index in indices) + case ( + Reference.Field(("in" | "out") as side), + Reference.Index(entry), + Reference.Field("stabilizers"), + ): + return (StabilizerSign(side, entry, index) for index in indices) + case ( + Reference.Field(("in" | "out") as side), + Reference.Index(entry), + Reference.Field(("x" | "z") as basis), + ): + return (LogicalSign(side, entry, basis, index) for index in indices) + case _: + raise ValueError(f"model address {parsed.path!r} is not a parity reference") + + +def reference_term( + reference: ReferenceLike, +) -> Outcome | ReadoutSign | StabilizerSign | LogicalSign: + """Require one selected parity term without expanding the remaining selection.""" + terms = reference_terms(reference) + first = next(terms) + if next(terms, None) is not None: + raise ValueError( + f"reference {str(reference)!r} must select exactly one position" + ) + return first + + +def _parse_atom(reference: ReferenceLike | Literal[0, 1]) -> list[Atom]: + if isinstance(reference, int): + if type(reference) is not int or reference not in (0, 1): + raise ValueError("parity constants must be integer bits 0 or 1") + return [reference] + return list(reference_terms(reference)) + + +def parse_equation(references: Iterable[ReferenceLike | Literal[0, 1]]) -> Equation: + """Retain all parity terms in declared order, expanding reference selectors.""" + return tuple(atom for reference in references for atom in _parse_atom(reference)) + + +def parse_equations( + equations: Iterable[Iterable[ReferenceLike | Literal[0, 1]]], +) -> tuple[Equation, ...]: + """Parse parity equations, such as a gadget's ``checks``.""" + return tuple(parse_equation(equation) for equation in equations) + + +def outcomes_of(equation: Iterable[Atom]) -> list[int]: + """The measurement-record indices an equation addresses, in order.""" + return [atom.index for atom in equation if isinstance(atom, Outcome)] + + +def stabilizer_signs_of( + equation: Iterable[Atom], *, side: Side | None = None +) -> list[StabilizerSign]: + """The stabilizer-sign atoms of an equation, optionally one side only.""" + return [ + atom + for atom in equation + if isinstance(atom, StabilizerSign) and side in (None, atom.side) + ] + + +def outcome_equation(indices: Iterable[int]) -> Equation: + """An outcome-XOR pattern as an equation.""" + return tuple(Outcome(index) for index in indices) + + +def as_references( + atoms: Iterable[ReferenceLike | Atom], +) -> list[ReferenceLike | Literal[0, 1]]: + """One parity equation in the shape qodec's setters accept.""" + return [atom if isinstance(atom, int) else str(atom) for atom in atoms] + + +__all__ = [ + "Atom", + "Basis", + "Equation", + "LogicalSign", + "Outcome", + "Side", + "StabilizerSign", + "as_references", + "outcome_equation", + "outcomes_of", + "parse_equation", + "parse_equations", + "stabilizer_signs_of", +] diff --git a/source/qdk_package/qdk/simulation/_simulation.py b/source/qdk_package/qdk/simulation/_simulation.py index fa3ef1ccc05..720dcf114af 100644 --- a/source/qdk_package/qdk/simulation/_simulation.py +++ b/source/qdk_package/qdk/simulation/_simulation.py @@ -44,8 +44,8 @@ OP_RECORD_OUTPUT, ) -if TYPE_CHECKING: # This is in the pyi file only - from .._native import GpuShotResults +if TYPE_CHECKING: + from .._native import GpuShotResults # This is in the pyi file only class AggregateGatesPass(pyqir.QirModuleVisitor): diff --git a/source/qdk_package/test_requirements.txt b/source/qdk_package/test_requirements.txt index c3e596052c5..872fa60e3f1 100644 --- a/source/qdk_package/test_requirements.txt +++ b/source/qdk_package/test_requirements.txt @@ -1,5 +1,11 @@ pytest expecttest==0.3.0 +ipython pyqir>=0.12.5,<0.13 cirq-core==1.6.1; platform_system != 'Windows' or platform_machine == 'AMD64' pandas>=2.1 +# `qdk.ec` test dependencies. The `ec` extra itself (qodec, paulimer, binar) is +# not listed here: those distributions are not on PyPI yet, and tests/ec_tests +# skips itself when they are missing. +hypothesis +multiset diff --git a/source/qdk_package/tests/ec_tests/__init__.py b/source/qdk_package/tests/ec_tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/algebra/__init__.py b/source/qdk_package/tests/ec_tests/algebra/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/algebra/test_frame.py b/source/qdk_package/tests/ec_tests/algebra/test_frame.py new file mode 100644 index 00000000000..9867b136c0c --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_frame.py @@ -0,0 +1,172 @@ +"""Tests for the provenance-carrying simulation frame group. + +These cover the outcome-frame machinery ``FrameGroup`` exposes for the +readout-discovery path: factoring a target in the group and XOR-ing its +factors' frames, plus the per-generator ``relabel`` / ``restrict_to`` / +``complex_conjugated`` transforms and the support-based ``partition``. +""" + +from __future__ import annotations + +import pytest + +from qdk.ec._analysis.propagation.frames import FrameGroup, PauliFrame +from qdk.ec._analysis.propagation.pauli import Pauli, identity + + +def _z(qubit: int) -> Pauli: + return Pauli({qubit: "Z"}) + + +def _x(qubit: int) -> Pauli: + return Pauli({qubit: "X"}) + + +def _group(pairs: list[tuple[Pauli, set[int]]]) -> FrameGroup: + return FrameGroup(PauliFrame(pauli, frozenset(frame)) for pauli, frame in pairs) + + +# ── unframed ──────────────────────────────────────────────────────────────── + + +def test_unframed_exposes_underlying_pauli_group() -> None: + group = _group([(_z(0), set()), (_x(3), set())]) + plain = group.unframed + assert plain.generators == [_z(0), _x(3)] + assert set(plain.support) == {0, 3} + + +# ── factorization_of ──────────────────────────────────────────────────────── + + +def test_factorization_of_single_generator_returns_its_frame() -> None: + group = _group([(_z(0), {0}), (_x(1), {1})]) + factors = group.factorization_of(_z(0)) + assert factors is not None + assert len(factors) == 1 + assert factors[0].pauli == _z(0) + assert factors[0].frame == frozenset({0}) + + +def test_factorization_of_product_returns_per_factor_frames() -> None: + group = _group([(_z(0), {0}), (_z(1), {0, 1}), (_z(2), {2})]) + factors = group.factorization_of(_z(0) * _z(1) * _z(2)) + assert factors is not None + by_pauli = {f.pauli: f.frame for f in factors} + assert by_pauli == { + _z(0): frozenset({0}), + _z(1): frozenset({0, 1}), + _z(2): frozenset({2}), + } + + +def test_factorization_of_target_not_in_group_returns_none() -> None: + group = _group([(_z(0), {0})]) + assert group.factorization_of(_x(5)) is None + + +def test_factorization_of_identity_returns_empty_list() -> None: + group = _group([(_z(0), {0}), (_z(1), {1})]) + assert group.factorization_of(Pauli.identity()) == [] + + +def test_frame_of_signed_target_ignores_phase_only_factor() -> None: + group = _group([(_z(0), {3}), (identity(-1), set())]) + + assert group.frame_of(-_z(0)) == frozenset({3}) + + +# ── frame_of ──────────────────────────────────────────────────────────────── + + +def test_frame_of_xors_factor_frames() -> None: + # {0} XOR {0, 1} XOR {2} = {1, 2} + group = _group([(_z(0), {0}), (_z(1), {0, 1}), (_z(2), {2})]) + assert group.frame_of(_z(0) * _z(1) * _z(2)) == frozenset({1, 2}) + + +def test_frame_of_identity_is_empty() -> None: + group = _group([(_z(0), {0})]) + assert group.frame_of(Pauli.identity()) == frozenset() + + +def test_frame_of_raises_when_target_not_in_group() -> None: + group = _group([(_z(0), set())]) + with pytest.raises(ValueError): + group.frame_of(_x(9)) + + +# ── __or__ ────────────────────────────────────────────────────────────────── + + +def test_or_concatenates_generators_and_frames() -> None: + union = _group([(_z(0), {0})]) | _group([(_x(1), {1}), (_z(2), {2})]) + assert union.unframed.generators == [_z(0), _x(1), _z(2)] + assert [g.frame for g in union.generators] == [ + frozenset({0}), + frozenset({1}), + frozenset({2}), + ] + + +# ── relabel ───────────────────────────────────────────────────────────────── + + +def test_relabel_remaps_qubit_indices_keeping_frames() -> None: + group = _group([(_z(0), {1}), (_x(1), {2})]) + remapped = group.relabel({0: 10, 1: 11}) + assert remapped.unframed.generators == [Pauli({10: "Z"}), Pauli({11: "X"})] + assert [g.frame for g in remapped.generators] == [frozenset({1}), frozenset({2})] + + +def test_relabel_passes_unmapped_qubits_through() -> None: + remapped = _group([(Pauli({0: "Z", 5: "X"}), {0})]).relabel({0: 100}) + assert remapped.unframed.generators == [Pauli({100: "Z", 5: "X"})] + + +# ── restrict_to ───────────────────────────────────────────────────────────── + + +def test_restrict_to_drops_characters_outside_support() -> None: + group = _group([(Pauli({0: "Z", 1: "X", 2: "Y"}), {0, 1})]) + restricted = group.restrict_to({0, 2}) + assert restricted.unframed.generators == [Pauli({0: "Z", 2: "Y"})] + assert [g.frame for g in restricted.generators] == [frozenset({0, 1})] + + +def test_restrict_to_preserves_phase() -> None: + group = _group([(Pauli({0: "Z"}) * identity(-1), set())]) + restricted = group.restrict_to({0}) + assert restricted.unframed.generators[0].phase == -1 + + +# ── complex_conjugated ────────────────────────────────────────────────────── + + +def test_complex_conjugated_flips_sign_on_odd_y_weight() -> None: + group = _group( + [(_z(0), set()), (Pauli({0: "Y"}), set()), (Pauli({0: "Y", 1: "Y"}), set())] + ) + gens = group.complex_conjugated().unframed.generators + assert gens[0] == _z(0) # Y-weight 0 -> unchanged + assert gens[1] == Pauli({0: "Y"}) * identity(-1) # Y-weight 1 -> flipped + assert gens[2] == Pauli({0: "Y", 1: "Y"}) # Y-weight 2 -> unchanged + + +def test_complex_conjugated_keeps_frames() -> None: + group = _group([(Pauli({0: "Y"}), {1, 2})]) + assert [g.frame for g in group.complex_conjugated().generators] == [ + frozenset({1, 2}) + ] + + +# ── partition ─────────────────────────────────────────────────────────────── + + +def test_partition_separates_supported_from_complement() -> None: + group = _group([(_z(0), {0}), (_z(1), {1}), (_z(2), {2})]) + over, complement, _cross = group.partition(over={0, 1}) + over_paulis = set(over.unframed.generators) + complement_paulis = set(complement.unframed.generators) + assert _z(0) in over_paulis or _z(1) in over_paulis + assert _z(2) in complement_paulis diff --git a/source/qdk_package/tests/ec_tests/algebra/test_pauli_enumerator.py b/source/qdk_package/tests/ec_tests/algebra/test_pauli_enumerator.py new file mode 100644 index 00000000000..1df7cf9d90a --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_pauli_enumerator.py @@ -0,0 +1,57 @@ +from typing import Any, Callable +import math +from hypothesis import strategies, given + +from qdk.ec._analysis.propagation.pauli import Pauli +from ec_tests.testing.pauli_enumeration import PauliEnumerator + + +@strategies.composite +def error_characters(draw_from: Callable[..., Any]) -> str: + characters = draw_from(strategies.permutations("XYZ")) + length = draw_from(strategies.integers(min_value=0, max_value=3)) + return "".join(characters[:length]) + + +@given( + strategies.sets(strategies.integers(min_value=0, max_value=100), max_size=5), + strategies.integers(min_value=0, max_value=5), + error_characters(), +) +def test_enumeration_of_weight(support: set[int], weight: int, characters: str) -> None: + weight = min(len(support), weight, len(characters)) + enumerator = PauliEnumerator(support, characters=characters) + enumeration = enumerator.of_weight(weight) + expected_length = math.comb(len(support), weight) * (len(characters) ** weight) + assert len(set(enumeration)) == expected_length + assert all(pauli.weight == weight for pauli in enumeration) + + +@given( + strategies.sets(strategies.integers(min_value=0, max_value=100), max_size=5), + strategies.lists(strategies.integers(min_value=0, max_value=5)), + error_characters(), +) +def test_enumeration_by_weight( + support: set[int], weights: list[int], characters: str +) -> None: + enumerator = PauliEnumerator(support, characters=characters) + of_weights: list[Pauli] = [] + for weight in weights: + of_weights.extend(enumerator.of_weight(weight)) + by_weight = enumerator.by_weight(weights) + assert list(of_weights) == list(by_weight) + + +@given( + strategies.sets(strategies.integers(min_value=0, max_value=100), max_size=5), + strategies.integers(min_value=0, max_value=5), + error_characters(), +) +def test_enumeration_up_to_weight( + support: set[int], weight: int, characters: str +) -> None: + enumerator = PauliEnumerator(support, characters=characters) + by_weight = enumerator.by_weight(range(weight + 1)) + up_to_weight = enumerator.up_to_weight(weight) + assert list(by_weight) == list(up_to_weight) diff --git a/source/qdk_package/tests/ec_tests/algebra/test_pauli_group.py b/source/qdk_package/tests/ec_tests/algebra/test_pauli_group.py new file mode 100644 index 00000000000..8d1ac8a3d79 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_pauli_group.py @@ -0,0 +1,26 @@ +from typing import Sequence +from paulimer import PauliGroup + +from qdk.ec._analysis.propagation.pauli import Pauli + + +def test_intersection_of() -> None: + assert 2 ** (PauliGroup([]) & PauliGroup([])).log2_size == 1 + + group1 = PauliGroup([Pauli({0: "X"}), Pauli({1: "Y"})]) + group2 = PauliGroup([Pauli({2: "Z"})]) + assert 2 ** (group1 & group2).log2_size == 1 + group1 = PauliGroup([Pauli({0: "X"}), Pauli({1: "Y"}), Pauli({2: "Z"})]) + group2 = PauliGroup([Pauli({0: "X", 1: "Y", 2: "Z"})]) + intersection = group1 & group2 + assert 2**intersection.log2_size > 0 + for pauli in intersection.elements: + assert pauli in group1 and pauli in group2 + + +def are_all_commuting(paulis: Sequence[Pauli]) -> bool: + for i, pauli1 in enumerate(paulis): + for pauli2 in paulis[i + 1 :]: + if not pauli1.commutes_with(pauli2): + return False + return True diff --git a/source/qdk_package/tests/ec_tests/algebra/test_separable.py b/source/qdk_package/tests/ec_tests/algebra/test_separable.py new file mode 100644 index 00000000000..e9a2c3dacbd --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_separable.py @@ -0,0 +1,96 @@ +from itertools import zip_longest, chain +from typing import Any +import pytest +from hypothesis import given, strategies, settings +from multiset import Multiset +from qdk.ec._analysis.propagation.pauli import ( + Pauli, + characters_of, +) +from qdk.ec._analysis.separable_code import SeparableCode +from qdk.ec._analysis.stabilizer_code import StabilizerCode +from ec_tests.testing.pauli_enumeration import PauliEnumerator +from ec_tests.algebra.test_stabilizer_codes import stabilizer_codes as _stabilizer_codes + + +def stabilizer_codes() -> strategies.SearchStrategy[StabilizerCode]: + return strategies.sampled_from(_stabilizer_codes) + + +@given(strategies.lists(stabilizer_codes(), max_size=5)) +def test_blocks_match_codes(codes: list[StabilizerCode]) -> None: + tensor = SeparableCode.by_stacking(*codes) + for code, block in zip_longest(codes, tensor.blocks): + assert code.length == block.length + assert code.logical_qubit_count == block.logical_qubit_count + for code_gen, block_gen in zip_longest(code.stabilizers, block.stabilizers): + assert _weight_profile_of(code_gen) == _weight_profile_of(block_gen) + + +@settings(deadline=1000) +@given(strategies.lists(stabilizer_codes(), max_size=5)) +def test_bulk_properties_are_internally_consistent(codes: list[StabilizerCode]) -> None: + tensor = SeparableCode.by_stacking(*codes) + assert tuple(tensor.stabilizers) == tuple( + chain(*(block.stabilizers for block in tensor.blocks)) + ) + assert tuple(tensor.logical_basis) == tuple( + chain(*(block.logical_basis for block in tensor.blocks)) + ) + + fused = StabilizerCode(tensor.stabilizers, logical_basis=tensor.logical_basis) + assert tensor.support == fused.support + assert tensor.length == fused.length + assert tensor.logical_qubit_count == fused.logical_qubit_count + assert tuple(tensor.stabilizers) == tuple(fused.stabilizers) + assert tuple(tensor.logical_basis) == tuple(fused.logical_basis) + + +@settings(deadline=1000) +@given( + strategies.lists(stabilizer_codes(), max_size=3), + strategies.lists(strategies.integers(), min_size=10, max_size=10), +) +def test_error_properties_are_internally_consistent( + codes: list[StabilizerCode], integers: list[int] +) -> None: + return + tensor = SeparableCode.by_stacking(*codes) + fused = StabilizerCode(tensor.stabilizers, logical_basis=tensor.logical_basis) + errors = list(PauliEnumerator(tensor.support).up_to_weight(1)) + indexes = [integer % len(errors) for integer in integers] + for index in indexes: + error = errors[index] + assert tensor.syndrome_of(error) == fused.syndrome_of(error) + assert tensor.is_trivial_error(error) == fused.is_trivial_error(error) + assert tensor.is_trivial_logical_error(error) == fused.is_trivial_logical_error( + error + ) + assert tensor.is_logical_error(error) == fused.is_logical_error(error) + assert tensor.is_non_trivial_logical_error( + error + ) == fused.is_non_trivial_logical_error(error) + assert tensor.logical_action_of(error) == fused.logical_action_of(error) + assert tensor.unsigned_logical_action_of( + error + ) == fused.unsigned_logical_action_of(error) + + +@settings(deadline=1000) +@given(strategies.lists(stabilizer_codes(), max_size=3)) +def test_representatives_are_internally_consistent(codes: list[StabilizerCode]) -> None: + tensor = SeparableCode.by_stacking(*codes) + fused = StabilizerCode(tensor.stabilizers, logical_basis=tensor.logical_basis) + paulis = PauliEnumerator(set(range(tensor.logical_qubit_count))).up_to_weight(1) + for pauli in paulis: + assert tensor.representative_of(pauli) == fused.representative_of(pauli) + + +@given(stabilizer_codes()) +def test_overlapping(code: StabilizerCode) -> None: + with pytest.raises(ValueError): + SeparableCode(code, code) + + +def _weight_profile_of(pauli: Pauli) -> "Multiset[Any]": + return Multiset(characters_of(pauli).values()) diff --git a/source/qdk_package/tests/ec_tests/algebra/test_stabilizer_codes.py b/source/qdk_package/tests/ec_tests/algebra/test_stabilizer_codes.py new file mode 100644 index 00000000000..2a19034b6de --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_stabilizer_codes.py @@ -0,0 +1,319 @@ +import math +import pytest +from paulimer import DensePauli +from paulimer import PauliGroup + +from qdk.ec._analysis.propagation.pauli import Pauli, identity +from qdk.ec._analysis.stabilizer_code import StabilizerCode +from ec_tests.testing import code_catalog +from ec_tests.algebra.test_subsystem_codes import ( + assert_encoding_clifford_of, + assert_consistency_of, + assert_valid_logical_basis, +) + +reed_muller_codes = [ + code_catalog.make_quantum_reed_muller_code( + number_of_variables, maximum_x_degree, maximum_z_degree + ) + for number_of_variables in range(3, 6) + for maximum_x_degree in range(0, number_of_variables) + for maximum_z_degree in range(0, number_of_variables - maximum_x_degree) +] +repetition_codes = [ + code_catalog.make_repetition_code(distance) for distance in range(2, 10) +] +hamming_codes = [ + code_catalog.make_quantum_hamming_code(number_of_checks) + for number_of_checks in range(3, 6) +] +named_codes = [ + code_catalog.make_five_qubit_code(), + code_catalog.make_steane_code(), + code_catalog.make_shor_code(), + code_catalog.make_quantum_golay_code(), + code_catalog.make_color_code_832(), + code_catalog.make_tesseract_code(), + code_catalog.make_carbon_code(), +] +iceberg_codes = [code_catalog.make_iceberg_code(length) for length in range(2, 20, 2)] +stabilizer_codes = ( + named_codes + repetition_codes + hamming_codes + reed_muller_codes + iceberg_codes +) + + +@pytest.mark.parametrize("code", stabilizer_codes) +def test_consistency_of(code: StabilizerCode) -> None: + assert_consistency_of(code) + + +def test_five_qubit_code() -> None: + code = code_catalog.make_five_qubit_code() + expected_generators = [ + Pauli.from_string("ZXXZI"), + Pauli.from_string("IZXXZ"), + Pauli.from_string("ZIZXX"), + Pauli.from_string("XZIZX"), + ] + assert PauliGroup(expected_generators) == PauliGroup(code.stabilizers) + assert code.length == 5 + assert code.logical_qubit_count == 1 + + +def test_five_qubit_code_and_logical_op() -> None: + code = code_catalog.make_five_qubit_code() + code_ = StabilizerCode( + code.stabilizers, + logical_basis=[ + Pauli.from_string("XXXXX"), + Pauli.from_string("ZZZZZ"), + ], + ) + assert PauliGroup(code_.stabilizers) == PauliGroup(code.stabilizers) + assert code_.length == 5 + assert code_.logical_qubit_count == 1 + + +def test_shor_code() -> None: + code = code_catalog.make_shor_code() + expected_generators = [ + Pauli.from_string("ZZIIIIIII"), + Pauli.from_string("IZZIIIIII"), + Pauli.from_string("IIIZZIIII"), + Pauli.from_string("IIIIZZIII"), + Pauli.from_string("IIIIIIZZI"), + Pauli.from_string("IIIIIIIZZ"), + Pauli.from_string("XXXXXXIII"), + Pauli.from_string("IIIXXXXXX"), + ] + assert PauliGroup(expected_generators) == PauliGroup(code.stabilizers) + assert code.length == 9 + assert code.logical_qubit_count == 1 + + +def test_shor_code_and_logical_op() -> None: + code = code_catalog.make_shor_code() + code_ = StabilizerCode( + code.stabilizers, + logical_basis=[ + Pauli.from_string("XXXXXXXXX"), + Pauli.from_string("ZZZZZZZZZ"), + ], + ) + assert PauliGroup(code_.stabilizers) == PauliGroup(code.stabilizers) + assert code_.length == 9 + assert code_.logical_qubit_count == 1 + + +def test_steane_code() -> None: + code = code_catalog.make_steane_code() + assert code.length == 7 + assert code.logical_qubit_count == 1 + + +def test_steane_code_and_logical_op() -> None: + code = code_catalog.make_steane_code() + code_ = StabilizerCode( + code.stabilizers, + logical_basis=[ + Pauli.from_string("XXXXXXX"), + Pauli.from_string("ZZZZZZZ"), + ], + ) + assert PauliGroup(code_.stabilizers) == PauliGroup(code.stabilizers) + assert code_.length == 7 + assert code_.logical_qubit_count == 1 + + +steane_generator_strings = [ + "XXXXIII", + "XXIIXXI", + "XIXIXIX", + "ZZZZIII", + "ZZIIZZI", + "ZIZIZIZ", +] +steane_generators = list(map(Pauli.from_string, steane_generator_strings)) + + +def test_steane_code_non_central_logical_basis() -> None: + with pytest.raises(ValueError): + StabilizerCode( + steane_generators, + logical_basis=[ + Pauli.from_string("XXXXXXX"), + Pauli.from_string("ZZZZZZI"), + ], + ) + + +def test_steane_code_commuting_logical_basis() -> None: + with pytest.raises(ValueError): + StabilizerCode( + steane_generators, + logical_basis=[ + Pauli.from_string("XXXXXXX"), + Pauli.from_string("XXXXXXX"), + ], + ) + + +def test_steane_code_dissallowed_imaginary_phase() -> None: + with pytest.raises(ValueError): + StabilizerCode( + steane_generators, + logical_basis=[ + Pauli.from_string("XXXXXXX") * identity(1j), + Pauli.from_string("ZZZZZZZ"), + ], + ) + + +def test_trivial_code_full_logical_basis() -> None: + with pytest.raises(ValueError): + StabilizerCode( + [Pauli.from_string("ZZZ")], + logical_basis=[ + Pauli.from_string("XX"), + Pauli.from_string("ZI"), + ], + ) + + +def test_trivial_code_non_commuting_logical_ops() -> None: + with pytest.raises(ValueError): + StabilizerCode( + [Pauli.from_string("II")], + logical_basis=[ + Pauli.from_string("XI"), + Pauli.from_string("ZI"), + Pauli.from_string("YX"), + Pauli.from_string("YZ"), + ], + ) + + +def test_repetition_code() -> None: + for distance in range(2, 15): + code = code_catalog.make_repetition_code(distance) + for qubit in range(1, distance): + assert code.is_trivial_error(Pauli({0: "X", qubit: "X"})) + assert code.length == distance + assert code.logical_qubit_count == 1 + + +def test_hamming_code() -> None: + for number_of_checks in range(3, 7): + code = code_catalog.make_quantum_hamming_code(number_of_checks) + assert code.length == pow(2, number_of_checks) - 1 + assert ( + code.logical_qubit_count + == pow(2, number_of_checks) - 1 - 2 * number_of_checks + ) + + +def expected_classical_reed_muller_code_dimension( + number_of_variables: int, maximum_degree: int +) -> int: + return sum( + (math.comb(number_of_variables, degree) for degree in range(maximum_degree + 1)) + ) + + +def expected_quantum_reed_muller_code_dimension( + number_of_variables: int, maximum_x_degree: int, maximum_z_degree: int +) -> int: + return ( + (1 << number_of_variables) + - expected_classical_reed_muller_code_dimension( + number_of_variables, maximum_x_degree + ) + - expected_classical_reed_muller_code_dimension( + number_of_variables, maximum_z_degree + ) + ) + + +def test_reed_muller_codes() -> None: + for number_of_variables in range(3, 6): + for maximum_x_degree in range(0, number_of_variables): + for maximum_z_degree in range(0, number_of_variables - maximum_x_degree): + code = code_catalog.make_quantum_reed_muller_code( + number_of_variables, maximum_x_degree, maximum_z_degree + ) + assert code.length == pow(2, number_of_variables) + assert ( + code.logical_qubit_count + == expected_quantum_reed_muller_code_dimension( + number_of_variables, maximum_x_degree, maximum_z_degree + ) + ) + assert_valid_logical_basis(code) + + +def test_punctured_reed_muller_codes() -> None: + for number_of_variables in range(3, 6): + for maximum_x_degree in range(0, number_of_variables): + for maximum_z_degree in range(0, number_of_variables - maximum_x_degree): + if maximum_x_degree > 0 or maximum_z_degree > 0: + code = code_catalog.make_quantum_punctured_reed_muller_code( + number_of_variables, maximum_x_degree, maximum_z_degree + ) + assert code.length == pow(2, number_of_variables) - 1 + assert ( + code.logical_qubit_count + == expected_quantum_reed_muller_code_dimension( + number_of_variables, maximum_x_degree, maximum_z_degree + ) + + 1 + ) + + +def test_quantum_golay_codes() -> None: + code = code_catalog.make_quantum_golay_code() + assert code.length == 23 + assert code.logical_qubit_count == 1 + + +def test_color_code_832() -> None: + code = code_catalog.make_color_code_832() + assert code.length == 8 + assert code.logical_qubit_count == 3 + + +def test_tesseract_code() -> None: + code = code_catalog.make_tesseract_code() + assert code.length == 16 + assert code.logical_qubit_count == 6 + + +def test_carbon_code() -> None: + code = code_catalog.make_carbon_code() + assert code.length == 12 + assert code.logical_qubit_count == 2 + + +def test_icebergs() -> None: + for code in iceberg_codes: + assert code.length == code.logical_qubit_count + 2 + + +@pytest.mark.skip(reason="DensePauli is not supported for StabilizerCode.") +def test_stabilizer_code_can_be_initialized_with_dense_or_sparse_paulis() -> None: + """Regression test for bug #65564.""" + gens = [ + "XXXX", + "ZZZZ", + ] + + normalizer_gens = ["IXIX", "ZZII", "XXII", "IZIZ"] + + assert StabilizerCode( + [DensePauli.from_string(s) for s in gens], # type: ignore[attr-defined] + logical_basis=[DensePauli.from_string(s) for s in normalizer_gens], # type: ignore[attr-defined] + ) + + +@pytest.mark.parametrize("code", stabilizer_codes) +def test_encoding_clifford_of(code: StabilizerCode) -> None: + assert_encoding_clifford_of(code) diff --git a/source/qdk_package/tests/ec_tests/algebra/test_subsystem_codes.py b/source/qdk_package/tests/ec_tests/algebra/test_subsystem_codes.py new file mode 100644 index 00000000000..d54f4eeb887 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_subsystem_codes.py @@ -0,0 +1,160 @@ +from typing import Sequence +from itertools import zip_longest, product, chain +import pytest +from more_itertools import interleave, chunked +from paulimer import SparsePauli as RustSparsePauli +from qdk.ec._analysis.code_algebra import ( + encoding_clifford_of, + SubsystemCode, + are_mutually_commutative, + clifford_images_of, + is_symplectic_basis, + why_not_symplectic_basis, +) +from qdk.ec._analysis.propagation.groups import is_stabilizer_group +from ec_tests.testing import code_catalog +from paulimer import PauliGroup + +from qdk.ec._analysis.propagation.pauli import Pauli, identity +from ec_tests.testing.pauli_enumeration import PauliEnumerator + +bacon_shor_codes = [ + code_catalog.make_bacon_shor_code(number_of_rows, number_of_columns) + for number_of_rows in range(2, 6) + for number_of_columns in range(2, 6) +] +subsystem_codes = bacon_shor_codes + + +@pytest.mark.parametrize("code", subsystem_codes) +def test_consistency_of(code: SubsystemCode) -> None: + assert_consistency_of(code) + + +@pytest.mark.parametrize("code", subsystem_codes) +def test_encoding_clifford_of(code: SubsystemCode) -> None: + assert_encoding_clifford_of(code) + + +def test_relocation_preserves_signed_operators_and_gauges() -> None: + code = SubsystemCode( + [identity(-1) * Pauli("Z_0")], + [Pauli("X_1"), Pauli("Z_1")], + gauge_basis=[Pauli("X_2"), Pauli("Z_2")], + ) + + relocated = code.relocated({0: 5, 1: 7, 2: 9}) + + assert relocated.stabilizers == [identity(-1) * Pauli("Z_5")] + assert relocated.logical_basis == [Pauli("X_7"), Pauli("Z_7")] + assert relocated.gauge_basis == (Pauli("X_9"), Pauli("Z_9")) + assert relocated.support == frozenset({5, 7, 9}) + assert code.support == frozenset({0, 1, 2}) + + +def assert_consistency_of(code: SubsystemCode) -> None: + assert_code_generators(code) + assert_valid_logical_basis(code) + assert_valid_logical_actions(code) + assert_valid_representatives(code) + assert_anti_generators(code) + assert_group_property_consistency_of(code) + assert_subsystem_init_consistency_of(code) + + +def assert_subsystem_init_consistency_of(code: SubsystemCode) -> None: + def assert_clone( + gauge_basis: Sequence[Pauli] | None = None, + # anti_stabilizers: Sequence[Pauli] | None = None, + ) -> None: + clone = SubsystemCode( + code.stabilizers, + code.logical_basis, + gauge_basis=gauge_basis, + # anti_stabilizers=anti_stabilizers, + ) + assert tuple(code.stabilizers) == tuple(clone.stabilizers) + assert tuple(code.logical_basis) == tuple(clone.logical_basis) + assert code.is_equivalent_to(clone) + if gauge_basis is not None: + assert tuple(code.gauge_basis) == tuple(gauge_basis) + # if anti_stabilizers is not None: + # assert tuple(code.anti_stabilizers) == tuple(anti_stabilizers) + + assert_clone() + assert_clone(gauge_basis=code.gauge_basis) + # assert_clone(gauge_basis=code.gauge_basis, anti_stabilizers=code.anti_stabilizers) + + +def assert_group_property_consistency_of(code: SubsystemCode) -> None: + assert code.stabilizer.generators == code.stabilizers + assert code.anti_stabilizer.generators == code.anti_stabilizers + assert code.logical.generators == code.logical_basis + assert tuple(code.gauge.generators) == code.gauge_basis + + +def assert_encoding_clifford_of(code: SubsystemCode) -> None: + support = sorted(code.support) + encoding_clifford = encoding_clifford_of(code, supported_by=support) + assert encoding_clifford.is_valid + images = clifford_images_of(code) + assert len(images) == 2 * len(support) + + preimages = interleave( + [RustSparsePauli({index: "X"}) for index in range(len(support))], + [RustSparsePauli({index: "Z"}) for index in range(len(support))], + ) + for preimage, image in zip_longest(preimages, images): + dense_image = encoding_clifford.image_of(preimage) + remapped = Pauli( + {support[i]: dense_image[i] for i in dense_image.support} + ) * identity(dense_image.phase) + assert image == remapped + + +def assert_code_generators(code: SubsystemCode) -> None: + assert all(map(code.is_trivial_error, code.stabilizers)) + + +def assert_valid_logical_basis(code: SubsystemCode) -> None: + assert len(code.logical_basis) == 2 * code.logical_qubit_count + for logical in code.logical_basis: + assert logical * logical == Pauli.identity() + for generator in code.stabilizers: + assert generator.commutes_with(logical) + assert PauliGroup(code.logical_basis).binary_rank == len(code.logical_basis) + + +def assert_valid_logical_actions(code: SubsystemCode) -> None: + for index, logicals in enumerate(chunked(code.logical_basis, 2)): + logical_x, logical_z = logicals + for generator, phase in product(code.stabilizers, [1, -1, 1.0j, -1.0j]): + x_action = code.logical_action_of(logical_x * generator * identity(phase)) + z_action = code.logical_action_of(logical_z * generator * identity(phase)) + assert x_action == Pauli({index: "X"}) * identity(phase) + assert z_action == Pauli({index: "Z"}) * identity(phase) + + +def assert_valid_representatives(code: SubsystemCode) -> None: + return + for pauli in PauliEnumerator(set(range(code.logical_qubit_count))).up_to_weight(2): + assert code.logical_action_of(code.representative_of(pauli)) == pauli + + +def assert_anti_generators(code: SubsystemCode) -> None: + anti_stabilizers = code.anti_stabilizers + stabilizers = code.stabilizers + assert len(anti_stabilizers) == len(stabilizers) + interleaved = list(chain(*zip(stabilizers, anti_stabilizers))) + assert is_symplectic_basis(interleaved), why_not_symplectic_basis(interleaved) + assert all( + not first.commutes_with(second) for first, second in chunked(interleaved, 2) + ) + for index, (stabilizer, anti) in enumerate(chunked(interleaved, 2)): + rest = interleaved[2 * index + 2 :] + assert all(stabilizer.commutes_with(element) for element in rest) + assert all(anti.commutes_with(element) for element in rest) + assert is_stabilizer_group(PauliGroup(anti_stabilizers)) + assert are_mutually_commutative( + PauliGroup(code.logical_basis), PauliGroup(anti_stabilizers) + ) diff --git a/source/qdk_package/tests/ec_tests/algebra/test_surface_code.py b/source/qdk_package/tests/ec_tests/algebra/test_surface_code.py new file mode 100644 index 00000000000..1cd480bc9e7 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/algebra/test_surface_code.py @@ -0,0 +1,33 @@ +from hypothesis import strategies, given +from ec_tests.testing.code_catalog.surface_codes import ( + make_rotated_surface_code, +) +from ec_tests.algebra.test_subsystem_codes import ( + assert_valid_logical_basis, +) + + +def odd_integers_strategy( + min_value: int, max_value: int +) -> strategies.SearchStrategy[int]: + return strategies.integers(min_value=min_value, max_value=max_value).filter( + lambda x: x % 2 == 1 + ) + + +@given( + odd_integers_strategy(min_value=3, max_value=13), + odd_integers_strategy(min_value=3, max_value=13), +) +def test_rotated_surface_code_length(x_distance: int, z_distance: int) -> None: + code = make_rotated_surface_code(x_distance=x_distance, z_distance=z_distance) + assert code.length == x_distance * z_distance + + +@given( + odd_integers_strategy(min_value=3, max_value=5), +) +def test_rotated_surface_code_logicals(distance: int) -> None: + code = make_rotated_surface_code(x_distance=distance, z_distance=distance) + assert code.logical_qubit_count == 1 + assert_valid_logical_basis(code) diff --git a/source/qdk_package/tests/ec_tests/codecs/__init__.py b/source/qdk_package/tests/ec_tests/codecs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/conftest.py b/source/qdk_package/tests/ec_tests/conftest.py new file mode 100644 index 00000000000..d9629b7a5cb --- /dev/null +++ b/source/qdk_package/tests/ec_tests/conftest.py @@ -0,0 +1,70 @@ +"""Collection guard and shared fixtures for the ``qdk.ec`` test suite. + +``qdk.ec`` and its dependencies are an optional extra of the ``qdk`` package +(``pip install "qdk[ec]"``). When those dependencies are absent this whole +directory is skipped rather than erroring at import time, so a plain +``pytest`` run of the ``qdk`` test suite still works on a bare install. +""" + +from __future__ import annotations + +import os +from importlib.util import find_spec + +import pytest + +#: Third-party modules every ``qdk.ec`` test needs. MWPF-backed tests carry a +#: per-test skip marker for source environments where it is not installed. +_REQUIRED = ("hypothesis", "numpy", "paulimer", "qodec") + +_MISSING = [name for name in _REQUIRED if find_spec(name) is None] + + +def pytest_ignore_collect(collection_path, config) -> bool: # noqa: ARG001 + """Skip the whole ``qdk.ec`` suite when the ``ec`` extra is not installed.""" + del collection_path, config + return bool(_MISSING) + + +if not _MISSING: + import qodec as qc + from hypothesis import Verbosity, settings + + settings.register_profile("factory") + settings.register_profile("build", print_blob=True, deadline=1000) + settings.register_profile("fast", max_examples=10) + settings.register_profile("thorough", print_blob=True, max_examples=1000) + settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose) + settings.register_profile("no_deadline", deadline=None) + settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "fast")) + + # ── Shared gadget fixtures (c4 translation layer), used across the suite. + @pytest.fixture(scope="package") + def bundle() -> qc.Qodec: + from ec_tests.testing.qodecs import c4 + + return c4() + + @pytest.fixture(scope="package") + def translation(bundle: qc.Qodec) -> qc.Layer: + return bundle.layers[0] + + @pytest.fixture(scope="package") + def idle_gadget(translation: qc.Layer) -> qc.Gadget: + return translation.gadgets["idle"] + + @pytest.fixture(scope="package") + def measure_xx_gadget(translation: qc.Layer) -> qc.Gadget: + return translation.gadgets["measure_xx"] + + @pytest.fixture(scope="package") + def measure_zz_gadget(translation: qc.Layer) -> qc.Gadget: + return translation.gadgets["measure_zz"] + + @pytest.fixture(scope="package") + def prepare_xx_gadget(translation: qc.Layer) -> qc.Gadget: + return translation.gadgets["prepare_xx"] + + @pytest.fixture(scope="package") + def prepare_zz_gadget(translation: qc.Layer) -> qc.Gadget: + return translation.gadgets["prepare_zz"] diff --git a/source/qdk_package/tests/ec_tests/develop/__init__.py b/source/qdk_package/tests/ec_tests/develop/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/develop/test_build.py b/source/qdk_package/tests/ec_tests/develop/test_build.py new file mode 100644 index 00000000000..83f5b0d6981 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/develop/test_build.py @@ -0,0 +1,775 @@ +"""``qdk.ec.build_qodec`` — building a qodec from a code. + +The suite is organised around what the builder promises: a *structurally* valid +qodec whose gadgets are *semantically* verified and that *round-trips*. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import qodec as qc + +from ec_tests.testing import code_catalog as catalog +from ec_tests.testing.qodecs import c4 +from qdk.ec import CodeProfile, _audit, build_qodec +from qdk.ec import _distance as distance +from qdk.ec._analysis import channel_action as action +from qdk.ec._fill import complete_qodec +from qdk.ec._build import _METADATA_KEY, _build as qodec_from_code +from qdk.ec._analysis.code_algebra import as_qodec_code +from ec_tests.testing.optional import requires_stim + +#: CSS codes with and without a valid transversal H candidate. Each entry is +#: (label, factory, physical qubits, logical qubits). +CSS_CODES = [ + ("repetition3", lambda: catalog.make_repetition_code(3), 3, 1), + ("steane", catalog.make_steane_code, 7, 1), + ("shor", catalog.make_shor_code, 9, 1), + ( + "surface3", + lambda: catalog.make_rotated_surface_code(x_distance=3, z_distance=3), + 9, + 1, + ), +] + + +def _code(label: str, factory) -> qc.Code: + return as_qodec_code(factory(), label) + + +def build_notes(qodec: qc.Qodec) -> dict: + """The build record left in a built qodec's metadata.""" + section = dict(qodec.metadata).get(_METADATA_KEY) or {} + return dict(section.get("build", {})) + + +def _round_tripped(qodec: qc.Qodec, directory: Path) -> qc.Qodec: + # qodec.save/load take strings, not os.PathLike. + qodec.save(str(directory), single_file=True) + return qc.Qodec.load(str(directory / qodec.manifest_filename)) + + +@pytest.fixture(scope="module") +def steane() -> qc.Qodec: + return qodec_from_code(_code("steane", catalog.make_steane_code)) + + +# ── Structure ─────────────────────────────────────────────────────────────── + + +@requires_stim +def test_result_is_a_two_layer_qodec(steane: qc.Qodec) -> None: + assert len(steane.layers) == 2 + assert steane.layers[0].instruction_set.name == "steane" + assert steane.layers[1].instruction_set.name == "stim" + assert steane.layers[1].gadgets == {} + + +@requires_stim +def test_logical_block_encodes_the_logical_qubits(steane: qc.Qodec) -> None: + (block,) = steane.layers[0].instruction_set.blocks + + assert block.name == "steane" + assert block.encodes == 1 + + +@requires_stim +def test_every_declared_instruction_has_a_gadget(steane: qc.Qodec) -> None: + layer = steane.layers[0] + + assert set(layer.instruction_set.instructions) == set(layer.gadgets) + + +@requires_stim +@pytest.mark.parametrize("strategy", ["bare-css/v1", "flagged-css/v1"]) +def test_the_expected_instruction_menu_is_built(strategy: str) -> None: + built = build_qodec(_code("steane", catalog.make_steane_code), strategy=strategy) + expected = { + "prepare_z_all", + "prepare_x_all", + "syndrome", + "measure_z_all", + "measure_x_all", + "h_all", + "cx_all", + } + assert len(expected) == 7 + assert set(built.layers[0].gadgets) == expected + assert set(built.layers[0].instruction_set.instructions) == expected + + +@requires_stim +def test_the_code_is_carried_through(steane: qc.Qodec) -> None: + assert "steane" in steane.codes + assert list(steane.codes["steane"].stabilizers) + + +@requires_stim +@pytest.mark.parametrize( + ("layer", "mnemonic", "description"), + [ + (0, "prepare_z_all", "Prepare all 1 logical qubit(s) in |0⟩."), + (0, "prepare_x_all", "Prepare all 1 logical qubit(s) in |+⟩."), + (1, "R", "Reset to |0⟩."), + ], +) +def test_instruction_descriptions_preserve_unicode_kets( + steane: qc.Qodec, layer: int, mnemonic: str, description: str +) -> None: + assert steane.layers[layer].instruction_set.instructions[mnemonic].description == description + bundle = steane.dumps() + assert description in bundle + restored = qc.Qodec.loads(bundle) + assert restored.layers[layer].instruction_set.instructions[mnemonic].description == description + + +@requires_stim +@pytest.mark.parametrize("strategy", ["bare-css/v1", "flagged-css/v1"]) +@pytest.mark.parametrize("code_name", ["C4", "steane"]) +def test_built_boundary_types_are_explicit(strategy: str, code_name: str) -> None: + code = ( + c4().codes["C4"] + if code_name == "C4" + else _code("steane", catalog.make_steane_code) + ) + built = build_qodec(code, strategy=strategy, strict=False) + + for gadget in built.layers[0].gadgets.values(): + for encoding in (*gadget.inputs, *gadget.outputs): + assert encoding.block_types == ["qubit"] * len(encoding.support) + assert qc.Qodec.loads(built.dumps()) == built + + +@requires_stim +def test_name_and_description_default_from_the_code() -> None: + built = qodec_from_code(_code("steane", catalog.make_steane_code)) + + assert built.name == "steane" + assert built.description.startswith("Built from ") + assert "[[7, 1]]" in built.description + + +@requires_stim +def test_name_and_description_can_be_overridden() -> None: + built = qodec_from_code( + _code("steane", catalog.make_steane_code), + name="my_qodec", + description="hand written", + ) + + assert built.name == "my_qodec" + assert built.description == "hand written" + assert built.layers[0].instruction_set.name == "my_qodec" + + +@requires_stim +@pytest.mark.parametrize( + ("label", "factory", "physical", "logical"), + CSS_CODES, + ids=[case[0] for case in CSS_CODES], +) +def test_build_notes_record_the_code_shape( + label: str, factory, physical: int, logical: int +) -> None: + built = qodec_from_code(_code(label, factory)) + assert set(built.metadata[_METADATA_KEY]) == {"build"} + notes = build_notes(built) + + assert notes["source"] == "qdk.ec.build_qodec" + assert notes["code"] == label + assert notes["physical_qubits"] == physical + assert notes["logical_qubits"] == logical + assert set(notes["omitted"]) == (set() if label == "steane" else {"h_all"}) + + +def test_build_notes_are_empty_for_a_hand_authored_qodec() -> None: + assert build_notes(c4()) == {} + + +# ── Circuits ──────────────────────────────────────────────────────────────── + + +@requires_stim +def test_syndrome_round_allocates_a_syndrome_ancilla_and_a_flag_per_stabilizer( + steane: qc.Qodec, +) -> None: + code = steane.codes["steane"] + stabilizers = len(list(code.stabilizers)) + source = steane.layers[0].gadgets["syndrome"].circuit.source + + measured = [ + int(target) + for line in source.splitlines() + if line.startswith("M ") + for target in line.split()[1:] + ] + # Every Steane stabilizer has weight 4, so each carries exactly one flag. + assert len(measured) == 2 * stabilizers + syndromes, flag_qubits = measured[:stabilizers], measured[stabilizers:] + # Syndrome ancillas are measured first, in stabilizer order, so the record + # index of stabilizer i is i regardless of which stabilizers carry flags. + assert syndromes == sorted(syndromes) + assert set(syndromes).isdisjoint(flag_qubits) + assert min(measured) >= 7, "ancillas must not collide with the 7 data qubits" + + +@requires_stim +def test_syndrome_records_are_ordered_stabilizers_then_flags( + steane: qc.Qodec, +) -> None: + """The record layout must not depend on which stabilizers carry flags.""" + source = steane.layers[0].gadgets["syndrome"].circuit.source + measurement_lines = [line for line in source.splitlines() if line.startswith("M ")] + + assert len(measurement_lines) == 2, "expected one M for syndromes, one for flags" + + +@requires_stim +def test_flag_outcomes_are_discovered_as_deterministic_checks( + steane: qc.Qodec, +) -> None: + """A flag bit is deterministic, so completion must find it as a check. + + That is what turns a flagged hook error into a detector the decoder sees. + """ + idle = steane.layers[0].gadgets["syndrome"] + + flag_checks = [ + check + for check in idle.checks + if len(check) == 1 and str(check[0]).startswith("circuit.readouts") + ] + assert len(flag_checks) == 6, "one flag check per weight-4 stabilizer" + + +def test_a_weight_two_stabilizer_carries_no_flag() -> None: + """Flag brackets must stay nested, which a weight-2 stabilizer cannot host.""" + from qdk.ec._build import _flag_capacity + + assert _flag_capacity(2) == 0 + assert _flag_capacity(3) == 1 + assert _flag_capacity(4) == 1 + assert _flag_capacity(6) == 2 + + +@requires_stim +def test_flag_count_defaults_to_the_codes_error_correcting_radius() -> None: + """Chamberland-Beverland call for t = (d-1)//2 flags for a distance-d code.""" + steane_code = _code("steane", catalog.make_steane_code) + + notes = build_notes(qodec_from_code(steane_code)) + + assert notes["flags_per_stabilizer"] == 1 + + +@requires_stim +def test_default_strategy_is_flagged() -> None: + code = _code("steane", catalog.make_steane_code) + + built = build_qodec(code) + + assert built == build_qodec(code, strategy="flagged-css/v1") + assert build_notes(built)["flags_per_stabilizer"] == 1 + + +@requires_stim +def test_bare_strategy_uses_the_unflagged_circuit() -> None: + code = _code("steane", catalog.make_steane_code) + + built = build_qodec(code, strategy="bare-css/v1") + + assert built == qodec_from_code( + code, flags=0, strict=True, description=built.description + ) + assert built.description.endswith("Strategy: bare-css/v1.") + source = built.layers[0].gadgets["syndrome"].circuit.source + assert build_notes(built)["flags_per_stabilizer"] == 0 + # 7 data qubits + one ancilla per stabilizer, and nothing else. + assert max(int(t) for line in source.splitlines() for t in line.split()[1:]) == 12 + + +@requires_stim +@pytest.mark.parametrize("use_analysis", [False, True]) +def test_bare_strategy_skips_distance_computation( + monkeypatch: pytest.MonkeyPatch, use_analysis: bool +) -> None: + from qdk.ec import _build + + def fail_distance(code: qc.Code) -> None: + raise AssertionError("bare construction must not compute code distance") + + code = _code("steane", catalog.make_steane_code) + argument = CodeProfile(code) if use_analysis else code + monkeypatch.setattr(_build, "code_distance_of", fail_distance) + + built = build_qodec( + argument, + strategy="bare-css/v1", + name="bare_steane", + description="No flag ancillas.", + ) + + assert built.name == "bare_steane" + assert built.description == "No flag ancillas." + assert build_notes(built)["flags_per_stabilizer"] == 0 + + +@requires_stim +def test_build_from_profile_uses_its_operator_snapshot() -> None: + code = _code("steane", catalog.make_steane_code) + profile = CodeProfile(code) + expected = build_qodec(profile, strategy="bare-css/v1", name="steane") + code.stabilizers = [] + code.x = [] + code.z = [] + + assert build_qodec(profile, strategy="bare-css/v1", name="steane") == expected + + +@pytest.mark.parametrize("strategy", ["bare-css/v2", "unknown"]) +def test_unknown_build_strategy_is_rejected(strategy: str) -> None: + code = _code("steane", catalog.make_steane_code) + + with pytest.raises(ValueError, match="unknown qodec construction strategy"): + build_qodec(code, strategy=strategy) + + +def test_negative_flag_counts_are_rejected() -> None: + with pytest.raises(ValueError, match="non-negative"): + qodec_from_code(_code("steane", catalog.make_steane_code), flags=-1) + + +@requires_stim +def test_syndrome_round_never_touches_data_qubits_with_single_qubit_gates( + steane: qc.Qodec, +) -> None: + source = steane.layers[0].gadgets["syndrome"].circuit.source + + for line in source.splitlines(): + gate, *targets = line.split() + if gate in ("R", "H", "M"): + assert all(int(target) >= 7 for target in targets), line + + +@requires_stim +def test_measure_gadgets_are_transversal(steane: qc.Qodec) -> None: + gadgets = steane.layers[0].gadgets + + assert gadgets["measure_z_all"].circuit.source == "M 0 1 2 3 4 5 6\n" + assert gadgets["measure_x_all"].circuit.source == "H 0 1 2 3 4 5 6\nM 0 1 2 3 4 5 6\n" + + +@requires_stim +def test_transversal_clifford_circuits(steane: qc.Qodec) -> None: + gadgets = steane.layers[0].gadgets + + assert gadgets["h_all"].circuit.source == "H 0 1 2 3 4 5 6\n" + assert gadgets["cx_all"].circuit.source == ( + "CX 0 7\nCX 1 8\nCX 2 9\nCX 3 10\nCX 4 11\nCX 5 12\nCX 6 13\n" + ) + + +@requires_stim +def test_physical_instruction_set_has_no_unused_pauli_gates(steane: qc.Qodec) -> None: + assert set(steane.layers[1].instruction_set.instructions) == { + "R", + "H", + "CX", + "CZ", + "M", + } + + +@requires_stim +def test_circuits_are_tagged_as_stim(steane: qc.Qodec) -> None: + assert all( + gadget.circuit.format == "stim" for gadget in steane.layers[0].gadgets.values() + ) + + +# ── Semantics ─────────────────────────────────────────────────────────────── + + +@requires_stim +@pytest.mark.parametrize( + ("label", "factory"), + [(case[0], case[1]) for case in CSS_CODES], + ids=[case[0] for case in CSS_CODES], +) +def test_every_gadget_realizes_the_action_it_declares(label: str, factory) -> None: + built = qodec_from_code(_code(label, factory)) + + mismatched = { + mnemonic: action.gadget_action_mismatch(gadget) + for mnemonic, gadget in built.layers[0].gadgets.items() + if action.gadget_action_mismatch(gadget) is not None + } + assert mismatched == {} + + +@requires_stim +@pytest.mark.parametrize( + ("label", "factory"), + [(case[0], case[1]) for case in CSS_CODES], + ids=[case[0] for case in CSS_CODES], +) +def test_gadgets_that_hold_state_discover_checks(label: str, factory) -> None: + built = qodec_from_code(_code(label, factory)) + + for mnemonic in ("prepare_z_all", "prepare_x_all", "syndrome"): + gadget = built.layers[0].gadgets[mnemonic] + assert gadget.checks, f"{mnemonic} discovered no checks" + + +@requires_stim +def test_measure_gadgets_bind_a_readout_per_logical_qubit(steane: qc.Qodec) -> None: + for mnemonic in ("measure_z_all", "measure_x_all"): + gadget = steane.layers[0].gadgets[mnemonic] + assert len(gadget.readouts) == 1, mnemonic + + +@requires_stim +def test_idle_checks_reference_both_boundaries(steane: qc.Qodec) -> None: + atoms = { + str(atom) for check in steane.layers[0].gadgets["syndrome"].checks for atom in check + } + + assert any(atom.startswith("in[0].stabilizers") for atom in atoms) + assert any(atom.startswith("out[0].stabilizers") for atom in atoms) + + +@requires_stim +def test_built_code_keeps_its_distance() -> None: + built = qodec_from_code(_code("steane", catalog.make_steane_code)) + + code_distance, _ = distance.code_distance_of(built.codes["steane"]) + + assert code_distance == 3 + + +# ── Audit ─────────────────────────────────────────────────────────────────── + + +@requires_stim +@pytest.mark.parametrize( + "code", + [_code(case[0], case[1]) for case in CSS_CODES] + + [ + qc.Code( + "C4", + ["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + ["X_0 X_1", "X_0 X_2"], + ["Z_0 Z_2", "Z_0 Z_1"], + ) + ], + ids=[case[0] for case in CSS_CODES] + ["C4"], +) +@pytest.mark.parametrize("strategy", ["bare-css/v1", "flagged-css/v1"]) +def test_build_strategies_return_audit_clean_qodecs( + code: qc.Code, strategy: str +) -> None: + built = build_qodec(code, strategy=strategy, strict=False) + + report = _audit.audit(built) + assert not report.diagnostics, str(report) + expected = { + "prepare_z_all", + "prepare_x_all", + "syndrome", + "measure_z_all", + "measure_x_all", + "cx_all", + } + if code.name == "steane": + expected.add("h_all") + assert set(built.layers[0].gadgets) == expected + assert set(built.layers[0].instruction_set.instructions) == expected + + +@requires_stim +@pytest.mark.parametrize("strategy", ["bare-css/v1", "flagged-css/v1"]) +@pytest.mark.parametrize("strict", [False, True]) +def test_build_rejects_invalid_final_declarations( + monkeypatch, strategy, strict +) -> None: + from qdk.ec import _build + + original = _build._rebound + + def rebound_without_readouts(gadget, instruction): + rebound = original(gadget, instruction) + if instruction.mnemonic == "measure_z_all": + rebound.readouts = [] + return rebound + + monkeypatch.setattr(_build, "_rebound", rebound_without_readouts) + with pytest.raises(ValueError, match="did not pass audit"): + build_qodec( + _code("steane", catalog.make_steane_code), strategy=strategy, strict=strict + ) + + +@requires_stim +def test_hand_authored_readouts_need_incoming_frame_corrections() -> None: + fixture = c4() + report = _audit.audit(fixture) + assert len(report.errors) == 4 + assert {item.rule for item in report.errors} == {"gadget/readout-mismatch"} + for basis in ("x", "z"): + gadget = fixture.layers[0].gadgets[f"measure_{basis}{basis}"] + gadget.readouts = [ + (*readout.equation, f"in[0].{basis}[{index}]") + for index, readout in enumerate(gadget.readouts) + ] + corrected = _audit.audit(fixture) + assert corrected.ok, str(corrected) + + +# ── Round-tripping ────────────────────────────────────────────────────────── + + +@requires_stim +def test_built_qodec_round_trips_through_yaml(steane: qc.Qodec, tmp_path: Path) -> None: + restored = _round_tripped(steane, tmp_path / "bundle") + + assert restored == steane + + +@requires_stim +def test_structured_omissions_round_trip_through_yaml(tmp_path: Path) -> None: + built = qodec_from_code(_code("five_qubit", catalog.make_five_qubit_code)) + + restored = _round_tripped(built, tmp_path / "bundle") + + assert build_notes(restored)["omitted"] == build_notes(built)["omitted"] + + +@requires_stim +def test_built_qodec_round_trips_through_disk(steane: qc.Qodec, tmp_path: Path) -> None: + steane.save(str(tmp_path / "bundle")) + restored = qc.Qodec.load(str(tmp_path / "bundle" / steane.manifest_filename)) + + assert restored == steane + + +@requires_stim +def test_completion_is_idempotent_on_a_built_qodec( + steane: qc.Qodec, +) -> None: + recompleted = complete_qodec(steane) + + assert not _audit.audit(recompleted).diagnostics + for mnemonic, gadget in steane.layers[0].gadgets.items(): + before = {frozenset(str(a) for a in c) for c in gadget.checks} + after = { + frozenset(str(a) for a in c) + for c in recompleted.layers[0].gadgets[mnemonic].checks + } + assert before == after, mnemonic + assert gadget.readouts == recompleted.layers[0].gadgets[mnemonic].readouts + + +# ── Partial build ─────────────────────────────────────────────────────── + + +@requires_stim +def test_a_non_z_logical_basis_omits_the_gadgets_it_cannot_support() -> None: + """The five-qubit code's conventional basis has X components in logical Z.""" + built = qodec_from_code(_code("five_qubit", catalog.make_five_qubit_code)) + + omitted = build_notes(built)["omitted"] + assert "prepare_z_all" in omitted + assert "measure_z_all" in omitted + assert "syndrome" in built.layers[0].gadgets + assert set(built.layers[0].instruction_set.instructions) == set( + built.layers[0].gadgets + ) + + +@requires_stim +def test_omissions_carry_structured_reasons() -> None: + from collections.abc import Mapping + + built = qodec_from_code(_code("five_qubit", catalog.make_five_qubit_code)) + + assert all( + isinstance(reason, Mapping) + and set(reason) == {"stage", "kind", "message"} + and reason["stage"] in {"completion", "verification"} + and isinstance(reason["kind"], str) + and reason["kind"] + and isinstance(reason["message"], str) + and reason["message"] + for reason in build_notes(built)["omitted"].values() + ) + + +@requires_stim +def test_unexpected_completion_failure_propagates(monkeypatch) -> None: + from qdk.ec import _build + + original = _build.complete_gadget + + def complete_or_fail(gadget: qc.Gadget) -> qc.Gadget: + if gadget.implements.mnemonic == "syndrome": + raise RuntimeError("unexpected completion failure") + return original(gadget) + + monkeypatch.setattr(_build, "complete_gadget", complete_or_fail) + + with pytest.raises(RuntimeError, match="unexpected completion failure"): + qodec_from_code(_code("steane", catalog.make_steane_code)) + + +def test_unexpected_verification_failure_propagates(monkeypatch) -> None: + from qdk.ec import _build + + def fail_verification(gadget: qc.Gadget) -> str | None: + raise RuntimeError("unexpected verification failure") + + monkeypatch.setattr(_build, "gadget_action_mismatch", fail_verification) + + with pytest.raises(RuntimeError, match="unexpected verification failure"): + qodec_from_code(_code("steane", catalog.make_steane_code)) + + +def test_strict_mode_raises_instead_of_omitting() -> None: + code = _code("five_qubit", catalog.make_five_qubit_code) + + with pytest.raises(ValueError, match="could not build"): + qodec_from_code(code, strict=True) + + +@requires_stim +def test_strict_mode_is_a_no_op_when_everything_builds() -> None: + code = _code("steane", catalog.make_steane_code) + + assert set(qodec_from_code(code, strict=True).layers[0].gadgets) == set( + qodec_from_code(code).layers[0].gadgets + ) + + +@requires_stim +@pytest.mark.parametrize("strategy", ["flagged-css/v1", "bare-css/v1"]) +def test_unsupported_transversal_h_is_omitted_or_raises(strategy: str) -> None: + code = _code("repetition3", lambda: catalog.make_repetition_code(3)) + built = build_qodec(code, strategy=strategy, strict=False) + + assert "h_all" not in built.layers[0].gadgets + assert "cx_all" in built.layers[0].gadgets + assert set(build_notes(built)["omitted"]) == {"h_all"} + assert build_notes(built)["omitted"]["h_all"]["stage"] == "verification" + with pytest.raises(ValueError, match="could not build 'h_all'"): + build_qodec(code, strategy=strategy) + + +@requires_stim +def test_transversal_h_must_match_the_declared_logical_action() -> None: + built = qodec_from_code(c4().codes["C4"]) + + assert "h_all" not in built.layers[0].gadgets + failure = build_notes(built)["omitted"]["h_all"] + assert failure["stage"] == "verification" + assert failure["kind"] == "ActionMismatch" + + +@requires_stim +def test_logical_basis_choice_can_decide_whether_readout_builds() -> None: + """Two valid logical bases for [[4,2,2]] behave differently. + + This pins an observed basis-dependence in the observable-discovery pass + completion relies on, so the difference is visible rather than silent. + """ + fixture_basis = qodec_from_code(c4().codes["C4"], name="c4_fixture_basis") + catalog_basis = qodec_from_code(_code("c422", catalog.make_422_code)) + + assert set(build_notes(fixture_basis)["omitted"]) == {"h_all"} + assert "measure_z_all" in build_notes(catalog_basis)["omitted"] + + +# ── Multi-logical-qubit codes ─────────────────────────────────────────────── + + +@requires_stim +def test_a_k_equals_two_code_gets_a_two_block_cnot() -> None: + fixture = c4() + built = qodec_from_code(fixture.codes["C4"], name="c4_build") + + assert set(built.layers[0].gadgets) == { + "prepare_z_all", + "prepare_x_all", + "syndrome", + "measure_z_all", + "measure_x_all", + "cx_all", + } + gadget = built.layers[0].gadgets["cx_all"] + assert len(gadget.implements.inputs) == len(gadget.implements.outputs) == 2 + for boundary in (gadget.inputs, gadget.outputs): + assert [tuple(encoding.support) for encoding in boundary] == [ + ("0", "1", "2", "3"), + ("4", "5", "6", "7"), + ] + assert ( + gadget.implements.action + == fixture.layers[0].gadgets["transversal_cx"].implements.action + ) + assert action.gadget_action_mismatch(gadget) is None + + +@requires_stim +def test_transversal_cnot_is_verified_for_a_large_k_code() -> None: + """Logical coordinates remain authored-order even when k is large.""" + built = qodec_from_code(_code("iceberg8", lambda: catalog.make_iceberg_code(8))) + + gadget = built.layers[0].gadgets["cx_all"] + assert len(built.codes["iceberg8"].x) == 6 + assert len(gadget.inputs) == len(gadget.outputs) == 2 + assert action.gadget_action_mismatch(gadget) is None + assert set(built.layers[0].gadgets) | set(build_notes(built)["omitted"]) == { + "prepare_z_all", + "prepare_x_all", + "syndrome", + "measure_z_all", + "measure_x_all", + "h_all", + "cx_all", + } + + +@requires_stim +def test_transversal_h_covers_every_logical_qubit() -> None: + code = qc.Code("pair", stabilizers=[], x=["X_0", "X_1"], z=["Z_0", "Z_1"]) + built = qodec_from_code(code, flags=0, strict=True) + + gadget = built.layers[0].gadgets["h_all"] + assert gadget.circuit.source == "H 0 1\n" + assert action.gadget_action_mismatch(gadget) is None + + +# ── Rejected inputs ───────────────────────────────────────────────────────── + + +def test_y_components_are_rejected_with_an_actionable_message() -> None: + code = qc.Code("has_y", stabilizers=["Y_0 X_1"], x=["X_1"], z=["Z_0 Z_1"]) + + with pytest.raises(NotImplementedError, match="Y components"): + qodec_from_code(code) + + +def test_a_code_with_no_logical_qubits_is_rejected() -> None: + """A [[1, 0]] code: a valid stabilizer code that encodes nothing.""" + code = qc.Code("full_rank", stabilizers=["Z_0"], x=[], z=[]) + + with pytest.raises(ValueError, match="no logical qubits"): + qodec_from_code(code) + + +def test_an_unnamed_code_requires_an_explicit_name() -> None: + code = qc.Code("", stabilizers=["Z_0 Z_1"], x=["X_0 X_1"], z=["Z_0"]) + + with pytest.raises(ValueError, match="no name"): + qodec_from_code(code) diff --git a/source/qdk_package/tests/ec_tests/develop/test_complete_qodec.py b/source/qdk_package/tests/ec_tests/develop/test_complete_qodec.py new file mode 100644 index 00000000000..621871b5728 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/develop/test_complete_qodec.py @@ -0,0 +1,113 @@ +"""Tests for whole-qodec completion.""" + +from __future__ import annotations + +import qodec as qc +import pytest + +from ec_tests.testing.qodecs import c4 +from qdk.ec import _fill +from qdk.ec._fill import complete_qodec +from qdk.ec._readouts import as_readout +from ec_tests.testing.optional import requires_stim + + +def _stripped(qodec: qc.Qodec) -> qc.Qodec: + """``qodec`` with every gadget's checks removed, i.e. an unfinished draft.""" + layers = [] + for layer in qodec.layers: + drafts = [ + qc.Gadget( + gadget.implements, + gadget.circuit, + inputs=list(gadget.inputs), + outputs=list(gadget.outputs), + checks=[], + readouts=[as_readout(entry) for entry in gadget.readouts], + parameter_bindings=dict(gadget.parameter_bindings), + metadata=dict(gadget.metadata), + ) + for gadget in layer.gadgets.values() + ] + layers.append(qc.Layer(layer.instruction_set, gadgets=drafts)) + return qc.Qodec(layers, name=qodec.name, description=qodec.description) + + +@requires_stim +def test_complete_qodec_fills_in_checks_for_every_gadget() -> None: + draft = _stripped(c4()) + assert all( + not gadget.checks for layer in draft.layers for gadget in layer.gadgets.values() + ) + + completed = complete_qodec(draft) + + discovered = [ + (layer_index, mnemonic, len(gadget.checks)) + for layer_index, layer in enumerate(completed.layers) + for mnemonic, gadget in layer.gadgets.items() + ] + assert discovered, "the c4 qodec has gadgets to complete" + assert any(count > 0 for _, _, count in discovered) + + +@requires_stim +def test_complete_qodec_leaves_the_input_untouched() -> None: + draft = _stripped(c4()) + + complete_qodec(draft) + + assert all( + not gadget.checks for layer in draft.layers for gadget in layer.gadgets.values() + ) + + +@requires_stim +def test_complete_qodec_preserves_the_layer_chain_and_identity() -> None: + qodec = c4() + + completed = complete_qodec(qodec) + + assert completed is not qodec + assert completed.name == qodec.name + assert completed.description == qodec.description + assert [layer.instruction_set.name for layer in completed.layers] == [ + layer.instruction_set.name for layer in qodec.layers + ] + assert [sorted(layer.gadgets) for layer in completed.layers] == [ + sorted(layer.gadgets) for layer in qodec.layers + ] + + +@requires_stim +def test_complete_qodec_matches_the_authored_checks() -> None: + qodec = c4() + + completed = complete_qodec(_stripped(qodec)) + + for layer, completed_layer in zip(qodec.layers, completed.layers): + for mnemonic, authored in layer.gadgets.items(): + rediscovered = completed_layer.gadgets[mnemonic] + assert { + frozenset(str(atom) for atom in check) for check in authored.checks + } <= { + frozenset(str(atom) for atom in check) for check in rediscovered.checks + }, f"completion dropped an authored check of {mnemonic!r}" + + +def test_completion_error_identifies_gadget_and_preserves_cause( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cause = ValueError("invalid circuit") + + def fail(_gadget: qc.Gadget) -> qc.Gadget: + raise cause + + monkeypatch.setattr(_fill, "complete_gadget", fail) + + with pytest.raises( + RuntimeError, match="failed to fill layer 2 gadget 'broken'" + ) as caught: + _fill._try_complete_gadget(object(), 2, "broken") # type: ignore[arg-type] + + assert caught.value.__cause__ is cause diff --git a/source/qdk_package/tests/ec_tests/develop/test_completion.py b/source/qdk_package/tests/ec_tests/develop/test_completion.py new file mode 100644 index 00000000000..8d4934740ed --- /dev/null +++ b/source/qdk_package/tests/ec_tests/develop/test_completion.py @@ -0,0 +1,104 @@ +"""Tests for deterministic gadget completion.""" + +from __future__ import annotations + +import qodec as qc +import pytest + +from ec_tests.testing.code_catalog import make_steane_code +from qdk.ec import audit, build_qodec, filled +from qdk.ec._analysis.code_algebra import as_qodec_code +from qdk.ec._fill import complete_gadget +from qdk.ec._readouts import as_readout +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_filled_returns_completed_gadget_copy(idle_gadget: qc.Gadget) -> None: + draft = qc.Gadget( + idle_gadget.implements, + idle_gadget.circuit, + inputs=list(idle_gadget.inputs), + outputs=list(idle_gadget.outputs), + checks=[], + readouts=[as_readout(value) for value in idle_gadget.readouts], + parameter_bindings=dict(idle_gadget.parameter_bindings), + metadata=dict(idle_gadget.metadata), + ) + + completed = filled(draft) + + assert completed is not draft + assert list(draft.checks) == [] + assert len(completed.checks) > 0 + assert completed.implements == draft.implements + assert completed.circuit == draft.circuit + + +def test_completion_preserves_named_flag_readouts(prepare_zz_gadget: qc.Gadget) -> None: + completed = complete_gadget(prepare_zz_gadget) + (readout,) = completed.readouts + assert readout.is_flag + assert readout.name == "reject" + assert completed.readouts == prepare_zz_gadget.readouts + + +@pytest.mark.parametrize( + "mnemonic", ["measure_x_all", "measure_z_all", "h_all", "cx_all"] +) +def test_completion_preserves_verified_frame_equations(mnemonic: str) -> None: + protocol = build_qodec( + as_qodec_code(make_steane_code(), "steane"), strategy="bare-css/v1" + ) + original = protocol.layers[0].gadgets[mnemonic] + before = protocol.dumps() + + completed = complete_gadget(original) + + assert completed is not original + assert completed.checks == original.checks + assert completed.readouts == original.readouts + assert protocol.dumps() == before + gadgets = protocol.layers[0].gadgets + gadgets[mnemonic] = completed + protocol.layers[0].gadgets = gadgets + assert not audit(protocol).diagnostics + + +def test_completion_does_not_preserve_an_invalid_authored_check() -> None: + protocol = build_qodec( + as_qodec_code(make_steane_code(), "steane"), strategy="bare-css/v1" + ) + gadget = protocol.layers[0].gadgets["syndrome"] + expected = tuple(gadget.checks) + gadget.checks = [*expected, [1]] + + completed = complete_gadget(gadget) + + assert completed.checks == expected + assert gadget.checks[-1] == (1,) + + +def test_filled_restores_missing_readouts_and_output_frame_relations() -> None: + code = qc.Code( + "C4", + stabilizers=["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + x=["X_0 X_1", "X_0 X_2"], + z=["Z_0 Z_2", "Z_0 Z_1"], + ) + protocol = build_qodec(code, strategy="bare-css/v1", strict=False) + layer = protocol.layers[0] + layer.gadgets["measure_x_all"].readouts = [] + layer.gadgets["measure_z_all"].readouts = [] + layer.gadgets["cx_all"].checks = [] + before = protocol.dumps() + + completed = filled(protocol) + + assert isinstance(completed, qc.Qodec) + assert completed is not protocol + report = audit(completed) + assert not report.diagnostics, str(report) + assert protocol.dumps() == before + assert filled(completed) == completed diff --git a/source/qdk_package/tests/ec_tests/inference/__init__.py b/source/qdk_package/tests/ec_tests/inference/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/inference/test_channel_action.py b/source/qdk_package/tests/ec_tests/inference/test_channel_action.py new file mode 100644 index 00000000000..61030e81506 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_channel_action.py @@ -0,0 +1,659 @@ +"""Tests for channel-action profiling.""" + +from __future__ import annotations + +import pytest +import qodec as qc +from qodec.gadgets import Encoding + +import qdk.ec as ec +from qdk.ec._analysis.channel_action import ( + ChannelAction, + action_of, + are_equivalent_mod_paulis, + are_outcome_equivalent, + declared_action_of, + declared_program_of, + gadget_action_mismatch, + input_qubits_of, + realized_action_of, +) +from qdk.ec._analysis.propagation.interpreter import program_of, propagate_faults +from qdk.ec._analysis.propagation.isa_actions import remap_pauli +from qdk.ec._analysis.propagation.frames import FrameGroup, PauliFrame +from qdk.ec._analysis.propagation.pauli import Pauli +from qdk.ec._build import _physical_isa +from qdk.ec._layout import ProgramLayout +from ec_tests.testing.optional import requires_stim + + +def _action_of_gadget(gadget: qc.Gadget) -> ChannelAction: + return action_of(program_of(gadget)) + + +def test_remap_pauli_preserves_signed_observable() -> None: + from qdk.ec._audit._structure import _instruction_issues + + assert remap_pauli("-Y_0 Y_1", {0: 2, 1: 5}) == -Pauli("Y_2 Y_5") + instruction = qc.Instruction( + "signed_measure", + inputs=[qc.instructions.BlockOperand("pair")], + action=[qc.actions.Observe(["-Y_0 Y_1"])], + ) + assert list(_instruction_issues(instruction, {"pair": 2})) == [] + + +def test_clifford_channel_keeps_all_logical_generator_images() -> None: + operand = qc.instructions.BlockOperand("pair") + instruction = qc.Instruction( + "green", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Clifford({"Z_0": "Y_0 X_1", "Z_1": "X_0 Z_1"})], + ) + instruction_set = qc.InstructionSet( + "logical", blocks=[qc.instructions.Block("pair", 2)], instructions=[instruction] + ) + code = qc.Code("pair", [], ["X_0", "X_1"], ["Z_0", "Z_1"]) + encoding = Encoding(code, support=["0"], block_types=["pair"]) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(instruction_set, "- green: [0]", format="yaml"), + inputs=[encoding], + outputs=[encoding], + ) + expected = {Pauli("X_0"), Pauli("Z_0"), Pauli("X_1"), Pauli("Z_1")} + declared, realized = declared_action_of(gadget), realized_action_of(gadget) + assert set(declared._mapping) == expected + assert set(realized._mapping) == expected + assert declared.is_equivalent_to(realized) + + +@requires_stim +def test_c4_measurement_signs_use_circuit_readout_positions() -> None: + code = qc.Code( + "C4", + stabilizers=["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + x=["X_0 X_1", "X_0 X_2"], + z=["Z_0 Z_2", "Z_0 Z_1"], + ) + gadget = ( + ec.build_qodec(code, strategy="bare-css/v1", strict=False) + .layers[0] + .gadgets["measure_z_all"] + ) + profile = ec.GadgetProfile(gadget) + assert { + generator.pauli: generator.frame + for generator in profile.action._observables.generators + } == {Pauli("Z_0"): frozenset({0, 2}), Pauli("Z_1"): frozenset({0, 1})} + + +@requires_stim +@pytest.mark.parametrize("decoded", [False, True]) +def test_action_frames_use_circuit_readouts( + measure_zz_gadget: qc.Gadget, + idle_gadget: qc.Gadget, + prepare_xx_gadget: qc.Gadget, + decoded: bool, +) -> None: + for gadget in (measure_zz_gadget, idle_gadget, prepare_xx_gadget): + program = program_of(gadget) + if decoded: + action = realized_action_of(gadget) + else: + action = action_of(program) + generators = ( + *action._stabilizers.generators, + *action._observables.generators, + *action._mapping.values(), + ) + readouts = set(range(len(program.readouts))) + assert all(generator.frame <= readouts for generator in generators) + + +def _action_from_stim(source: str) -> ChannelAction: + return action_of(qc.gadgets.Circuit(_physical_isa(), source, format="stim")) + + +@requires_stim +def test_interleaved_resets_do_not_offset_readout_indices() -> None: + action = _action_from_stim("R 2\nM 0\nR 2\nM 1\n") + assert { + generator.pauli: generator.frame + for generator in action._observables.generators + if generator.pauli.weight + } == {Pauli("Z_0"): frozenset({0}), Pauli("Z_1"): frozenset({1})} + + +@requires_stim +def test_correlated_readouts_keep_one_shared_sign_variable() -> None: + action = _action_from_stim("R 0 1\nH 0\nCX 0 1\nM 0\nM 1\n") + assert action._stabilizers.frame_of(Pauli("Z_0")) == frozenset({0}) + assert action._stabilizers.frame_of(Pauli("Z_1")) == frozenset({0}) + assert action._stabilizers.frame_of(Pauli("Z_0 Z_1")) == frozenset() + + +@requires_stim +def test_unrecorded_reset_outcome_is_averaged_out() -> None: + action = _action_from_stim("R 0 1\nH 0\nCX 0 1\nR 0\n") + assert ( + action._stabilizers.unframed == FrameGroup([PauliFrame(Pauli("Z_0"))]).unframed + ) + assert all(not generator.frame for generator in action._stabilizers.generators) + + +@requires_stim +def test_hidden_outcomes_cancel_in_retained_joint_relations() -> None: + action = _action_from_stim("R 0 1 2\nH 0\nCX 0 1\nCX 0 2\nR 0\n") + expected = FrameGroup([PauliFrame(Pauli("Z_0")), PauliFrame(Pauli("Z_1 Z_2"))]) + assert action._stabilizers.unframed == expected.unframed + assert all(not generator.frame for generator in action._stabilizers.generators) + + +@requires_stim +def test_readout_can_recover_a_hidden_reset_sign() -> None: + action = _action_from_stim("R 0 1\nH 0\nCX 0 1\nR 0\nM 1\n") + assert action._stabilizers.frame_of(Pauli("Z_0")) == frozenset() + assert action._stabilizers.frame_of(Pauli("Z_1")) == frozenset({0}) + + +@requires_stim +def test_deterministic_readouts_keep_their_record_positions() -> None: + action = _action_from_stim("R 1\nM 1\nM 0\n") + assert action._observables.frame_of(Pauli("Z_0")) == frozenset({1}) + assert action._stabilizers.frame_of(Pauli("Z_1")) == frozenset() + + +def test_flags_keep_their_positions_in_action_readout_indices() -> None: + instruction_set = _physical_isa() + instruction_set.instructions["flag"] = qc.Instruction("flag", flags=["reject"]) + program = qc.gadgets.Circuit(instruction_set, "- flag: []\n- M: [0]", format="yaml") + assert len(program.readouts) == 2 + assert action_of(program)._observables.frame_of(Pauli("Z_0")) == frozenset({1}) + + +@pytest.mark.parametrize( + "source", + [ + "- negative_z: [0]", + "- R: [0, 1]\n- H: [0]\n- CX: [0, 1]\n- R: [0]\n- negative_z: [1]", + ], +) +def test_signed_readout_conversion_preserves_the_eigenvalue(source: str) -> None: + instruction_set = _physical_isa() + instruction_set.instructions["negative_z"] = qc.Instruction( + "negative_z", + inputs=[qc.instructions.BlockOperand("qubit")], + action=[qc.actions.Observe(["-Z_0"])], + ) + action = action_of(qc.gadgets.Circuit(instruction_set, source, format="yaml")) + expected = PauliFrame(Pauli("-Z_1" if "CX" in source else "-Z_0"), frozenset({0})) + assert expected in action._stabilizers.generators + + +@requires_stim +def test_fault_probe_frames_are_indexed_by_readouts_not_reset_rows() -> None: + program = qc.gadgets.Circuit(_physical_isa(), "R 1\nM 0\nR 1\n", format="stim") + deltas, hidden, readouts = propagate_faults( + program, + [ec.FaultEvent.after(1, readout_flips=0)], + [Pauli.identity()], + residual_frames=[frozenset({0})], + ) + assert hidden == 2 and readouts == 1 + assert deltas[hidden, 0] + assert deltas[hidden + readouts, 0] + with pytest.raises(ValueError, match="probe readout index 1 is out of bounds"): + propagate_faults( + program, + [ec.FaultEvent()], + [Pauli.identity()], + residual_frames=[frozenset({1})], + ) + + +@requires_stim +def test_input_qubits_of_idle_channel_is_nonempty(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + inputs = input_qubits_of(program) + assert isinstance(inputs, frozenset) + assert all(isinstance(qubit, int) for qubit in inputs) + assert inputs <= frozenset(range(ProgramLayout.of(program).total_qubits)) + + +@requires_stim +def test_action_of_idle_channel_returns_channel_action( + idle_gadget: qc.Gadget, +) -> None: + action = _action_of_gadget(idle_gadget) + assert isinstance(action, ChannelAction) + assert isinstance(action._observables, FrameGroup) + assert isinstance(action._stabilizers, FrameGroup) + assert isinstance(action._mapping, dict) + + +@requires_stim +def test_action_is_equivalent_to_itself(idle_gadget: qc.Gadget) -> None: + action = _action_of_gadget(idle_gadget) + assert action.is_equivalent_to(action) + assert action.is_equivalent_to(action, modulo_paulis=True) + assert are_equivalent_mod_paulis(action, action) + assert are_outcome_equivalent(action, action) + + +@pytest.mark.parametrize( + "expected_frames,actual_frames,negated_output,equivalent", + [ + (((0,),), ((17,),), None, True), + (((0,), (0,)), ((3,), (4,)), None, False), + (((0,), (1,)), ((2,), (2, 7)), None, True), + (((),), ((),), 0, False), + (((0,),), ((3,),), 0, True), + (((0,), (0,)), ((3,), (3,)), 1, False), + (((0,), (1,), (0, 1)), ((3,), (4,), (3, 4)), None, True), + (((0,), (1,), (0, 1)), ((3,), (4,), (5,)), None, False), + (((0,), (1,), (0, 1)), ((3,), (4,), (3, 4)), 2, False), + ((), (), None, True), + ], +) +def test_action_comparison_and_explanation_agree_on_sign_relations( + expected_frames: tuple[tuple[int, ...], ...], + actual_frames: tuple[tuple[int, ...], ...], + negated_output: int | None, + equivalent: bool, +) -> None: + expected = ChannelAction._create( + FrameGroup([]), + FrameGroup( + PauliFrame(Pauli({index: "Z"}), frozenset(frame)) + for index, frame in enumerate(expected_frames) + ), + {}, + ) + actual = ChannelAction._create( + FrameGroup([]), + FrameGroup( + PauliFrame( + ( + -Pauli({index: "Z"}) + if index == negated_output + else Pauli({index: "Z"}) + ), + frozenset(frame), + ) + for index, frame in enumerate(actual_frames) + ), + {}, + ) + for left, right in ((expected, actual), (actual, expected)): + assert left.is_equivalent_to(right) == equivalent + assert are_outcome_equivalent(left, right) == equivalent + assert (left.why_not_equivalent_to(right) == "") == equivalent + assert left.is_equivalent_to(right, modulo_paulis=True) + + +def test_action_comparison_keeps_measurement_and_preparation_sign_correlations() -> ( + None +): + expected = ChannelAction._create( + FrameGroup([PauliFrame(Pauli("Z_0"), frozenset({2}))]), + FrameGroup([PauliFrame(Pauli("Z_0"), frozenset({2}))]), + {}, + ) + independent = ChannelAction._create( + FrameGroup([PauliFrame(Pauli("Z_0"), frozenset({4}))]), + FrameGroup([PauliFrame(Pauli("Z_0"), frozenset({6}))]), + {}, + ) + assert expected.is_equivalent_to(independent, modulo_paulis=True) + assert not expected.is_equivalent_to(independent) + assert expected.why_not_equivalent_to(independent) + + +def test_action_comparison_does_not_assume_mapping_corrections() -> None: + operator = Pauli("X_0") + expected = ChannelAction._create( + FrameGroup([]), FrameGroup([]), {operator: PauliFrame(operator)} + ) + actual = ChannelAction._create( + FrameGroup([]), + FrameGroup([]), + {operator: PauliFrame(-operator, frozenset({7}))}, + ) + assert not expected.is_equivalent_to(actual) + assert expected.why_not_equivalent_to(actual) + + +@requires_stim +def test_distinct_gadgets_are_not_equivalent( + idle_gadget: qc.Gadget, measure_xx_gadget: qc.Gadget +) -> None: + idle = _action_of_gadget(idle_gadget) + measure = _action_of_gadget(measure_xx_gadget) + assert not idle.is_equivalent_to(measure) + assert not idle.is_equivalent_to(measure, modulo_paulis=True) + assert not are_equivalent_mod_paulis(idle, measure) + + +@requires_stim +def test_sign_flipped_action_is_mod_paulis_equivalent_but_not_outcome( + idle_gadget: qc.Gadget, +) -> None: + action = _action_of_gadget(idle_gadget) + if not action._mapping: + return + flipped_mapping = {key: value * -1 for key, value in action._mapping.items()} + flipped = ChannelAction._create( + action._observables, action._stabilizers, flipped_mapping + ) + assert are_equivalent_mod_paulis(action, flipped) + assert flipped.is_equivalent_to(action, modulo_paulis=True) + assert not are_outcome_equivalent(action, flipped) + assert not flipped.is_equivalent_to(action) + + +@requires_stim +def test_different_stabilizers_are_not_mod_paulis_equivalent( + idle_gadget: qc.Gadget, +) -> None: + action = _action_of_gadget(idle_gadget) + extra = FrameGroup( + list(action._stabilizers.generators) + [PauliFrame(Pauli({0: "Z"}))] + ) + perturbed = ChannelAction._create(action._observables, extra, action._mapping) + assert not are_equivalent_mod_paulis(action, perturbed) + + +@requires_stim +def test_preparation_declared_stabilizers_are_deterministic( + prepare_xx_gadget: qc.Gadget, + prepare_zz_gadget: qc.Gadget, +) -> None: + """A ``stabilize`` preparation must fix its stabilisers at a definite +1. + + Regression: the interpreter enacted ``stabilize P`` as a bare projective + measurement, so an X-basis preparation (``P`` anticommutes with the |0⟩ + reset) left the prepared sign riding on the random projection outcome — a + spurious frame on the *declared* action that made every prepare_x gadget mismatch + its deterministic (reset + H) circuit. Z-basis preparations were + unaffected because Z already stabilises |0⟩. Both must come out frame-free + and audit-clean. + """ + for gadget in (prepare_xx_gadget, prepare_zz_gadget): + declared = declared_action_of(gadget) + generators = declared._stabilizers.standardized().generators + assert generators, "preparation fixes no stabilisers" + assert all(not framed.frame for framed in generators), ( + "preparation left an outcome frame on its stabilisers; `stabilize` " + "must deterministically prepare the +1 eigenstate" + ) + assert gadget_action_mismatch(gadget) is None + + +@requires_stim +def test_idle_declared_and_realized_actions_match_golden_values( + idle_gadget: qc.Gadget, +) -> None: + profile = ec.GadgetProfile(idle_gadget) + + expected = ( + "mapping:\n" " X_0 → X_0\n" " Z_0 → Z_0\n" " X_1 → X_1\n" " Z_1 → Z_1" + ) + assert str(profile.objective) == expected + assert str(profile.action) == expected + + +@requires_stim +@pytest.mark.parametrize( + "mnemonic, expected", + [ + ("prepare_zz", "stabilizers:\n Z_0 = +1\n Z_1 = +1"), + ( + "transversal_cx", + "mapping:\n" + " X_0 → X_0 X_2\n" + " Z_0 → Z_0\n" + " X_1 → X_1 X_3\n" + " Z_1 → Z_1\n" + " X_2 → X_2\n" + " Z_2 → Z_0 Z_2\n" + " X_3 → X_3\n" + " Z_3 → Z_1 Z_3", + ), + ( + "measure_zz", + "observables:\n" + " Z_0 = -1^(circuit.readouts[0] ⊕ circuit.readouts[2])\n" + " Z_1 = -1^(circuit.readouts[0] ⊕ circuit.readouts[1])", + ), + ], +) +def test_action_display_examples( + translation: qc.Layer, mnemonic: str, expected: str +) -> None: + from IPython.lib.pretty import pretty + + action = ec.GadgetProfile(translation.gadgets[mnemonic]).action + assert str(action) == expected + assert repr(action) == expected + assert pretty(action) == expected + + +@pytest.mark.parametrize( + "operator, readouts, expected", + [ + ("Z_1", (), "+1"), + ("-Z_1", (), "-1"), + ("Z_1", (0,), "-1^(circuit.readouts[0])"), + ("-Z_1", (0,), "-1^(1 ⊕ circuit.readouts[0])"), + ("Z_1", (12, 2), "-1^(circuit.readouts[2] ⊕ circuit.readouts[12])"), + ("-Z_1", (12, 2), "-1^(1 ⊕ circuit.readouts[2] ⊕ circuit.readouts[12])"), + ], +) +def test_action_display_preserves_eigenvalue_signs( + operator: str, readouts: tuple[int, ...], expected: str +) -> None: + action = ChannelAction._create( + FrameGroup([]), + FrameGroup([PauliFrame(Pauli(operator), frozenset(readouts))]), + {}, + ) + assert str(action) == f"stabilizers:\n Z_1 = {expected}" + + +@pytest.mark.parametrize( + "operator, readouts, expected", + [ + ("-Z_1", (), "-Z_1"), + ("X_1", (1, 0), "-1^(circuit.readouts[0] ⊕ circuit.readouts[1]) X_1"), + ("-Z_1", (0,), "-1^(1 ⊕ circuit.readouts[0]) Z_1"), + ("iZ_1", (0,), "i -1^(circuit.readouts[0]) Z_1"), + ("-iZ_1", (0,), "i -1^(1 ⊕ circuit.readouts[0]) Z_1"), + ], +) +def test_action_display_preserves_mapping_signs( + operator: str, readouts: tuple[int, ...], expected: str +) -> None: + action = ChannelAction._create( + FrameGroup([]), + FrameGroup([]), + {Pauli("X_1"): PauliFrame(Pauli(operator), frozenset(readouts))}, + ) + assert str(action) == f"mapping:\n X_1 → {expected}" + + +def test_action_display_keeps_shared_readout_signs_across_sections() -> None: + measured = PauliFrame(Pauli("Z_0"), frozenset({0})) + action = ChannelAction._create(FrameGroup([measured]), FrameGroup([measured]), {}) + assert str(action) == ( + "observables:\n Z_0 = -1^(circuit.readouts[0])\n" + "stabilizers:\n Z_0 = -1^(circuit.readouts[0])" + ) + assert str(measured) == "Z^{0}" + + +def test_action_display_order_is_numeric_and_does_not_mutate_generators() -> None: + operators = [Pauli("Z_12"), Pauli("-Z_2"), Pauli("X_2")] + generators = tuple(PauliFrame(operator) for operator in operators) + action = ChannelAction._create(FrameGroup([]), FrameGroup(generators), {}) + assert str(action) == "stabilizers:\n X_2 = +1\n Z_2 = -1\n Z_12 = +1" + assert action._stabilizers.generators == generators + + +def test_action_display_handles_empty_relations_and_pretty_cycles() -> None: + from io import StringIO + from IPython.lib.pretty import RepresentationPrinter, pretty + + action = ChannelAction._create(FrameGroup([]), FrameGroup([]), {}) + assert str(action) == "no observable, stabilizer, or mapping relations" + assert repr(action) == pretty(action) == str(action) + output = StringIO() + printer = RepresentationPrinter(output) + action._repr_pretty_(printer, cycle=True) + printer.flush() + assert output.getvalue() == "..." + + +@requires_stim +def test_realized_action_is_invariant_under_equivalent_logical_representatives( + idle_gadget: qc.Gadget, +) -> None: + equivalent_code = qc.Code( + "C4-alternate-basis", + stabilizers=["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + x=["X_2 X_3", "X_1 X_3"], + z=["Z_1 Z_3", "Z_2 Z_3"], + ) + alternate = qc.Gadget( + idle_gadget.implements, + idle_gadget.circuit, + inputs=[ + Encoding(equivalent_code, support=list(entry.support)) + for entry in idle_gadget.inputs + ], + outputs=[ + Encoding(equivalent_code, support=list(entry.support)) + for entry in idle_gadget.outputs + ], + checks=list(idle_gadget.checks), + readouts=list(idle_gadget.readouts), + ) + + original = ec.GadgetProfile(idle_gadget) + changed = ec.GadgetProfile(alternate) + assert original.action.is_equivalent_to(changed.action) + + +@requires_stim +def test_destructive_measurement_carries_no_logical_but_stays_distinguishable( + measure_zz_gadget: qc.Gadget, + measure_xx_gadget: qc.Gadget, + prepare_zz_gadget: qc.Gadget, +) -> None: + """Pins why ``_decode`` skips a logical with no image instead of raising. + + An empty mapping is the right answer for a destructive gadget, and the + observables still separate it from the other basis and from a preparation. + """ + measured = realized_action_of(measure_zz_gadget) + + assert not measured._mapping + for other in (measure_xx_gadget, prepare_zz_gadget): + assert not are_equivalent_mod_paulis(measured, realized_action_of(other)) + + +def test_declared_program_binds_inputs_and_outputs_to_the_same_indices( + idle_gadget: qc.Gadget, +) -> None: + """Pins the reference side of the action check: both operand sets are 0..n-1.""" + (call,) = declared_program_of(idle_gadget).calls() + + assert call.mnemonic == idle_gadget.implements.mnemonic + assert call.operands == [0, 1] + + +def test_declared_circuit_preserves_parameter_names() -> None: + operand = qc.instructions.BlockOperand("qubit") + parameter = qc.instructions.Parameter("theta", "number") + instruction = qc.Instruction( + "rotate", + inputs=[operand], + outputs=[operand], + parameters=[parameter], + action=[qc.actions.Rotate("Z_0", "theta")], + ) + instruction_set = qc.InstructionSet( + "rotations", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[instruction], + ) + circuit = qc.gadgets.Circuit( + instruction_set, + "- rotate: {operands: [0], arguments: {theta: angle}}", + format="yaml", + ) + encoding = Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + gadget = qc.Gadget( + instruction, + circuit, + inputs=[encoding], + outputs=[encoding], + parameter_bindings={"theta": "angle"}, + ) + declared = declared_program_of(gadget) + assert declared.instruction_set.instructions["rotate"].parameters == [parameter] + assert declared.calls()[0].arguments == {"theta": "theta"} + + +def test_action_explanation_identifies_missing_observable() -> None: + expected = ChannelAction._create( + FrameGroup([PauliFrame(Pauli({0: "X"}))]), FrameGroup([]), {} + ) + actual = ChannelAction._create( + FrameGroup([PauliFrame(Pauli({0: "Z"}))]), FrameGroup([]), {} + ) + assert expected.why_not_equivalent_to(actual) == ( + "Expected measured logical observable X; absent from the circuit's group." + ) + + +def test_action_explanation_identifies_output_image() -> None: + operator = Pauli({0: "X"}) + expected = ChannelAction._create( + FrameGroup([]), FrameGroup([]), {operator: PauliFrame(operator)} + ) + actual = ChannelAction._create( + FrameGroup([]), FrameGroup([]), {operator: PauliFrame(Pauli({0: "Z"}))} + ) + assert ( + expected.why_not_equivalent_to(actual) + == "Logical X: expected X; circuit gives Z." + ) + + +def test_action_explanation_identifies_opposite_sign() -> None: + expected = ChannelAction._create( + FrameGroup([]), FrameGroup([PauliFrame(Pauli({0: "Z"}))]), {} + ) + actual = ChannelAction._create( + FrameGroup([]), FrameGroup([PauliFrame(-Pauli({0: "Z"}))]), {} + ) + assert ( + expected.why_not_equivalent_to(actual) + == "Opposite sign parity: output stabilizer Z." + ) + + +def test_action_explanation_identifies_variable_sign() -> None: + expected = ChannelAction._create( + FrameGroup([]), FrameGroup([PauliFrame(Pauli({0: "Z"}))]), {} + ) + actual = ChannelAction._create( + FrameGroup([]), FrameGroup([PauliFrame(Pauli({0: "Z"}), frozenset({0}))]), {} + ) + assert expected.why_not_equivalent_to(actual) == ( + "Sign parity (output stabilizer Z): circuit varies with outcomes; expected is fixed." + ) diff --git a/source/qdk_package/tests/ec_tests/inference/test_check_discovery.py b/source/qdk_package/tests/ec_tests/inference/test_check_discovery.py new file mode 100644 index 00000000000..a505dcfa004 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_check_discovery.py @@ -0,0 +1,50 @@ +"""Smoke tests for internal check discovery. + +The module's heavy logic is exercised through `audit` and the C4 demo; +this file pins the public surface (`profile_of`, `simulate_channel`, +`Profile`) so a refactor cannot accidentally remove or rename them. +""" + +from __future__ import annotations + +from qdk.ec._checks import Profile, profile_of +from qdk.ec._analysis.check_discovery import simulate_channel +from ec_tests.testing.qodecs import c4 +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_profile_of_returns_profile_with_checks_and_readouts() -> None: + qodec = c4() + gadget = qodec.layers[0].gadgets["measure_zz"] + profile = profile_of(gadget) + assert isinstance(profile, Profile) + assert len(profile.checks) >= 1 + # measure_zz declares two observe outcomes, named positionally. + assert set(profile.readouts) >= {"0", "1"} + + +def test_profile_of_idle_round_finds_four_stabilizer_checks() -> None: + """C4's `idle` circuit runs both X- and Z-stabilizer extractions + in and out, yielding 4 deterministic checks.""" + qodec = c4() + gadget = qodec.layers[0].gadgets["idle"] + profile = profile_of(gadget) + assert len(profile.checks) == 4 + + +def test_simulate_channel_returns_simulation() -> None: + qodec = c4() + gadget = qodec.layers[0].gadgets["idle"] + sim = simulate_channel(gadget) + assert sim.simulation.outcome_count > 0 + + +def test_simulate_channel_with_declared_records_declared_outcomes() -> None: + """`with_declared` tells `simulate_channel` to also probe each + declared `Observe` Pauli after the walk.""" + qodec = c4() + gadget = qodec.layers[0].gadgets["measure_zz"] + sim = simulate_channel(gadget, with_declared=True) + assert len(sim.declared_outcomes) == 2 diff --git a/source/qdk_package/tests/ec_tests/inference/test_conditional_simulation.py b/source/qdk_package/tests/ec_tests/inference/test_conditional_simulation.py new file mode 100644 index 00000000000..882b04bdf99 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_conditional_simulation.py @@ -0,0 +1,126 @@ +"""Tests for the simulator-to-frame-group snapshot. + +These exercise :func:`qdk.ec._analysis.propagation.frame_group_of` +without committing to paulimer's specific choice of stabiliser representation +(which depends on internal basis choices). What we can pin down: + +* The number of generators equals ``simulation.qubit_count``. +* Each generator's Pauli structure equals ``clifford.image_z(q)``. +* Frame ``q`` is the support of ``sign_matrix`` row ``q``. +* Bell-correlation invariants survive a round-trip through the snapshot. +""" + +from __future__ import annotations + +from paulimer import OutcomeCompleteSimulation, SparsePauli, UnitaryOpcode + +from qdk.ec._analysis.propagation.stabilizer import frame_group_of +from qdk.ec._analysis.propagation.frames import FrameGroup +from qdk.ec._analysis.propagation.pauli import Pauli + + +def _fresh(qubit_count: int) -> OutcomeCompleteSimulation: + sim = OutcomeCompleteSimulation.with_capacity(qubit_count, 32, 32) + sim.reserve_qubits(qubit_count) + sim.reserve_outcomes(32, 32) + return sim + + +def _expected_frame( + simulation: OutcomeCompleteSimulation, qubit: int +) -> frozenset[int]: + return frozenset(list(simulation.sign_matrix.rows)[qubit].support) + + +# ── Basic invariants ──────────────────────────────────────────────────────── + + +def test_fresh_simulator_yields_empty_frames() -> None: + sim = _fresh(3) + group = frame_group_of(sim) + + assert isinstance(group, FrameGroup) + assert len(group.generators) == sim.qubit_count == 3 + for entry in group.generators: + assert entry.frame == frozenset() + + +def test_pauli_structures_match_clifford_image_z() -> None: + sim = _fresh(2) + sim.apply_unitary(UnitaryOpcode.Hadamard, [0]) + sim.apply_unitary(UnitaryOpcode.ControlledX, [0, 1]) + + group = frame_group_of(sim) + clifford = sim.clifford + for qubit, entry in enumerate(group.generators): + assert entry.pauli == Pauli.from_dense(clifford.image_z(qubit)) + + +# ── After measurements ───────────────────────────────────────────────────── + + +def test_frames_match_sign_matrix_after_measurements() -> None: + sim = _fresh(2) + # Put each qubit in a superposition then measure Z (each gives a random bit). + sim.apply_unitary(UnitaryOpcode.Hadamard, [0]) + sim.apply_unitary(UnitaryOpcode.Hadamard, [1]) + sim.measure(SparsePauli({0: "Z"})) + sim.measure(SparsePauli({1: "Z"})) + + group = frame_group_of(sim) + assert sim.sign_matrix.column_count >= 2 # two random bits introduced + for qubit, entry in enumerate(group.generators): + assert entry.frame == _expected_frame(sim, qubit) + + +# ── Bell correlations ────────────────────────────────────────────────────── + + +def test_bell_then_data_z_measurement_correlates_aux_z_with_data_z() -> None: + """Bell-pair (0=data, 1=aux), measure Z on data — Z_0 and Z_1 should + factor to the same outcome frame because Z_0 Z_1 is a stabiliser with + sign +1 (the Bell-Z) so Z_0 ≡ Z_1 modulo it. + """ + sim = _fresh(2) + sim.apply_unitary(UnitaryOpcode.PrepareBell, [0, 1]) + sim.measure(SparsePauli({0: "Z"})) + + group = frame_group_of(sim) + z_0 = Pauli({0: "Z"}) + z_1 = Pauli({1: "Z"}) + assert group.factorization_of(z_0) is not None + assert group.factorization_of(z_1) is not None + assert group.frame_of(z_0) == group.frame_of(z_1) + + +def test_frame_of_xors_factor_frames_consistently() -> None: + sim = _fresh(2) + sim.apply_unitary(UnitaryOpcode.Hadamard, [0]) + sim.apply_unitary(UnitaryOpcode.Hadamard, [1]) + sim.measure(SparsePauli({0: "Z"})) + sim.measure(SparsePauli({1: "Z"})) + + group = frame_group_of(sim) + z_0 = Pauli({0: "Z"}) + z_1 = Pauli({1: "Z"}) + factors = group.factorization_of(z_0 * z_1) + assert factors is not None + accumulated: frozenset[int] = frozenset() + for factor in factors: + accumulated ^= factor.frame + assert group.frame_of(z_0 * z_1) == accumulated + + +def test_deterministic_measurement_does_not_widen_frames() -> None: + """Measuring an observable that is already a stabiliser is deterministic; + it should not add a random column to the sign matrix, so frames stay + empty.""" + sim = _fresh(1) + # Z_0 is already a stabiliser of |0⟩, so measuring Z_0 is deterministic. + width_before = sim.sign_matrix.column_count + sim.measure(SparsePauli({0: "Z"})) + assert sim.sign_matrix.column_count == width_before + + group = frame_group_of(sim) + for entry in group.generators: + assert entry.frame == frozenset() diff --git a/source/qdk_package/tests/ec_tests/inference/test_essential_checks.py b/source/qdk_package/tests/ec_tests/inference/test_essential_checks.py new file mode 100644 index 00000000000..ca48f63d744 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_essential_checks.py @@ -0,0 +1,30 @@ +"""Tests for essential-check profiling.""" + +import qodec as qc +from qdk.ec._references import outcomes_of, parse_equations +from qdk.ec._checks import essential_checks_of +from qdk.ec._analysis.essential_checks import outcomes_flipped_by_anti_observables_of +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_anti_observable_flips_one_per_logical_basis_element( + idle_gadget: qc.Gadget, +) -> None: + flips = outcomes_flipped_by_anti_observables_of(idle_gadget) + expected_count = sum( + len(list(encoding.code.x)) * 2 for encoding in idle_gadget.inputs + ) + assert len(flips) == expected_count + for flip in flips: + assert isinstance(flip, frozenset) + + +def test_essential_checks_collapse_duplicate_checks(idle_gadget: qc.Gadget) -> None: + declared = tuple( + frozenset(outcomes_of(check)) for check in parse_equations(idle_gadget.checks) + ) + essential = essential_checks_of(idle_gadget) + assert len(set(essential)) == len(essential) + assert len(set(essential)) <= len(set(declared)) diff --git a/source/qdk_package/tests/ec_tests/inference/test_outcome_code.py b/source/qdk_package/tests/ec_tests/inference/test_outcome_code.py new file mode 100644 index 00000000000..f034a2539d4 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_outcome_code.py @@ -0,0 +1,32 @@ +"""Tests for outcome-code profiling.""" + +from qdk.ec._checks import OutcomeCode, outcome_code_of +from qdk.ec._analysis.propagation.interpreter import program_of +import qodec as qc +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_outcome_code_of_idle_channel_is_nonempty(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + code = outcome_code_of(program) + assert isinstance(code, OutcomeCode) + assert code.measurement_count == len(program.readouts) + assert code.check_count >= 1 + + +def test_outcome_code_of_returns_equal_results(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + assert outcome_code_of(program) == outcome_code_of(program) + + +def test_outcome_code_checks_are_subsets_of_measurement_indices( + idle_gadget: qc.Gadget, +) -> None: + program = program_of(idle_gadget) + code = outcome_code_of(program) + valid_indices = set(range(code.measurement_count)) + for check in code.checks(): + assert isinstance(check, frozenset) + assert check <= valid_indices diff --git a/source/qdk_package/tests/ec_tests/inference/test_program.py b/source/qdk_package/tests/ec_tests/inference/test_program.py new file mode 100644 index 00000000000..ad4e3d1c6a2 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_program.py @@ -0,0 +1,71 @@ +"""Tests for source-backed qodec circuits used by the analyses.""" + +import pytest + +from qdk.ec._analysis.propagation.interpreter import program_of, walk_program +from qdk.ec._layout import ProgramLayout +from qodec.gadgets import Circuit +import qodec as qc +from ec_tests.testing.optional import requires_stim + + +def test_program_rejects_unknown_mnemonic() -> None: + circuit = Circuit(qc.InstructionSet("empty"), "- rx: []", format="yaml") + with pytest.raises(ValueError, match="rx"): + _ = circuit.calls() + + +@requires_stim +def test_program_lookup_returns_instruction(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + first = program.calls()[0] + instr_def = program.instruction_set.instructions[first.mnemonic] + assert instr_def.mnemonic == first.mnemonic + + +def test_program_lookup_raises_on_unknown_mnemonic(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + with pytest.raises(KeyError, match="rx"): + _ = program.instruction_set.instructions["rx"] + + +def _padding_instruction_set() -> qc.InstructionSet: + qubit = qc.instructions.BlockOperand("qubit") + return qc.InstructionSet( + "padding", + blocks=[qc.instructions.Block("qubit", 1)], + instructions=[ + qc.Instruction( + "R", outputs=[qubit], action=[qc.actions.Stabilize(["Z_0"])] + ), + qc.Instruction("M", inputs=[qubit], action=[qc.actions.Observe(["Z_0"])]), + qc.Instruction("MPAD0", action=[qc.actions.Observe(["I"])]), + qc.Instruction("MPAD1", action=[qc.actions.Observe(["-I"])]), + ], + ) + + +@requires_stim +@pytest.mark.parametrize( + ("source", "expected", "qubit_count"), + [ + ("MPAD 0 1 1 0", [False, True, True, False], 0), + ("R 0 1\nM 0\nMPAD 0 1\nM 1", [False, False, True, False], 2), + ("REPEAT 2 {\nMPAD(0) 1 0\n}", [True, False, True, False], 0), + ], +) +def test_mpad_executes_as_constant_record_bits( + source: str, expected: list[bool], qubit_count: int +) -> None: + program = Circuit(_padding_instruction_set(), source, format="stim") + result = walk_program(program) + values = [ + bool(result.simulation.outcome_shift[row]) for row in result.observe_outcomes + ] + assert values == expected + assert not any( + result.simulation.random_outcome_indicator[row] + for row in result.observe_outcomes + ) + assert len(program.readouts) == len(expected) + assert ProgramLayout.of(program).total_qubits == qubit_count diff --git a/source/qdk_package/tests/ec_tests/inference/test_stabilizer_evaluation.py b/source/qdk_package/tests/ec_tests/inference/test_stabilizer_evaluation.py new file mode 100644 index 00000000000..27ecf715858 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/inference/test_stabilizer_evaluation.py @@ -0,0 +1,28 @@ +"""Tests for stabilizer evaluation through simulation targets.""" + +from __future__ import annotations + +import qodec as qc +from paulimer import PauliGroup + +from qdk.ec._analysis.propagation.frames import PauliFrame +from qdk.ec._analysis.propagation.interpreter import ( + program_of, + walk_for_outcome_code, +) +from qdk.ec._analysis.propagation.stabilizer import frame_group_of +from qdk.ec._layout import ProgramLayout +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_walking_a_program_stabilizes_every_qubit(idle_gadget: qc.Gadget) -> None: + program = program_of(idle_gadget) + walk = walk_for_outcome_code(program) + + frames = list(frame_group_of(walk.simulation).generators) + + assert all(isinstance(framed, PauliFrame) for framed in frames) + group = PauliGroup([framed.pauli for framed in frames], all_commute=True) + assert len(group.generators) == ProgramLayout.of(program).total_qubits diff --git a/source/qdk_package/tests/ec_tests/profile/__init__.py b/source/qdk_package/tests/ec_tests/profile/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/profile/test_code.py b/source/qdk_package/tests/ec_tests/profile/test_code.py new file mode 100644 index 00000000000..1f866955831 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/profile/test_code.py @@ -0,0 +1,355 @@ +"""Code profiling accepts qodec's canonical code type.""" + +import qodec as qc +import pytest +from paulimer import SparsePauli + +from ec_tests.testing.code_catalog import make_five_qubit_code +import qdk.ec as ec +from qdk.ec._analysis.code_algebra import ( + SubsystemCode, + as_qodec_code, + subsystem_code_of, +) +from qdk.ec._distance import code_distance_of + + +def repetition_code() -> qc.Code: + return qc.Code( + "repetition_2", + stabilizers=["Z_0 Z_1"], + x=["X_0 X_1"], + z=["Z_0"], + ) + + +def test_syndrome_of_accepts_qodec_code() -> None: + view = ec.CodeProfile(repetition_code()) + assert view.syndrome_of(SparsePauli({0: "X"})) == frozenset({0}) + + +def test_code_profile_snapshots_operators() -> None: + code = repetition_code() + profile = ec.CodeProfile(code) + code.stabilizers = ["X_0 X_1"] + code.x = ["Z_0 Z_1"] + code.z = ["X_0"] + + assert profile.stabilizers == (SparsePauli("Z_0 Z_1"),) + assert profile.x == (SparsePauli("X_0 X_1"),) + assert profile.z == (SparsePauli("Z_0"),) + assert profile.syndrome_of(SparsePauli("X_0")) == frozenset({0}) + + +def test_code_profile_requires_a_qodec_code() -> None: + with pytest.raises(TypeError, match="expected qodec.Code"): + ec.CodeProfile([]) + + with pytest.raises(TypeError, match="expected qodec.Code"): + ec.CodeProfile(ec.CodeProfile(repetition_code())) + + assert not hasattr(ec.CodeProfile, "of") + + +def test_code_profile_does_not_expose_algebra_construction() -> None: + profile = ec.CodeProfile(repetition_code()) + + assert isinstance(profile._algebra, SubsystemCode) + assert not isinstance(profile, SubsystemCode) + for name in ("relocated", "_from_operators", "_initialize_operators"): + assert not hasattr(profile, name) + + +@pytest.mark.parametrize("name", ["stabilizers", "x", "z", "gauge_x", "gauge_z"]) +def test_operator_collections_are_tuples_of_independent_copies(name: str) -> None: + profile = ec.CodeProfile(qc.Code("gauge", ["Z_0 Z_1"], ["X_2"], ["Z_2"])) + operators = getattr(profile, name) + assert isinstance(operators, tuple) + assert operators + expected = tuple(pauli.copy() for pauli in operators) + operators[0].__imul__(SparsePauli("X_9")) + assert getattr(profile, name) == expected + + +def test_logical_axes_follow_qodec_order_and_can_have_any_physical_factors() -> None: + profile = ec.CodeProfile(qc.Code("pair", [], ["Z_5", "Y_9"], ["X_5", "X_9"])) + assert profile.x == (SparsePauli("Z_5"), SparsePauli("Y_9")) + assert profile.z == (SparsePauli("X_5"), SparsePauli("X_9")) + assert len(profile.x) == len(profile.z) == profile.logical_qubit_count == 2 + for index in range(profile.logical_qubit_count): + assert profile.representative_of(SparsePauli({index: "X"})) == profile.x[index] + assert profile.representative_of(SparsePauli({index: "Z"})) == profile.z[index] + assert profile.gauge_x == profile.gauge_z == () + + +def test_gauge_axes_preserve_pairs_and_commute_with_logical_operators() -> None: + profile = ec.CodeProfile( + qc.Code("gauge", ["Z_1 Z_3 Z_5 Z_7"], ["X_1 X_3"], ["Z_1"]) + ) + gauge_x, gauge_z = profile.gauge_x, profile.gauge_z + assert len(gauge_x) == len(gauge_z) == 2 + for index, x in enumerate(gauge_x): + for other_index, z in enumerate(gauge_z): + assert x.commutes_with(z) is (index != other_index) + assert all(x.commutes_with(other) for other in gauge_x) + for z in gauge_z: + assert all(z.commutes_with(other) for other in gauge_z) + for gauge in gauge_x + gauge_z: + assert set(gauge.support) <= profile.support + assert all( + gauge.commutes_with(operator) + for operator in profile.stabilizers + profile.x + profile.z + ) + + +def test_profile_without_logical_qubits_exposes_empty_logical_axes() -> None: + profile = ec.CodeProfile(qc.Code("gauge", ["Z_0 Z_1"], [], [])) + assert profile.x == profile.z == () + assert profile.logical_qubit_count == 0 + assert len(profile.gauge_x) == len(profile.gauge_z) == 1 + + +def test_code_profile_preserves_physical_labels_and_generator_order() -> None: + profile = ec.CodeProfile( + qc.Code("sparse", ["Z_5 Z_9", "Z_1 Z_5"], ["X_1 X_5 X_9"], ["Z_1"]) + ) + assert profile.support == frozenset({1, 5, 9}) + assert profile.length == 3 + assert profile.logical_qubit_count == 1 + assert profile.stabilizers == (SparsePauli("Z_5 Z_9"), SparsePauli("Z_1 Z_5")) + assert profile.syndrome_of(SparsePauli("X_9")) == frozenset({0}) + assert profile.syndrome_of(SparsePauli("X_1")) == frozenset({1}) + + +@pytest.mark.parametrize( + "error,syndrome,effect,is_logical", + [ + ("I", frozenset(), "I", False), + ("-I", frozenset(), "I", False), + ("Z_0 Z_1", frozenset(), "I", False), + ("X_0", frozenset({0}), "X", False), + ("X_1", frozenset({0}), "I", False), + ("Z_0", frozenset(), "Z", True), + ("X_0 X_1", frozenset(), "X", True), + ("-X_0 X_1", frozenset(), "X", True), + ], +) +def test_logical_errors_require_zero_syndrome_and_a_nonidentity_effect( + error: str, syndrome: frozenset[int], effect: str, is_logical: bool +) -> None: + profile = ec.CodeProfile(repetition_code()) + pauli = SparsePauli(error) + assert profile.syndrome_of(pauli) == syndrome + assert profile.logical_effect_of( + pauli, including_phase=False + ) == SparsePauli(effect) + assert profile.is_logical(pauli) is is_logical + + +@pytest.mark.parametrize("phase", ["", "-", "i", "-i"]) +@pytest.mark.parametrize("logical", ["I", "X", "Y", "Z"]) +def test_logical_representatives_round_trip_with_phase( + phase: str, logical: str +) -> None: + profile = ec.CodeProfile(repetition_code()) + pauli = SparsePauli(phase + logical) + physical = profile.representative_of(pauli) + assert profile.logical_effect_of(physical) == pauli + assert profile.logical_effect_of(physical, including_phase=False) == abs(pauli) + assert profile.logical_effect_of(physical * profile.stabilizers[0]) == pauli + + +def test_generated_stabilizer_signs_are_preserved() -> None: + profile = ec.CodeProfile(qc.Code("bell", ["X_0 X_1", "Z_0 Z_1"], [], [])) + assert profile.logical_effect_of(SparsePauli("Y_0 Y_1")) == SparsePauli("-I") + assert not profile.is_logical(SparsePauli("Y_0 Y_1")) + + +@pytest.mark.parametrize("error,effect", [("-X_0", "X"), ("-X_1", "I")]) +def test_detectable_errors_require_explicit_phase_free_conversion( + error: str, effect: str +) -> None: + profile = ec.CodeProfile(repetition_code()) + with pytest.raises(ValueError, match="no logical action with a scalar phase"): + profile.logical_effect_of(SparsePauli(error)) + assert profile.logical_effect_of( + SparsePauli(error), including_phase=False + ) == SparsePauli(effect) + + +@pytest.mark.parametrize( + "error,effect,is_logical", + [("Z_0", "I", False), ("X_0 X_1", "I", False), ("Z_0 X_2", "X", True)], +) +def test_gauge_components_have_no_unique_scalar_phase( + error: str, effect: str, is_logical: bool +) -> None: + profile = ec.CodeProfile(qc.Code("gauge", ["Z_0 Z_1"], ["X_2"], ["Z_2"])) + pauli = SparsePauli(error) + assert profile.is_logical(pauli) is is_logical + with pytest.raises(ValueError, match="gauge component"): + profile.logical_effect_of(pauli) + assert profile.logical_effect_of( + pauli, including_phase=False + ) == SparsePauli(effect) + + +@pytest.mark.parametrize("method", ["syndrome_of", "logical_effect_of", "is_logical"]) +def test_physical_error_queries_reject_out_of_support_labels(method: str) -> None: + profile = ec.CodeProfile(repetition_code()) + with pytest.raises(ValueError, match="not supported"): + getattr(profile, method)(SparsePauli("X_9")) + with pytest.raises(TypeError, match="expected Pauli"): + getattr(profile, method)("X_0") + + +def test_phase_free_errors_also_validate_physical_support() -> None: + profile = ec.CodeProfile(repetition_code()) + with pytest.raises(ValueError, match="not supported"): + profile.logical_effect_of(SparsePauli("X_9"), including_phase=False) + + +def test_representatives_use_logical_indexes_not_physical_labels() -> None: + profile = ec.CodeProfile(qc.Code("sparse", ["Z_5 Z_9"], ["X_5 X_9"], ["Z_5"])) + assert profile.representative_of(SparsePauli("X_0")) == SparsePauli("X_5 X_9") + for index in (1, 5, 9): + with pytest.raises(ValueError, match="no logical representative"): + profile.representative_of(SparsePauli({index: "X"})) + + +@pytest.mark.parametrize("supported_by", [[0], [0, 0], [0, 1, 1], [0, 2]]) +def test_encoding_requires_each_physical_label_exactly_once( + supported_by: list[int], +) -> None: + profile = ec.CodeProfile(repetition_code()) + with pytest.raises(ValueError, match="every physical support label once"): + profile.encoding_clifford(supported_by=supported_by) + + +def test_code_profile_representatives_and_encoding_match_algebra() -> None: + code = repetition_code() + profile = ec.CodeProfile(code) + algebra = subsystem_code_of(code) + + for pauli in (SparsePauli("X"), SparsePauli("Y"), SparsePauli("Z")): + assert profile.representative_of(pauli) == algebra.representative_of(pauli) + assert profile.encoding_clifford() == algebra.encoding_clifford() + assert profile.encoding_clifford(supported_by=[1, 0]) == algebra.encoding_clifford( + supported_by=[1, 0] + ) + + +def test_code_profile_equality_and_equivalence_are_distinct() -> None: + profile = ec.CodeProfile(repetition_code()) + clone = ec.CodeProfile(repetition_code()) + swapped = ec.CodeProfile(qc.Code("swapped", ["Z_0 Z_1"], ["Z_0"], ["X_0 X_1"])) + + assert profile == clone + assert profile != swapped + assert profile != profile._algebra + assert profile.is_equivalent_to(clone, including_signs=True) + assert not profile.is_equivalent_to(swapped) + assert profile.is_equivalent_to(swapped, strict_basis=False) + assert profile.why_not_equivalent_to(clone) == "" + assert profile.why_not_equivalent_to(swapped) == "Logical bases differ." + assert profile.why_not_equivalent_to(swapped, strict_basis=False) == "" + with pytest.raises(TypeError, match="unhashable"): + hash(profile) + + +@pytest.mark.parametrize("including_signs", [False, True]) +@pytest.mark.parametrize("strict_basis", [False, True]) +def test_equivalence_and_explanation_use_the_same_sign_policy( + including_signs: bool, strict_basis: bool +) -> None: + left = ec.CodeProfile(qc.Code("left", ["X_0 X_1", "Z_0 Z_1"], [], [])) + right = ec.CodeProfile(qc.Code("right", ["X_0 X_1", "Y_0 Y_1"], [], [])) + options = dict(including_signs=including_signs, strict_basis=strict_basis) + assert left.is_equivalent_to(right, **options) is (not including_signs) + assert left.why_not_equivalent_to(right, **options) == ( + "Stabilizer groups differ." if including_signs else "" + ) + assert not left.is_equivalent_to(right) + assert left.why_not_equivalent_to(right) == "Stabilizer groups differ." + + +@pytest.mark.parametrize("including_signs", [False, True]) +@pytest.mark.parametrize("strict_basis", [False, True]) +def test_equivalence_compares_gauge_spaces( + including_signs: bool, strict_basis: bool +) -> None: + left = ec.CodeProfile(qc.Code("left", ["Z_0 Z_1"], ["X_2"], ["Z_2"])) + right = ec.CodeProfile(qc.Code("right", ["Z_0 Z_1"], ["X_0 X_1 X_2"], ["Z_0"])) + options = dict(including_signs=including_signs, strict_basis=strict_basis) + assert not left.is_equivalent_to(right, **options) + assert left.why_not_equivalent_to(right, **options) == "Gauge groups differ." + + +def test_reordered_stabilizers_are_equivalent_but_not_structurally_equal() -> None: + left = ec.CodeProfile( + qc.Code("left", ["Z_0 Z_1", "Z_1 Z_2"], ["X_0 X_1 X_2"], ["Z_0"]) + ) + right = ec.CodeProfile( + qc.Code("right", ["Z_1 Z_2", "Z_0 Z_1"], ["X_0 X_1 X_2"], ["Z_0"]) + ) + assert left != right + assert left.is_equivalent_to(right) + assert left.why_not_equivalent_to(right) == "" + + +def test_code_profile_distance_preserves_search_options() -> None: + profile = ec.CodeProfile(repetition_code()) + + distance = profile.distance(errors="X", solver="enumeration") + assert distance == len(distance.witness.factors) == 2 + bounds = profile.distance_bounds(errors="X", solver="enumeration") + assert bounds == len(bounds.witness.factors) == 2 + bounded_profile = ec.CodeProfile( + as_qodec_code(make_five_qubit_code(), "five_qubit") + ) + with pytest.raises(RuntimeError, match="exact distance"): + bounded_profile.distance(upper_bound=2, solver="enumeration") + bounds = bounded_profile.distance_bounds(upper_bound=2, solver="enumeration") + assert bounds.lower_bound == 3 and bounds.upper_bound is None + assert list(bounds.witnesses) == [] + assert ( + profile.distance( + errors="X", logical_observable=SparsePauli("Z"), solver="enumeration" + ) + == 2 + ) + + +def test_code_distance_of_accepts_qodec_code() -> None: + distance, witness = code_distance_of(repetition_code(), errors="X") + assert distance == 2 + assert len(witness) == 2 + + +def test_distance_exposes_a_product_and_alternative_witnesses() -> None: + profile = ec.CodeProfile(repetition_code()) + distance = profile.distance() + + assert isinstance(distance, ec.Distance) + assert distance == 1 + assert distance.value == distance.lower_bound == distance.upper_bound == 1 + assert profile.is_logical(distance.witness.product) + witnesses = list(distance.witnesses) + assert witnesses[0] == distance.witness + assert {witness.product for witness in witnesses} == { + SparsePauli("Z_0"), + SparsePauli("Z_1"), + } + assert list(distance.witnesses) == witnesses + + +def test_distance_represents_impossible_failure_without_a_sentinel() -> None: + distance = ec.CodeProfile(repetition_code()).distance(errors=[]) + + assert distance.is_exact + assert distance.lower_bound is distance.upper_bound is distance.value is None + assert distance > 1000 + assert str(distance) == "\u221e" + assert list(distance.witnesses) == [] + with pytest.raises(LookupError): + _ = distance.witness diff --git a/source/qdk_package/tests/ec_tests/profile/test_distance.py b/source/qdk_package/tests/ec_tests/profile/test_distance.py new file mode 100644 index 00000000000..0eca39888c4 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/profile/test_distance.py @@ -0,0 +1,432 @@ +"""Contracts for bounded distances and generic failure witnesses.""" + +from dataclasses import FrozenInstanceError +from itertools import product +import operator + +import pytest + +import qodec as qc + +from qdk.ec import CodeProfile, Distance, FaultEvent, GadgetProfile, Pauli +from qdk.ec._analysis.distance_solvers import CustomBoundsSolver +from qdk.ec._analysis.odd_cycles import OddCycles +from qdk.ec._distance import ( + _copy_fault, + _fault_product, + _pauli_product, + distance_result_of, +) + + +def result(lower: int | None, upper: int | None) -> Distance[str]: + witness = ( + None + if upper is None + else Distance.Witness._create(("a",) * upper, product="".join) + ) + return Distance._create(lower, upper, witness=witness) + + +def test_surface_and_opaque_construction() -> None: + assert {name for name in dir(Distance) if not name.startswith("_")} == { + "lower_bound", + "upper_bound", + "value", + "is_exact", + "witness", + "witnesses", + "Witness", + } + assert {name for name in dir(Distance.Witness) if not name.startswith("_")} == { + "factors", + "product", + } + with pytest.raises(TypeError, match="profile"): + Distance() + with pytest.raises(TypeError, match="distance results"): + Distance.Witness() + with pytest.raises(TypeError): + bool(result(2, 2)) + with pytest.raises(TypeError): + hash(result(2, 2)) + with pytest.raises(TypeError): + iter(result(2, 2)) + with pytest.raises(TypeError): + len(result(2, 2).witness) + with pytest.raises(TypeError): + iter(result(2, 2).witness) + with pytest.raises((AttributeError, FrozenInstanceError, TypeError)): + result(2, 2).lower_bound = 3 + + +@pytest.mark.parametrize( + "lower,upper,text", + [(2, 2, "2"), (2, 4, "[2, 4]"), (3, None, "[3, \u221e]"), (None, None, "\u221e")], +) +def test_display_and_exact_value(lower, upper, text) -> None: + distance = result(lower, upper) + assert str(distance) == text + assert f"{distance:>14}" == text.rjust(14) + assert repr(distance).startswith( + f"Distance(lower_bound={lower!r}, upper_bound={upper!r}, witness=" + ) + assert distance.is_exact == (lower == upper) + if distance.is_exact: + assert distance.value == lower + else: + with pytest.raises(ValueError, match="not exact"): + _ = distance.value + if upper is None: + with pytest.raises(LookupError): + _ = distance.witness + assert list(distance.witnesses) == [] + with pytest.raises(ValueError): + format(distance, "d") + + +@pytest.mark.parametrize( + "operation", + [operator.eq, operator.ne, operator.lt, operator.le, operator.gt, operator.ge], +) +@pytest.mark.parametrize("left_bounds", [(2, 2), (2, 4), (3, None), (None, None)]) +@pytest.mark.parametrize("right_bounds", [(0, 0), (2, 2), (2, 4), (5, 5), (None, None)]) +def test_comparisons_require_a_proven_answer( + operation, left_bounds, right_bounds +) -> None: + def possible_values(bounds): + lower, upper = bounds + if lower is None: + return [float("inf")] + return list(range(lower, (upper if upper is not None else 6) + 1)) + ( + [float("inf")] if upper is None else [] + ) + + answers = { + operation(left, right) + for left, right in product( + possible_values(left_bounds), possible_values(right_bounds) + ) + } + left = result(*left_bounds) + right = result(*right_bounds) + if len(answers) == 1: + assert operation(left, right) == answers.pop() + else: + with pytest.raises(ValueError, match="unresolved"): + operation(left, right) + + +def test_reflected_comparisons_and_unsupported_operands() -> None: + distance = result(3, 5) + assert distance > 2 and 2 < distance + assert distance <= 5 and 5 >= distance + assert distance != 2 and 2 != distance + with pytest.raises(ValueError, match="unresolved"): + _ = 4 < distance + assert result(2, 2) == 2 == result(2, 2) + assert result(None, None) > 10**1000 + assert Distance.__eq__(distance, "3") is NotImplemented + assert Distance.__lt__(distance, 3.0) is NotImplemented + assert distance != "3" + with pytest.raises(TypeError): + _ = distance < "3" + + +def test_generic_witness_equality_hash_and_snapshot() -> None: + witness = Distance.Witness._create(("a", "b"), product="".join) + same = Distance.Witness._create(("a", "b"), product="".join) + combined = Distance.Witness._create(("ab",), product="".join) + assert witness.product == combined.product == "ab" + assert witness == same and hash(witness) == hash(same) + assert witness != combined + assert witness != object() + assert str(witness) == "a; b" + assert repr(witness) == "Distance.Witness(factors=('a', 'b'))" + factors = ([1], [2]) + mutable = Distance.Witness._create(factors, product=lambda items: sum(items, [])) + factors[0].append(3) + mutable.factors[0].append(4) + mutable.product.append(5) + assert mutable.factors == ([1], [2]) + assert mutable.product == [1, 2] + + +@pytest.mark.parametrize( + "factors, expected", [((), "1"), (("a",), "a"), (("a", "b"), "a; b")] +) +def test_witness_text_lists_only_factors( + factors: tuple[str, ...], expected: str +) -> None: + witness = Distance.Witness._create(factors, product="".join) + assert str(witness) == expected + assert f"{witness}" == expected + + +def test_fault_witness_text_preserves_factor_grouping() -> None: + quantum = FaultEvent.after(2, Pauli("X_0")) + readout = FaultEvent.after(7, readout_flips=0) + separate = Distance.Witness._create( + (quantum, readout), product=_fault_product, copy=_copy_fault + ) + combined = Distance.Witness._create( + (quantum * readout,), product=_fault_product, copy=_copy_fault + ) + + assert separate.product == combined.product + assert str(separate) == "X_0 after call 2; flip call 7 readout 0" + assert str(combined) == "(X_0 after call 2; flip call 7 readout 0)" + + +def test_lazy_fresh_iterators_do_not_search_during_protocols() -> None: + first = Distance.Witness._create(("a",), product="".join) + calls = [] + + def alternatives(): + calls.append("search") + yield Distance.Witness._create(("b",), product="".join) + raise RuntimeError("enumeration interrupted") + + distance = Distance._create(1, 1, witness=first, alternatives=alternatives) + iterator = distance.witnesses + assert distance == 1 + assert str(distance) == "1" + assert f"{distance.witness}" == "a" + assert "Distance.Witness" in repr(distance) + assert distance.witness is first + assert calls == [] + assert next(iterator) is first + assert calls == [] + assert next(iterator).product == "b" + assert calls == ["search"] + with pytest.raises(RuntimeError, match="interrupted"): + next(iterator) + assert next(distance.witnesses) is first + + +def test_alternatives_use_original_columns_and_keep_fixed_bounds() -> None: + problem = OddCycles([frozenset()] * 3, [frozenset({0})] * 3) + factors = ["a", "b", "c"] + distance = distance_result_of( + problem, factors, solver="enumeration", exact=True, product="".join + ) + factors.clear() + assert [witness.product for witness in distance.witnesses] == ["a", "b", "c"] + open_problem = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + bounded = distance_result_of( + open_problem, + ("a", "b", "c"), + solver=CustomBoundsSolver(lambda *_: (2, 3, [0, 1, 2])), + product="".join, + ) + assert [witness.product for witness in bounded.witnesses] == ["abc"] + assert (bounded.lower_bound, bounded.upper_bound) == (2, 3) + + +def test_fault_witness_keeps_readout_cancellation_and_phase() -> None: + factors = ( + FaultEvent.after(3, Pauli("X"), readout_flips=0), + FaultEvent.after(3, Pauli("Z"), readout_flips=[0, 1]), + ) + witness = Distance.Witness._create( + factors, product=_fault_product, copy=_copy_fault + ) + assert witness.product == factors[0] * factors[1] + (location,) = witness.product.locations + assert location.after_call == 3 + assert location.readout_flips == frozenset({1}) + assert location.error.phase == (Pauli("X") * Pauli("Z")).phase + assert hash(witness) == hash( + Distance.Witness._create(factors, product=_fault_product, copy=_copy_fault) + ) + + +def test_pauli_witness_preserves_phase_order_and_snapshot() -> None: + factors = (Pauli("X"), Pauli("Z")) + witness = Distance.Witness._create(factors, product=_pauli_product, copy=Pauli.copy) + reversed_witness = Distance.Witness._create( + tuple(reversed(factors)), product=_pauli_product, copy=Pauli.copy + ) + expected = Pauli("X") * Pauli("Z") + assert witness.product == expected + assert witness.product.phase != reversed_witness.product.phase + assert witness != reversed_witness + factors[0].__imul__(Pauli("X")) + witness.factors[0].__imul__(Pauli("X")) + witness.product.__imul__(Pauli("Z")) + assert witness.product == expected + assert witness.factors == (Pauli("X"), Pauli("Z")) + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_alternative_errors_flip_the_requested_logical_observable( + method: str, +) -> None: + profile = CodeProfile(qc.Code("pair", [], ["X_0", "X_1"], ["Z_0", "Z_1"])) + errors = [Pauli("X_0"), Pauli("X_1"), Pauli("Y_0"), Pauli("Y_1")] + distance = getattr(profile, method)( + errors=errors, logical_observable=Pauli("Z_1") + ) + assert distance == 1 + assert {witness.product for witness in distance.witnesses} == { + Pauli("X_1"), + Pauli("Y_1"), + } + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +@pytest.mark.parametrize("observable", ["X", "Y", "Z", "-X", "-Y", "-Z"]) +def test_observable_filter_uses_logical_indexes_and_pauli_commutation( + method: str, observable: str +) -> None: + profile = CodeProfile(qc.Code("sparse", [], ["Z_5"], ["X_5"])) + allowed = [Pauli({5: char}) for char in "XYZ"] + target = Pauli(observable) + distance = getattr(profile, method)(errors=allowed, logical_observable=target) + witnesses = {witness.product for witness in distance.witnesses} + assert distance == 1 + assert witnesses == { + error + for error in allowed + if not profile.logical_effect_of(error).commutes_with(target) + } + assert len(witnesses) == 2 + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +@pytest.mark.parametrize("observable", ["I", "-I", "iX", "X_1"]) +def test_invalid_logical_observables_raise_even_without_allowed_errors( + method: str, observable: str +) -> None: + profile = CodeProfile(qc.Code("one", [], ["X_5"], ["Z_5"])) + with pytest.raises(ValueError): + getattr(profile, method)(errors=[], logical_observable=Pauli(observable)) + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_distance_rejects_out_of_support_errors(method: str) -> None: + profile = CodeProfile(qc.Code("one", [], ["X_5"], ["Z_5"])) + with pytest.raises(ValueError, match="not supported"): + getattr(profile, method)(errors=[Pauli("X_9")]) + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_no_allowed_error_flips_the_observable_means_infinite_distance( + method: str, +) -> None: + profile = CodeProfile(qc.Code("one", [], ["X_5"], ["Z_5"])) + result = getattr(profile, method)( + errors=[Pauli("Z_5")], logical_observable=Pauli("Z") + ) + assert result.is_exact + assert result.value is None + assert list(result.witnesses) == [] + + +@pytest.mark.parametrize( + "left,right", + [ + (left, right) + for left, right in product("IXYZ", repeat=2) + if (left, right) != ("I", "I") + ], +) +def test_old_parity_filters_translate_to_logical_observables( + left: str, right: str +) -> None: + from qdk.ec._distance import CodeDistanceData + + profile = CodeProfile(qc.Code("pair", [], ["Z_5", "X_9"], ["X_5", "Z_9"])) + old_representative = Pauli({5: left, 9: right}) + errors = [Pauli({5: x, 9: y}) for x, y in product("IXYZ", repeat=2)] + old_problem = CodeDistanceData.of(profile, errors) + old_parity = old_problem.parity_indicator(old_representative) + old_selections = old_problem.odd_cycles.witnesses(1, old_parity) + expected = {errors[selection[0]] for selection in old_selections} + + effect = profile.logical_effect_of(old_representative, including_phase=False) + swap_axes = {"X": "Z", "Y": "Y", "Z": "X"} + observable = Pauli({index: swap_axes[effect[index]] for index in effect.support}) + distance = profile.distance(errors=errors, logical_observable=observable) + assert distance == 1 + assert {witness.product for witness in distance.witnesses} == expected + + +def test_gadget_alternatives_replay_without_simulation_during_iteration( + monkeypatch, +) -> None: + qubit = qc.instructions.BlockOperand("qubit") + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[qc.Instruction("idle", inputs=[qubit], outputs=[qubit])], + ) + circuit = qc.gadgets.Circuit(physical, "- idle: [0]", format="yaml") + profile = GadgetProfile(circuit) + faults = [FaultEvent.after(0, Pauli(basis)) for basis in ("X", "Y", "Z")] + distance = profile.distance(faults=faults) + expected = tuple(faults) + faults.clear() + circuit.source = "- idle: [1]" + + def unexpected_simulation(*args, **kwargs): + raise AssertionError("witness iteration must not rerun simulation") + + with monkeypatch.context() as context: + context.setattr("qdk.ec._profile.propagate_faults", unexpected_simulation) + iterator = distance.witnesses + assert next(iterator) == distance.witness + witnesses = list(distance.witnesses) + assert len(witnesses) == 3 + assert {witness.product for witness in witnesses} == set(expected) + assert list(distance.witnesses) == witnesses + effects = profile.effects_of([witness.product for witness in witnesses]) + assert all(effects) + assert all( + reference.path in {"out[0].x[0]", "out[0].z[0]"} + for effect in effects + for reference in effect + ) + + +@pytest.mark.parametrize( + "lower,upper", [(-1, None), (3, 2), (None, 2), (True, None), (0.5, None)] +) +def test_invalid_bounds_are_rejected(lower, upper) -> None: + with pytest.raises(ValueError): + Distance._create(lower, upper) + + +def test_witness_choices_do_not_change_numeric_equality() -> None: + first = Distance.Witness._create(("a",), product="".join) + second = Distance.Witness._create(("b",), product="".join) + assert first != second + assert Distance._create(1, 1, witness=first) == Distance._create( + 1, 1, witness=second + ) + + +def test_gadget_profile_snapshots_shared_definitions() -> None: + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction("idle", inputs=[operand], outputs=[operand]) + physical = qc.InstructionSet( + "physical", blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[instruction], + ) + code = qc.Code("qubit", [], ["X_0"], ["Z_0"]) + encoding = qc.gadgets.Encoding(code, support=["0"]) + gadget = qc.Gadget( + instruction, qc.gadgets.Circuit(physical, "- idle: [0]", format="yaml"), + inputs=[encoding], outputs=[encoding], + ) + profile = GadgetProfile(gadget) + instruction.flags.append("reject") + encoding.support[0] = "1" + code.x[0] = "Z_0" + physical.instructions.clear() + + assert profile.distance() == 1 + assert profile.action.is_equivalent_to(profile.objective) diff --git a/source/qdk_package/tests/ec_tests/profile/test_readouts.py b/source/qdk_package/tests/ec_tests/profile/test_readouts.py new file mode 100644 index 00000000000..9c2df4a2976 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/profile/test_readouts.py @@ -0,0 +1,47 @@ +"""What a gadget's measurement outcomes mean.""" + +from __future__ import annotations + +import qodec as qc + +from qdk.ec import _checks as checks_module +from qdk.ec._analysis import check_discovery +from qdk.ec._analysis.essential_checks import ( + outcomes_flipped_by_anti_observables_of, +) +from ec_tests.testing.optional import requires_stim + + +@requires_stim +def test_profile_of_discovers_the_readout_bindings( + measure_zz_gadget: qc.Gadget, +) -> None: + profile = check_discovery.profile_of(measure_zz_gadget) + + assert profile.readouts, "measure_zz binds at least one readout" + assert all( + isinstance(name, str) and all(isinstance(index, int) for index in outcomes) + for name, outcomes in profile.readouts.items() + ) + + +def test_checks_and_readouts_share_one_discovery_pass() -> None: + """Both views come from the same simulation, so they cannot disagree.""" + assert check_discovery.profile_of is checks_module.profile_of + + +@requires_stim +def test_anti_observable_flips_are_reported_per_outcome( + measure_zz_gadget: qc.Gadget, +) -> None: + flipped = outcomes_flipped_by_anti_observables_of(measure_zz_gadget) + + assert all(isinstance(entry, frozenset) for entry in flipped) + assert any( + entry for entry in flipped + ), "measuring ZZ must be flipped by some anti-observable" + + +@requires_stim +def test_idle_gadget_has_no_readouts(idle_gadget: qc.Gadget) -> None: + assert check_discovery.profile_of(idle_gadget).readouts == {} diff --git a/source/qdk_package/tests/ec_tests/qodecs/__init__.py b/source/qdk_package/tests/ec_tests/qodecs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/qodecs/test_load_code.py b/source/qdk_package/tests/ec_tests/qodecs/test_load_code.py new file mode 100644 index 00000000000..1d892200b62 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/qodecs/test_load_code.py @@ -0,0 +1,67 @@ +"""Tests for the algebraic profile view of qodec code artifacts.""" + +import pytest +from paulimer import PauliGroup + +from ec_tests.testing import code_catalog +from ec_tests.testing.qodecs import c4 +from qdk.ec._analysis.propagation.pauli import Pauli +from qdk.ec import CodeProfile +from qdk.ec._analysis.code_algebra import SubsystemCode + +qc = pytest.importorskip("qodec") + + +def test_sparse_pauli_parses_qodec_format() -> None: + result = Pauli("X_0 Z_1 Y_2") + assert result == Pauli({0: "X", 1: "Z", 2: "Y"}) + + +def test_sparse_pauli_parses_single_qubit() -> None: + result = Pauli("X_0") + assert result == Pauli({0: "X"}) + + +def test_load_c4_matches_iceberg() -> None: + bundle = c4() + loaded = CodeProfile(bundle.codes["C4"]) + expected = code_catalog.make_422_code() + + assert loaded.logical_qubit_count == expected.logical_qubit_count + assert loaded.length == expected.length + assert set(loaded.support) == set(expected.support) + _assert_same_stabilizer_group(loaded, expected) + _assert_logicals_are_well_formed(loaded, expected) + + +def _assert_same_stabilizer_group(actual: CodeProfile, expected: SubsystemCode) -> None: + actual_group = PauliGroup(actual.stabilizers) + expected_group = expected.stabilizer + for generator in expected_group.generators: + assert ( + generator in actual_group + ), f"expected stabilizer {generator} not in loaded code" + for generator in actual_group.generators: + assert ( + generator in expected_group + ), f"loaded stabilizer {generator} not in expected code" + + +def _assert_logicals_are_well_formed( + actual: CodeProfile, expected: SubsystemCode +) -> None: + """The loaded logical basis need not match the expected basis bit-for-bit + (different valid bases describe the same code), but every loaded logical + must commute with every expected stabilizer and act non-trivially as a + logical operator on the expected code. + """ + expected_stabilizers = expected.stabilizers + for generator in actual.x + actual.z: + for stabilizer in expected_stabilizers: + assert generator.commutes_with(stabilizer), ( + f"loaded logical {generator} does not commute with " + f"expected stabilizer {stabilizer}" + ) + assert expected.is_non_trivial_logical_error( + generator + ), f"loaded logical {generator} is trivial in the expected code" diff --git a/source/qdk_package/tests/ec_tests/strategies/__init__.py b/source/qdk_package/tests/ec_tests/strategies/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/strategies/iterables.py b/source/qdk_package/tests/ec_tests/strategies/iterables.py new file mode 100644 index 00000000000..d31b8a61960 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/strategies/iterables.py @@ -0,0 +1,19 @@ +from typing import Any, Iterable, Callable +from hypothesis import strategies +from more_itertools import split_into + + +@strategies.composite +def partitions( + draw: Callable[..., Any], + iterables: strategies.SearchStrategy[Iterable[Any]], +) -> Iterable[Iterable[Any]]: + elements = list(draw(iterables)) + bin_count = draw(strategies.integers(min_value=1, max_value=max(1, len(elements)))) + bin_lengths: list[int] = [] + for bin_index in range(bin_count - 1): + max_length = len(elements) - sum(bin_lengths) - (bin_count - bin_index) + 1 + length = draw(strategies.integers(min_value=1, max_value=max_length)) + bin_lengths.append(length) + bin_lengths.append(len(elements) - sum(bin_lengths)) + return split_into(elements, bin_lengths) diff --git a/source/qdk_package/tests/ec_tests/strategies/sparse_paulis.py b/source/qdk_package/tests/ec_tests/strategies/sparse_paulis.py new file mode 100644 index 00000000000..0ace1bab2e4 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/strategies/sparse_paulis.py @@ -0,0 +1,94 @@ +from typing import Any, Optional, Callable +from hypothesis import strategies +from ec_tests.strategies.sparse_phases import sparse_phases +from qdk.ec._analysis.propagation.pauli import Pauli, identity + + +def pauli_characters() -> strategies.SearchStrategy[str]: + return strategies.sampled_from("IXYZ") + + +@strategies.composite +def pauli_strings( + draw_from: Callable[..., Any], + size: Optional[int] = None, + min_weight: int = 0, + max_weight: int = 100, +) -> str: + if size is None: + size = draw_from(strategies.integers(min_value=min_weight, max_value=100)) + if size < min_weight: + raise ValueError(f"Size {size} is less than minimum weight {min_weight}.") + if size == 0: + return "" + max_weight = min(size, max_weight) + weight = draw_from(strategies.integers(min_value=min_weight, max_value=max_weight)) + support = draw_from( + strategies.lists( + strategies.integers(min_value=0, max_value=size - 1), + min_size=weight, + max_size=weight, + unique=True, + ) + ) + support_string = draw_from(strategies.text("XYZ", min_size=weight, max_size=weight)) + characters = ["I"] * size + for index, character in zip(support, support_string): + characters[index] = character + return "".join(characters) + + +@strategies.composite +def sparse_pauli_elements( # pylint: disable=too-many-arguments, too-many-positional-arguments + draw_from: Callable[..., Any], + size: Optional[int] = None, + min_weight: int = 0, + max_weight: int = 100, + phase_strategy: strategies.SearchStrategy[complex] = sparse_phases(), + qubit_strategy: strategies.SearchStrategy[int] = strategies.integers( + min_value=0, max_value=1000 + ), +) -> Pauli: + character_string = draw_from( + pauli_strings(size=size, min_weight=min_weight, max_weight=max_weight) + ) + qubits = draw_from( + strategies.lists( + qubit_strategy, + min_size=len(character_string), + max_size=len(character_string), + unique=True, + ) + ) + characters = dict(zip(qubits, character_string)) + phase = draw_from(phase_strategy) + return Pauli(characters) * identity(phase) + + +@strategies.composite +def equal_length_sparse_pauli_elements( + draw_from: Callable[..., Any], + count: int = 2, + max_length: int = 100, + phase_strategy: strategies.SearchStrategy[complex] = sparse_phases(), +) -> tuple[Pauli, ...]: + size = draw_from(strategies.integers(min_value=0, max_value=max_length)) + element_stategy = sparse_pauli_elements(size=size, phase_strategy=phase_strategy) + elements = draw_from( + strategies.lists(element_stategy, min_size=count, max_size=count) + ) + return tuple(elements) + + +@strategies.composite +def distinct_length_sparse_pauli_elements( + draw_from: Callable[..., Any], +) -> tuple[Pauli, Pauli]: + size_strategy = strategies.tuples( + strategies.integers(min_value=0, max_value=100), + strategies.integers(min_value=0, max_value=100), + ).filter(lambda sizes: sizes[0] != sizes[1]) + left_size, right_size = draw_from(size_strategy) + left = draw_from(sparse_pauli_elements(size=left_size)) + right = draw_from(sparse_pauli_elements(size=right_size)) + return (left, right) diff --git a/source/qdk_package/tests/ec_tests/strategies/sparse_phases.py b/source/qdk_package/tests/ec_tests/strategies/sparse_phases.py new file mode 100644 index 00000000000..fb11cc947e8 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/strategies/sparse_phases.py @@ -0,0 +1,27 @@ +""" +Hypothesis strategies for Pauli phases. + +The historical ``Phase`` class with conditional phases has been removed from the +public API. ``sparse_phases`` now yields the four allowed unit-magnitude complex +phases. The ``min_conditions``/``max_conditions`` parameters are accepted for +backward compatibility with older test signatures and are ignored. +""" + +from typing import Optional +from hypothesis import strategies + + +def sparse_phases( + min_conditions: int = 0, # pylint: disable=unused-argument + max_conditions: Optional[int] = 10, # pylint: disable=unused-argument +) -> strategies.SearchStrategy[complex]: + return strategies.sampled_from([1 + 0j, -1 + 0j, 1j, -1j]) + + +def compatible_sparse_phases( + min_size: int = 2, + max_size: Optional[int] = None, + min_conditions: int = 0, # pylint: disable=unused-argument + max_conditions: Optional[int] = 10, # pylint: disable=unused-argument +) -> strategies.SearchStrategy[list[complex]]: + return strategies.lists(sparse_phases(), min_size=min_size, max_size=max_size) diff --git a/source/qdk_package/tests/ec_tests/test_api_surface.py b/source/qdk_package/tests/ec_tests/test_api_surface.py new file mode 100644 index 00000000000..90f51bcbd62 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/test_api_surface.py @@ -0,0 +1,634 @@ +"""The deliberately small, flat public surface of ``qdk.ec``.""" + +from __future__ import annotations + +import importlib.util +import inspect +import sys + +import pytest + +import qdk.ec as ec +from ec_tests.testing.optional import requires_stim + +_SURFACE = { + "ChannelAction", + "CodeProfile", + "Diagnostic", + "Distance", + "FaultEffect", + "FaultEvent", + "GadgetProfile", + "Pauli", + "Report", + "audit", + "build_qodec", + "filled", +} + +_RETIRED_MODULES = ( + "qdk.ec.action", + "qdk.ec.checks", + "qdk.ec.code", + "qdk.ec.distance", + "qdk.ec.equivalence", + "qdk.ec.faults", + "qdk.ec.readouts", + "qdk.ec.lint", +) + + +def test_api_surface_is_exact() -> None: + assert set(ec.__all__) == _SURFACE + assert set(dir(ec)) == _SURFACE + assert all(getattr(ec, name) is not None for name in _SURFACE) + + +def test_old_names_are_not_exported() -> None: + assert not { + "action", + "checks", + "code", + "derive", + "distance", + "equivalence", + "faults", + "lint", + "readouts", + "complete_gadget", + "complete_qodec", + "qodec_from_code", + "SubsystemCode", + } & set(ec.__all__) + with pytest.raises(AttributeError, match="SubsystemCode"): + getattr(ec, "SubsystemCode") + with pytest.raises(AttributeError, match="derive"): + getattr(ec, "derive") + + +@pytest.mark.parametrize("module_name", _RETIRED_MODULES) +def test_retired_module_is_not_importable(module_name: str) -> None: + importlib.invalidate_caches() + sys.modules.pop(module_name, None) + + assert importlib.util.find_spec(module_name) is None + with pytest.raises(ModuleNotFoundError, match=module_name): + importlib.import_module(module_name) + + +def test_function_signatures() -> None: + assert ( + str(inspect.signature(ec.filled)) + == "(target: 'qc.Gadget | qc.Qodec') -> 'qc.Gadget | qc.Qodec'" + ) + assert str(inspect.signature(ec.audit)) == ( + "(qodec: 'qc.Qodec', *, disabled: 'Collection[str]' = (), " + "promote_warnings: 'bool' = False) -> 'Report'" + ) + assert str(inspect.signature(ec.build_qodec)) == ( + "(code: 'qc.Code | CodeProfile', *, name: 'str | None' = None, " + "description: 'str | None' = None, strategy: 'str' = " + "'flagged-css/v1', strict: 'bool' = True) -> 'qc.Qodec'" + ) + + +def test_diagnostic_severity_is_nested() -> None: + diagnostic = ec.Diagnostic( + "rule", ec.Diagnostic.Severity.WARNING, "summary", "artifact" + ) + assert diagnostic.severity is ec.Diagnostic.Severity.WARNING + assert "Severity" not in ec.__all__ + + +def test_fault_event_composition_and_weight() -> None: + x = ec.Pauli({2: "X"}) + z = ec.Pauli({3: "Z"}) + fault = ec.FaultEvent.after(4, x) * ec.FaultEvent.after(6, z) + + assert fault.weight == 2 + assert fault == ec.FaultEvent({4: x, 6: z}) + assert fault * fault == ec.FaultEvent({}) + assert hash(fault) + + +def test_readout_faults_are_immutable_and_compose_by_parity() -> None: + flips = [0, 2] + readout_fault = ec.FaultEvent.after(3, readout_flips=flips) + flips.clear() + assert readout_fault == ec.FaultEvent.after(3, readout_flips=[0, 2]) + assert readout_fault.weight == 2 + assert readout_fault * readout_fault == ec.FaultEvent() + assert hash(readout_fault) == hash(ec.FaultEvent.after(3, readout_flips=[2, 0])) + combined = readout_fault * ec.FaultEvent.after(3, ec.Pauli("X_0")) + assert combined == ec.FaultEvent.after(3, ec.Pauli("X_0"), readout_flips=[0, 2]) + assert combined.weight == 3 + assert combined * ec.FaultEvent.after(3, readout_flips=2) == ec.FaultEvent.after( + 3, ec.Pauli("X_0"), readout_flips=0 + ) + assert {name for name in dir(readout_fault) if not name.startswith("_")} == { + "Location", + "after", + "locations", + "weight", + } + assert ( + inspect.signature(ec.FaultEvent.after).parameters["readout_flips"].kind + is inspect.Parameter.KEYWORD_ONLY + ) + with pytest.raises(TypeError, match="integers"): + ec.FaultEvent.after(3, readout_flips=[True]) + + +def test_fault_event_locations_are_ordered_combined_changes() -> None: + event = ( + ec.FaultEvent.after(7, readout_flips=[2, 0]) + * ec.FaultEvent.after(3, ec.Pauli("X_4")) + * ec.FaultEvent.after(3, readout_flips=1) + ) + locations = event.locations + assert isinstance(locations, tuple) + assert [location.after_call for location in locations] == [3, 7] + assert locations[0].error == ec.Pauli("X_4") + assert locations[0].readout_flips == frozenset({1}) + assert locations[1].error == ec.Pauli.identity() + assert locations[1].readout_flips == frozenset({0, 2}) + assert all(isinstance(location, ec.FaultEvent.Location) for location in locations) + assert locations == event.locations + assert hash(locations) == hash(event.locations) + + +def test_fault_event_mapping_order_does_not_affect_identity() -> None: + first = ec.FaultEvent({7: ec.Pauli("X_0"), 2: ec.Pauli("Z_1")}) + second = ec.FaultEvent({2: ec.Pauli("Z_1"), 7: ec.Pauli("X_0")}) + assert [location.after_call for location in first.locations] == [2, 7] + assert first == second + assert hash(first) == hash(second) + assert str(first) == str(second) + assert repr(first) == repr(second) + assert first.locations is first.locations + + +def test_fault_event_product_merges_locations_preserving_pauli_order() -> None: + left = ec.FaultEvent({9: ec.Pauli("X_0"), 5: ec.Pauli("X_2"), 1: ec.Pauli("Z_3")}) + left *= ec.FaultEvent.after(5, readout_flips=[0, 1]) + right = ec.FaultEvent({8: ec.Pauli("Z_0"), 5: ec.Pauli("Z_2"), 4: ec.Pauli("X_1")}) + right *= ec.FaultEvent.after(5, readout_flips=[1, 2]) + product = left * right + assert [location.after_call for location in product.locations] == [1, 4, 5, 8, 9] + assert product.locations[2].error == ec.Pauli("X_2") * ec.Pauli("Z_2") + assert product.locations[2].readout_flips == frozenset({0, 2}) + assert product != right * left + assert product.locations[0] is left.locations[0] + assert product.locations[1] is right.locations[0] + assert left * ec.FaultEvent() == ec.FaultEvent() * left == left + + +def test_fault_event_locations_omit_canceled_changes() -> None: + event = ec.FaultEvent.after(3, ec.Pauli("X_4"), readout_flips=0) + remaining = event * ec.FaultEvent.after(3, ec.Pauli("X_4")) + assert len(remaining.locations) == 1 + assert remaining.locations[0].error == ec.Pauli.identity() + assert remaining.locations[0].readout_flips == frozenset({0}) + assert (event * event).locations == () + assert ec.FaultEvent().locations == () + assert ec.FaultEvent({3: ec.Pauli.identity()}).locations == () + + +@pytest.mark.parametrize("constructor", ["after", "mapping"]) +def test_fault_event_copies_input_paulis(constructor: str) -> None: + error = ec.Pauli("X_4") + event = ( + ec.FaultEvent.after(3, error) + if constructor == "after" + else ec.FaultEvent({3: error}) + ) + expected = ec.FaultEvent.after(3, ec.Pauli("X_4")) + original_hash = hash(event) + original_locations = event.locations + error *= ec.Pauli("Z_4") + assert event == expected + assert hash(event) == original_hash + assert event.locations == original_locations + + +def test_fault_event_location_errors_are_defensive_copies() -> None: + event = ec.FaultEvent.after(3, ec.Pauli("X_4"), readout_flips=0) + (location,) = event.locations + original_event_hash = hash(event) + original_location_hash = hash(location) + error = location.error + error *= ec.Pauli("Z_4") + assert location.error == ec.Pauli("X_4") + assert event.locations == (location,) + assert hash(event) == original_event_hash + assert hash(location) == original_location_hash + assert event * event == ec.FaultEvent() + + +def test_fault_event_location_contract() -> None: + assert {name for name in dir(ec.FaultEvent) if not name.startswith("_")} == { + "Location", + "after", + "locations", + "weight", + } + assert { + name for name in dir(ec.FaultEvent.Location) if not name.startswith("_") + } == {"after_call", "error", "readout_flips"} + with pytest.raises(TypeError, match="returned by FaultEvent.locations"): + ec.FaultEvent.Location() + (location,) = ec.FaultEvent.after(3, readout_flips=0).locations + for name, value in ( + ("after_call", 4), + ("error", ec.Pauli("X_0")), + ("readout_flips", frozenset()), + ): + with pytest.raises((AttributeError, TypeError)): + setattr(location, name, value) + assert "after_call=3" in repr(location) + + +def test_fault_event_after_accepts_single_or_multiple_local_readouts() -> None: + single = ec.FaultEvent.after(7, readout_flips=0) + assert single == ec.FaultEvent.after(7, readout_flips=[0]) + assert single.weight == 1 + assert single * single == ec.FaultEvent() + assert single != ec.FaultEvent.after(8, readout_flips=0) + combined = ec.FaultEvent.after(7, ec.Pauli("X_0"), readout_flips=[0, 2]) + assert combined == ( + ec.FaultEvent.after(7, ec.Pauli("X_0")) + * single + * ec.FaultEvent.after(7, readout_flips=2) + ) + for invalid in (True, False, [True], [0, False]): + with pytest.raises(TypeError, match="integers"): + ec.FaultEvent.after(7, readout_flips=invalid) + + +def test_fault_event_repr_is_replayable() -> None: + quantum = ec.FaultEvent.after(2, ec.Pauli("X_0")) + readout = ec.FaultEvent.after(7, readout_flips=0) + mixed = ec.FaultEvent.after(7, ec.Pauli("Z_0"), readout_flips=[2, 0]) + assert repr(readout) == "FaultEvent.after(7, readout_flips=0)" + assert repr(mixed) == "FaultEvent.after(7, Pauli('Z'), readout_flips=[0, 2])" + assert ( + repr(readout * quantum) + == "FaultEvent.after(2, Pauli('X')) * FaultEvent.after(7, readout_flips=0)" + ) + for event in (ec.FaultEvent(), quantum, readout, mixed, mixed * quantum): + assert ( + eval(repr(event), {"FaultEvent": ec.FaultEvent, "Pauli": ec.Pauli}) == event + ) + + +@pytest.mark.parametrize( + "event, expected", + [ + (ec.FaultEvent(), "no fault"), + (ec.FaultEvent.after(2, ec.Pauli("X_0")), "X_0 after call 2"), + (ec.FaultEvent.after(2, ec.Pauli("X_0 Z_3")), "X_0 Z_3 after call 2"), + (ec.FaultEvent.after(2, ec.Pauli("-Y_2")), "-Y_2 after call 2"), + (ec.FaultEvent.after(2, readout_flips=0), "flip call 2 readout 0"), + ( + ec.FaultEvent.after(2, readout_flips=[3, 0]), + "flip call 2 readouts 0, 3", + ), + ( + ec.FaultEvent.after(2, ec.Pauli("X_0"), readout_flips=0), + "(X_0 after call 2; flip call 2 readout 0)", + ), + ( + ec.FaultEvent.after(7, readout_flips=0) + * ec.FaultEvent.after(2, ec.Pauli("X_0")), + "(X_0 after call 2; flip call 7 readout 0)", + ), + ( + ec.FaultEvent.after(2, ec.Pauli("X_0")) + * ec.FaultEvent.after(2, ec.Pauli("X_0")), + "no fault", + ), + ], +) +def test_fault_event_str_describes_pauli_errors_and_call_local_readouts( + event: ec.FaultEvent, expected: str +) -> None: + assert str(event) == expected + assert f"{event}" == expected + + +def test_code_profile_contract() -> None: + import qodec as qc + + code = qc.Code("repetition_2", ["Z_0 Z_1"], ["X_0 X_1"], ["Z_0"]) + view = ec.CodeProfile(code) + + assert str(inspect.signature(ec.CodeProfile)) == "(code: 'qc.Code') -> 'None'" + assert {name for name in dir(ec.CodeProfile) if not name.startswith("_")} == { + "stabilizers", + "x", + "z", + "gauge_x", + "gauge_z", + "support", + "length", + "logical_qubit_count", + "syndrome_of", + "logical_effect_of", + "distance", + "distance_bounds", + "encoding_clifford", + "is_logical", + "representative_of", + "is_equivalent_to", + "why_not_equivalent_to", + } + assert isinstance(view.syndrome_of(ec.Pauli.identity()), frozenset) + assert view.logical_effect_of(ec.Pauli.identity()) == ec.Pauli.identity() + assert view.why_not_equivalent_to(view) == "" + for name in ("x", "z", "gauge_x", "gauge_z"): + descriptor = getattr(ec.CodeProfile, name) + assert isinstance(descriptor, property) + assert descriptor.fset is None + + +def test_code_profile_options_are_explicit_and_keyword_only() -> None: + for method in (ec.CodeProfile.distance, ec.CodeProfile.distance_bounds): + parameters = inspect.signature(method).parameters + assert set(parameters) == { + "self", + "errors", + "logical_observable", + "upper_bound", + "solver", + } + assert all( + parameter.kind is inspect.Parameter.KEYWORD_ONLY + for name, parameter in parameters.items() + if name != "self" + ) + parameters = inspect.signature(ec.CodeProfile.logical_effect_of).parameters + assert set(parameters) == {"self", "error", "including_phase"} + assert parameters["including_phase"].default is True + assert parameters["including_phase"].kind is inspect.Parameter.KEYWORD_ONLY + predicate = inspect.signature(ec.CodeProfile.is_equivalent_to) + explanation = inspect.signature(ec.CodeProfile.why_not_equivalent_to) + assert predicate.parameters == explanation.parameters + for name in ("including_signs", "strict_basis"): + assert predicate.parameters[name].default is True + assert predicate.parameters[name].kind is inspect.Parameter.KEYWORD_ONLY + + +def test_fault_effect_display_uses_the_reference_set() -> None: + effect = ec.FaultEffect(["checks[3,1,3]", "out[0].z[1]"]) + assert str(effect) == "['checks[1]', 'checks[3]', 'out[0].z[1]']" + assert repr(effect) == f"FaultEffect({effect})" + assert eval(repr(effect), {"FaultEffect": ec.FaultEffect}) == effect + assert str(ec.FaultEffect()) == "[]" + + +def test_fault_effect_membership_accepts_one_normalized_reference() -> None: + import qodec as qc + + effect = ec.FaultEffect(["checks[1,3]", "out[0].z[1]"]) + assert "checks[1]" in effect + assert qc.Reference("checks[01]") in effect + assert "readouts[0]" not in effect + assert "out[0].code.z[1]" in effect + assert "checks[1:2]" in effect + with pytest.raises(ValueError, match="single target"): + "checks[1,3]" in effect + + +def test_fault_effect_normalization_controls_equality_and_hashing() -> None: + import qodec as qc + + effect = ec.FaultEffect(["checks[3,1,3]", "out[0].z[1]"]) + assert len(effect) == 3 + assert all(isinstance(reference, qc.Reference) for reference in effect) + assert effect == ec.FaultEffect(["out[0].code.z[01]", "checks[1:4:2]"]) + assert ec.FaultEffect(["checks[1,1]"]) == ec.FaultEffect(["checks[1]"]) + assert effect != frozenset(effect) + assert effect != ec.FaultEffect() + assert hash(effect) == hash(ec.FaultEffect(reversed(list(effect)))) + + +def test_fault_effect_xor_cancels_repeated_changes() -> None: + effect = ec.FaultEffect(["checks[1]", "out[0].z[1]"]) + assert effect ^ effect == ec.FaultEffect() + assert effect ^ ec.FaultEffect(["checks[1]"]) == ec.FaultEffect(["out[0].z[1]"]) + assert not ec.FaultEffect() + with pytest.raises(TypeError): + effect ^ frozenset() + + +def test_fault_effect_surface_is_opaque_and_immutable() -> None: + effect = ec.FaultEffect(["checks[1]"]) + assert {name for name in dir(effect) if not name.startswith("_")} == { + "checks", + "readouts", + "frames", + } + with pytest.raises(AttributeError): + effect._references = frozenset() + + +@pytest.mark.parametrize( + "reference", + [ + "in[0].x[0]", + "circuit.readouts[0]", + 'metadata["name"]', + "out[0].X_1", + "out[0].x", + "out[0:2].x[0]", + "checks[-1]", + "checks[0:0]", + "outputs[0].code.z[1]", + "", + "checks", + "checks[1].equation[0]", + ], +) +def test_fault_effect_rejects_invalid_targets(reference: str) -> None: + with pytest.raises(ValueError): + ec.FaultEffect([reference]) + with pytest.raises(ValueError): + reference in ec.FaultEffect() + + +def test_fault_effect_orders_targets_and_copies_input() -> None: + references = [ + "out[10].x[0]", + "out[2].stabilizers[0]", + "checks[10,2]", + "readouts[1]", + "out[2].z[1]", + "out[2].x[3,0]", + ] + effect = ec.FaultEffect(iter(references)) + references.clear() + assert [str(reference) for reference in effect] == [ + "checks[2]", + "checks[10]", + "readouts[1]", + "out[2].x[0]", + "out[2].x[3]", + "out[2].z[1]", + "out[2].stabilizers[0]", + "out[10].x[0]", + ] + assert effect ^ ec.FaultEffect(["checks[2]"]) == ec.FaultEffect(list(effect)[1:]) + for invalid in (1, True, None): + with pytest.raises(TypeError): + ec.FaultEffect([invalid]) + with pytest.raises(TypeError): + invalid in effect + with pytest.raises(TypeError, match="iterable"): + ec.FaultEffect("checks[0]") + + +@pytest.mark.parametrize( + "references", + [ + [], + ["checks[10,2]"], + ["readouts[3,0]"], + ["out[1].x[0]", "out[0].z[2]", "out[0].stabilizers[1]"], + [ + "checks[10,2]", + "readouts[3,0]", + "out[1].x[0]", + "out[0].z[2]", + "out[0].stabilizers[1]", + ], + ], +) +def test_fault_effect_filtered_views_partition_the_effect( + references: list[str], +) -> None: + import qodec as qc + + effect = ec.FaultEffect(references) + original = ec.FaultEffect(references) + for name, root in ( + ("checks", "checks["), + ("readouts", "readouts["), + ("frames", "out["), + ): + view = getattr(effect, name) + expected = tuple( + reference for reference in effect if reference.path.startswith(root) + ) + assert isinstance(view, tuple) + assert all(isinstance(reference, qc.Reference) for reference in view) + assert view == expected + assert bool(view) == bool(expected) + assert hash(view) == hash(expected) + if view: + assert qc.Reference(view[0].path) in view + assert view[0].path not in view + with pytest.raises(AttributeError): + getattr(view, "append")(qc.Reference("checks[0]")) + with pytest.raises((AttributeError, TypeError)): + setattr(effect, name, ()) + assert effect.checks + effect.readouts + effect.frames == tuple(effect) + assert ec.FaultEffect(effect.checks + effect.readouts + effect.frames) == effect + assert effect == original + + +@requires_stim +def test_gadget_profile_contract(idle_gadget) -> None: + profile = ec.GadgetProfile(idle_gadget) + + assert isinstance(profile.action, ec.ChannelAction) + assert isinstance(profile.objective, ec.ChannelAction) + assert all(isinstance(check, frozenset) for check in profile.checks) + assert all(isinstance(readout, frozenset) for readout in profile.readouts) + assert profile.why_not_equivalent_to(profile) == "" + fault, effect = profile.fault_effects[0] + assert isinstance(fault, ec.FaultEvent) + assert isinstance(effect, ec.FaultEffect) + assert profile.effects_of([fault]) == (effect,) + assert {name for name in dir(ec.GadgetProfile) if not name.startswith("_")} == { + "action", + "objective", + "checks", + "readouts", + "fault_effects", + "effects_of", + "distance", + "distance_bounds", + "is_equivalent_to", + "why_not_equivalent_to", + } + + +@requires_stim +def test_gadget_profile_accepts_a_bare_circuit(idle_gadget) -> None: + """A circuit is a gadget with trivial encodings, so nothing is silently empty.""" + profile = ec.GadgetProfile(idle_gadget.circuit) + + assert profile.objective is None + assert isinstance(profile.action, ec.ChannelAction) + assert all(isinstance(readout, frozenset) for readout in profile.readouts) + assert all(isinstance(check, frozenset) for check in profile.checks) + outputs = profile._circuit_outputs + for _, effect in profile.fault_effects: + allowed = ec.FaultEffect( + [ + *(f"checks[{index}]" for index in range(len(profile.checks))), + *(f"readouts[{index}]" for index in range(len(profile.readouts))), + *( + f"out[{index}].{basis}[0]" + for index in range(len(outputs)) + for basis in ("x", "z") + ), + ] + ) + assert set(effect) <= set(allowed) + assert any( + reference.path.startswith(("checks[", "readouts[")) + for _, effect in profile.fault_effects + for reference in effect + ) + + +def test_profile_distance_signatures() -> None: + assert str(inspect.signature(ec.GadgetProfile.distance)) == ( + "(self, *, faults: 'Sequence[FaultEvent] | None' = None, " + "upper_bound: 'int | None' = None, solver: 'ExactSolver | None' = None) " + "-> 'Distance[FaultEvent]'" + ) + assert str(inspect.signature(ec.GadgetProfile.distance_bounds)) == ( + "(self, *, faults: 'Sequence[FaultEvent] | None' = None, " + "upper_bound: 'int | None' = None, solver: 'BoundsSolver | None' = None) " + "-> 'Distance[FaultEvent]'" + ) + + +@requires_stim +def test_channel_action_is_opaque(idle_gadget) -> None: + action = ec.GadgetProfile(idle_gadget).action + assert {name for name in dir(action) if not name.startswith("_")} == { + "is_equivalent_to", + "why_not_equivalent_to", + } + assert not inspect.signature(ec.ChannelAction).parameters + for name in ("observables", "stabilizers", "mapping"): + assert not hasattr(action, name) + with pytest.raises(AttributeError): + setattr(action, name, None) + with pytest.raises(TypeError, match="GadgetProfile"): + ec.ChannelAction() + assert action.is_equivalent_to(action) + assert action.why_not_equivalent_to(action) == "" + + +def test_gadget_profile_rejects_other_targets() -> None: + with pytest.raises(TypeError, match="Gadget or qodec.gadgets.Circuit"): + ec.GadgetProfile(object()) + + +def test_filled_rejects_bare_circuit(idle_gadget) -> None: + with pytest.raises(TypeError, match="Gadget or qodec.Qodec"): + ec.filled(idle_gadget.circuit) diff --git a/source/qdk_package/tests/ec_tests/test_program_operand_handling.py b/source/qdk_package/tests/ec_tests/test_program_operand_handling.py new file mode 100644 index 00000000000..4d1f85a1d29 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/test_program_operand_handling.py @@ -0,0 +1,91 @@ +"""Positional block operands determine the analysis's logical-qubit layout.""" + +from __future__ import annotations + +from collections import UserList +from collections.abc import MutableSequence + +import pytest + +import qodec as qc +from qodec.gadgets import Circuit +from qdk.ec._layout import ProgramLayout +from ec_tests.testing.qodecs import c4 + + +@pytest.fixture +def c4_qodec() -> qc.Qodec: + return c4() + + +@pytest.fixture +def c4_isa(c4_qodec: qc.Qodec) -> qc.InstructionSet: + return c4_qodec.layers[0].instruction_set + + +def test_explicit_operands_are_accepted(c4_isa: qc.InstructionSet) -> None: + program = Circuit( + c4_isa, + "- prepare_zz: [q]\n- idle: {operands: [q]}", + format="yaml", + ) + assert [call.operands for call in program.calls()] == [["q"], ["q"]] + layout = ProgramLayout.of(program) + assert layout.total_qubits == 2 + assert layout.call_qubit_map(program.calls()[1]) == {0: 0, 1: 1} + + +def test_multiqubit_blocks_keep_positional_order(c4_isa: qc.InstructionSet) -> None: + program = Circuit( + c4_isa, + "- idle: [left]\n- transversal_cx: [right, left]", + format="yaml", + ) + layout = ProgramLayout.of(program) + assert layout.total_qubits == 4 + assert layout.call_qubit_map(program.calls()[1]) == {0: 2, 1: 3, 2: 0, 3: 1} + + +def test_unknown_mnemonic_is_rejected(c4_isa: qc.InstructionSet) -> None: + program = Circuit(c4_isa, "- not_an_instruction: [q]", format="yaml") + with pytest.raises(ValueError, match="not_an_instruction"): + ProgramLayout.of(program) + + +def test_variadic_operands_bind_every_block() -> None: + operand = qc.instructions.BlockOperand("pair", is_variadic=True) + instruction_set = qc.InstructionSet( + "pairs", + blocks=[qc.instructions.Block("pair", encodes=2)], + instructions=[qc.Instruction("idle", inputs=[operand], outputs=[operand])], + ) + circuit = Circuit(instruction_set, "- idle: [left, middle, right]", format="yaml") + layout = ProgramLayout.of(circuit) + assert layout.total_qubits == 6 + assert layout.call_qubit_map(circuit.calls()[0]) == { + index: index for index in range(6) + } + + +def test_bound_operands_accept_non_list_mutable_sequences() -> None: + fixed = qc.instructions.BlockOperand("single") + variadic = qc.instructions.BlockOperand("pair", is_variadic=True) + operands: MutableSequence[qc.instructions.BlockOperand] = UserList( + [fixed, variadic] + ) + values: MutableSequence[int | str] = UserList([0, "left", "right"]) + + assert ProgramLayout._bound_operands(operands, values) == [ + (fixed, 0), + (variadic, "left"), + (variadic, "right"), + ] + assert operands == [fixed, variadic] + assert values == [0, "left", "right"] + + +def test_string_operand_is_one_label(c4_isa: qc.InstructionSet) -> None: + circuit = Circuit(c4_isa, "- idle: ['left block']", format="yaml") + layout = ProgramLayout.of(circuit) + assert layout.instance_bases == {"left block": 0} + assert layout.total_qubits == 2 diff --git a/source/qdk_package/tests/ec_tests/test_references.py b/source/qdk_package/tests/ec_tests/test_references.py new file mode 100644 index 00000000000..d82fa8625a1 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/test_references.py @@ -0,0 +1,236 @@ +"""Unit tests for the qodec property-path atom vocabulary. + +qodec parses paths; :mod:`qdk.ec._references` interprets their parity roles. +These cases pin the accepted shapes and preserve every parsed term. +""" + +from __future__ import annotations + +import pytest +from qodec import Reference + +from qdk.ec._references import ( + Basis, + LogicalSign, + Outcome, + ReadoutSign, + Side, + StabilizerSign, + outcome_equation, + outcomes_of, + parse_equation, + parse_equations, + reference_term, + reference_terms, + stabilizer_signs_of, +) + + +def test_parse_equation_reads_each_atom_shape() -> None: + assert parse_equation( + ["circuit.readouts[0]", "in[1].stabilizers[2]", "out[3].z[4]"] + ) == ( + Outcome(0), + StabilizerSign("in", 1, 2), + LogicalSign("out", 3, "z", 4), + ) + + +def test_reference_interpretation_is_separate_from_general_addresses() -> None: + assert tuple(reference_terms(Reference("readouts[2,0,2]"))) == ( + ReadoutSign(2), + ReadoutSign(0), + ReadoutSign(2), + ) + assert tuple(reference_terms("in[0].z[00:01]")) == (LogicalSign("in", 0, "z", 0),) + assert tuple(reference_terms("circuit.readouts[00:01]")) == (Outcome(0),) + for path in [ + 'metadata["readouts"][0]', + 'layers[0].gadgets["M"].in[0].z[0]', + "in[0:1].z[0]", + "in[0].code.z[0]", + ]: + assert Reference(path).segments + with pytest.raises(ValueError, match="not a parity reference"): + reference_terms(path) + + +def test_large_scalar_targets_stop_after_two_terms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import qodec as qc + from qdk.ec._frames import FrameMap + from qdk.ec._analysis.propagation.interpreter import _condition_indices + + created = [] + logical_init = LogicalSign.__init__ + outcome_init = Outcome.__init__ + + def logical( + self: LogicalSign, side: Side, entry: int, basis: Basis, index: int + ) -> None: + created.append(index) + assert len(created) <= 2 + logical_init(self, side, entry, basis, index) + + def outcome(self: Outcome, index: int) -> None: + created.append(index) + assert len(created) <= 2 + outcome_init(self, index) + + monkeypatch.setattr(LogicalSign, "__init__", logical) + monkeypatch.setattr(Outcome, "__init__", outcome) + gadget = qc.Gadget( + qc.Instruction("draft"), + qc.gadgets.Circuit(qc.InstructionSet("draft"), "opaque"), + frames={"out[0].z[0:1048576]": []}, + ) + with pytest.raises(ValueError, match="exactly one"): + FrameMap(gadget) + assert created == [0, 1] + created.clear() + with pytest.raises(ValueError, match="exactly one"): + _condition_indices( + qc.actions.Condition(["bit"]), {"bit": "circuit.readouts[0:1048576]"}, 1 + ) + assert created == [0, 1] + created.clear() + terms = reference_terms("out[0].z[0:1048576]") + assert created == [] + assert next(terms) == LogicalSign("out", 0, "z", 0) + + +def test_singleton_terms_accept_index_and_singleton_slices() -> None: + assert reference_term("out[0].z[0:1]") == LogicalSign("out", 0, "z", 0) + assert reference_term("circuit.readouts[0]") == Outcome(0) + + +@pytest.mark.parametrize( + "path", + ["circuit.readouts[0]", "circuit.readouts[00:01]", "circuit.readouts[0:2:2]"], +) +def test_argument_audit_and_execution_accept_singleton_readout_selectors( + path: str, +) -> None: + import qodec as qc + from qdk.ec._audit._structure import _argument_issue + from qdk.ec._analysis.propagation.interpreter import _condition_indices + + gadget = qc.Gadget( + qc.Instruction("draft"), + qc.gadgets.Circuit(qc.InstructionSet("draft"), "opaque"), + ) + bit = qc.instructions.Parameter("bit", "bit") + text = qc.instructions.Parameter("text", "string") + condition = qc.actions.Condition(["bit"]) + assert _argument_issue(path, bit, gadget, 1) is None + assert _condition_indices(condition, {"bit": path}, 1) == ([0], True) + assert ( + _argument_issue(path, text, gadget, 1) + == "a circuit readout can bind only a bit parameter" + ) + assert "out of bounds" in str(_argument_issue(path, bit, gadget, 0)) + with pytest.raises(ValueError, match="preceding circuit readout"): + _condition_indices(condition, {"bit": path}, 0) + assert _argument_issue("ordinary text", text, gadget, 1) is None + for invalid in [ + "circuit.readouts[0:2]", + "circuit.readouts[0,0]", + "circuit.readouts[0:0]", + 'metadata["bit"]', + ]: + assert _argument_issue(invalid, bit, gadget, 2) is not None + with pytest.raises(ValueError): + _condition_indices(condition, {"bit": invalid}, 2) + + +def test_parse_equation_expands_bracket_selectors() -> None: + assert parse_equation(["circuit.readouts[1:4]"]) == ( + Outcome(1), + Outcome(2), + Outcome(3), + ) + assert parse_equation(["circuit.readouts[0,2,5]"]) == ( + Outcome(0), + Outcome(2), + Outcome(5), + ) + + +def test_parse_equation_preserves_declared_readout_terms() -> None: + equation = parse_equation(["readouts[1,0,1]", "circuit.readouts[2]", 1]) + assert equation == (ReadoutSign(1), ReadoutSign(0), ReadoutSign(1), Outcome(2), 1) + assert outcomes_of(equation) == [2] + + +def test_readout_equation_preserves_dependencies() -> None: + import qodec as qc + from qdk.ec._readouts import readout_equation + + gadget = qc.Gadget( + qc.Instruction("draft", flags=["reject"]), + qc.gadgets.Circuit(qc.InstructionSet("draft"), "opaque", format="unknown"), + readouts=[["readouts[1]", "circuit.readouts[2]", 1]], + ) + assert readout_equation(gadget.readouts[0]) == (ReadoutSign(1), Outcome(2), 1) + + +@pytest.mark.parametrize( + "text", ["checks[2]", "in.block.stabilizers[1]", "metadata", ""] +) +def test_parse_equation_rejects_invalid_paths(text: str) -> None: + with pytest.raises(ValueError): + parse_equation([text]) + + +def test_parse_equation_expands_typed_encoding_references() -> None: + assert parse_equation( + [Reference("in[0].stabilizers[0:2]"), Reference("out[1].x[1,3]")] + ) == ( + StabilizerSign("in", 0, 0), + StabilizerSign("in", 0, 1), + LogicalSign("out", 1, "x", 1), + LogicalSign("out", 1, "x", 3), + ) + + +def test_parse_equations_parses_a_whole_check_list() -> None: + assert parse_equations([["circuit.readouts[0]"], ["out[0].stabilizers[1]"]]) == ( + (Outcome(0),), + (StabilizerSign("out", 0, 1),), + ) + + +def test_atoms_render_back_to_their_reference_text() -> None: + for text in ( + "circuit.readouts[7]", + "in[0].stabilizers[2]", + "out[1].x[3]", + ): + (atom,) = parse_equation([text]) + assert str(atom) == text + + +def test_outcomes_of_selects_only_measurement_records() -> None: + equation = parse_equation( + ["circuit.readouts[0]", "in[0].stabilizers[0]", "circuit.readouts[3]"] + ) + assert outcomes_of(equation) == [0, 3] + + +def test_sign_selectors_filter_by_side() -> None: + equation = parse_equation( + ["in[0].stabilizers[2]", "out[1].stabilizers[0]", "in[0].z[1]"] + ) + assert stabilizer_signs_of(equation, side="in") == [StabilizerSign("in", 0, 2)] + assert stabilizer_signs_of(equation, side="out") == [StabilizerSign("out", 1, 0)] + assert len(stabilizer_signs_of(equation)) == 2 + + +def test_sign_keys_are_side_independent() -> None: + assert StabilizerSign("in", 0, 2).key == StabilizerSign("out", 0, 2).key + assert LogicalSign("in", 1, "x", 0).key == LogicalSign("out", 1, "x", 0).key + + +def test_outcome_equation_builds_a_record_xor() -> None: + assert outcome_equation([2, 5]) == (Outcome(2), Outcome(5)) diff --git a/source/qdk_package/tests/ec_tests/testing/__init__.py b/source/qdk_package/tests/ec_tests/testing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/testing/code_catalog/__init__.py b/source/qdk_package/tests/ec_tests/testing/code_catalog/__init__.py new file mode 100644 index 00000000000..56dbc0180ad --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/code_catalog/__init__.py @@ -0,0 +1,39 @@ +from .stabilizer_code_catalog import ( + make_five_qubit_code, + make_steane_code, + make_shor_code, + make_repetition_code, + make_quantum_reed_muller_code, + make_quantum_punctured_reed_muller_code, + make_quantum_extended_hamming_code, + make_quantum_golay_code, + make_quantum_hamming_code, + make_color_code_832, + make_tesseract_code, + make_carbon_code, +) + +from .subsystem_codes import make_bacon_shor_code + +from .surface_codes import make_rotated_surface_code + +from .iceberg import make_422_code, make_iceberg_code + +__all__ = [ + "make_422_code", + "make_bacon_shor_code", + "make_carbon_code", + "make_color_code_832", + "make_five_qubit_code", + "make_iceberg_code", + "make_quantum_extended_hamming_code", + "make_quantum_golay_code", + "make_quantum_hamming_code", + "make_quantum_punctured_reed_muller_code", + "make_quantum_reed_muller_code", + "make_repetition_code", + "make_rotated_surface_code", + "make_shor_code", + "make_steane_code", + "make_tesseract_code", +] diff --git a/source/qdk_package/tests/ec_tests/testing/code_catalog/iceberg.py b/source/qdk_package/tests/ec_tests/testing/code_catalog/iceberg.py new file mode 100644 index 00000000000..632fb6d02c8 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/code_catalog/iceberg.py @@ -0,0 +1,23 @@ +from more_itertools import interleave +from qdk.ec._analysis.propagation.pauli import Pauli +from qdk.ec._analysis.stabilizer_code import StabilizerCode + + +def make_422_code() -> StabilizerCode: + return make_iceberg_code(4) + + +def make_iceberg_code(length: int) -> StabilizerCode: + if (length % 2 == 1) or length < 1: + raise ValueError(f"Length {length} is not a positive multiple of two.") + + x_berg = 0 + z_berg = length - 1 + generators = [ + Pauli({index: "X" for index in range(length)}), + Pauli({index: "Z" for index in range(length)}), + ] + x_logicals = [Pauli({index: "X", x_berg: "X"}) for index in range(1, length - 1)] + z_logicals = [Pauli({index: "Z", z_berg: "Z"}) for index in range(1, length - 1)] + logicals = list(interleave(x_logicals, z_logicals)) + return StabilizerCode(generators, logical_basis=logicals) diff --git a/source/qdk_package/tests/ec_tests/testing/code_catalog/stabilizer_code_catalog.py b/source/qdk_package/tests/ec_tests/testing/code_catalog/stabilizer_code_catalog.py new file mode 100644 index 00000000000..63b90566517 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/code_catalog/stabilizer_code_catalog.py @@ -0,0 +1,279 @@ +from typing import Iterable +from itertools import combinations +from qdk.ec._analysis.propagation.pauli import Pauli, PauliCharacter +from qdk.ec._analysis.stabilizer_code import StabilizerCode + + +def make_repetition_code( + size: int, +) -> StabilizerCode: + if size <= 1: + raise ValueError("number_of_repetitions must be > 1.") + generators = [Pauli({index: "X", index + 1: "X"}) for index in range(size - 1)] + return StabilizerCode(generators) + + +def make_shor_code() -> StabilizerCode: + return StabilizerCode( + [ + Pauli({0: "Z", 1: "Z"}), + Pauli({1: "Z", 2: "Z"}), + Pauli({3: "Z", 4: "Z"}), + Pauli({4: "Z", 5: "Z"}), + Pauli({6: "Z", 7: "Z"}), + Pauli({7: "Z", 8: "Z"}), + Pauli({0: "X", 1: "X", 2: "X", 3: "X", 4: "X", 5: "X"}), + Pauli({3: "X", 4: "X", 5: "X", 6: "X", 7: "X", 8: "X"}), + ] + ) + + +def make_five_qubit_code() -> StabilizerCode: + return StabilizerCode( + [ + Pauli.from_string("ZXXZI"), + Pauli.from_string("IZXXZ"), + Pauli.from_string("ZIZXX"), + Pauli.from_string("XZIZX"), + ] + ) + + +class BinaryMonomial: + """ + A binary monomial x0^{a0}... x[m-1]^{a[m-1]} with m variables + x0, ..., x[m-1] and ai = 0 or 1. + It is represented by the set of indices i such that ai = 1. + The empty set is interpreted the constant 1. + """ + + def __init__(self, variables: set[int]) -> None: + self.variables = variables + + def evaluate(self, support: set[int]) -> int: + """ + Return the value of the monimial when + xi = 1 if i is in support and xi = 0 otherwise. + """ + if len(self.variables) == 0: + return 1 + for index in self.variables: + if index not in support: + return 0 + return 1 + + +def _evaluation_vector_of( + monomial: BinaryMonomial, number_of_variables: int +) -> list[int]: + """ + Return a list with length 2^m containing the evaluation of + the given monomial for all the vectors of Z2^m. + """ + evaluation_vector = [] + for weight in range(number_of_variables + 1): + for support in combinations(range(number_of_variables), weight): + evaluation_vector.append(monomial.evaluate(set(support))) + return evaluation_vector + + +def _reed_muller_code_generator_matrix( + number_of_variables: int, maximum_degree: int +) -> list[list[int]]: + """ + The rows of the generator matrix of a RM code are the vectors + with length 2^m obtained by evaluating monomials with m variables + with degree <= r in all the points of Z2^m where + m = number_of_variables, + r = maximum_degree. + """ + matrix = [] + for weight in range(maximum_degree + 1): + for monomial_terms in combinations(range(number_of_variables), weight): + monomial = BinaryMonomial(set(monomial_terms)) + matrix.append(_evaluation_vector_of(monomial, number_of_variables)) + return matrix + + +def _generators_from_matrix( + matrix: list[list[int]], generators_type: PauliCharacter +) -> list[Pauli]: + if generators_type in "iI": + raise ValueError("Generators_type must be X, Y or Z.") + generators = [] + for row in matrix: + generators.append( + Pauli( + { + index: generators_type + for index, value in enumerate(row) + if value == 1 + } + ) + ) + return generators + + +def make_quantum_reed_muller_code( + number_of_variables: int, maximum_x_degree: int, maximum_z_degree: int +) -> StabilizerCode: + """ + The X stabilizers correspond to the polynomials with m variables + with degree <= rX and the Z stabilizers correspond to the + polynomials with m variables with degree <= rZ where: + m = number_of_variables, + rX = maximum_x_degree, + rZ = maximum_z_degree. + """ + if maximum_x_degree + maximum_z_degree > number_of_variables - 1: + raise ValueError("Degrees too large to define a Reed-Muller code.") + x_matrix = _reed_muller_code_generator_matrix(number_of_variables, maximum_x_degree) + z_matrix = _reed_muller_code_generator_matrix(number_of_variables, maximum_z_degree) + x_generators = _generators_from_matrix(x_matrix, "X") + z_generators = _generators_from_matrix(z_matrix, "Z") + return StabilizerCode(x_generators + z_generators) + + +def _punctured_reed_muller_code_generator_matrix( + number_of_variables: int, maximum_degree: int +) -> list[list[int]]: + matrix = [] + for weight in range(1, maximum_degree + 1): + for monomial_terms in combinations(range(number_of_variables), weight): + monomial = BinaryMonomial(set(monomial_terms)) + matrix.append(_evaluation_vector_of(monomial, number_of_variables)[1:]) + return matrix + + +def make_quantum_punctured_reed_muller_code( + number_of_variables: int, maximum_x_degree: int, maximum_z_degree: int +) -> StabilizerCode: + """ + Remove the two stabilizer generators X...X and Z...Z from the + quantum Reed Muller group and remove qubit 0. + """ + if maximum_x_degree == 0 and maximum_z_degree == 0: + raise ValueError("Maximum degrees cannot be both equal to 0.") + if maximum_x_degree + maximum_z_degree > number_of_variables - 1: + raise ValueError("Degrees too large to define a Reed-Muller code.") + x_matrix = _punctured_reed_muller_code_generator_matrix( + number_of_variables, maximum_x_degree + ) + z_matrix = _punctured_reed_muller_code_generator_matrix( + number_of_variables, maximum_z_degree + ) + x_generators = _generators_from_matrix(x_matrix, "X") + z_generators = _generators_from_matrix(z_matrix, "Z") + return StabilizerCode(x_generators + z_generators) + + +def make_steane_code() -> StabilizerCode: + return make_quantum_hamming_code(3) + + +def make_quantum_hamming_code(number_of_checks: int) -> StabilizerCode: + return make_quantum_punctured_reed_muller_code(number_of_checks, 1, 1) + + +def make_quantum_extended_hamming_code(number_of_checks: int) -> StabilizerCode: + return make_quantum_reed_muller_code(number_of_checks, 1, 1) + + +def _make_golay_code_generator_matrix() -> list[list[int]]: + return [ + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0], + [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1], + [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1], + [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1], + ] + + +def make_quantum_golay_code() -> StabilizerCode: + check_matrix = _make_golay_code_generator_matrix() + x_generators = _generators_from_matrix(check_matrix, "X") + z_generators = _generators_from_matrix(check_matrix, "Z") + return StabilizerCode(x_generators + z_generators) + + +def make_color_code_832() -> StabilizerCode: + return StabilizerCode( + [ + Pauli.from_string("XXXXXXXX"), + Pauli.from_string("ZZZZZZZZ"), + Pauli.from_string("ZZZZIIII"), + Pauli.from_string("ZZIIZZII"), + Pauli.from_string("ZIZIZIZI"), + ], + logical_basis=[ + Pauli.from_string("XXXXIIII"), + Pauli.from_string("ZIIIZIII"), + Pauli.from_string("XXIIXXII"), + Pauli.from_string("ZIZIIIII"), + Pauli.from_string("XIXIXIXI"), + Pauli.from_string("ZZIIIIII"), + ], + ) + + +def make_tesseract_code() -> StabilizerCode: + qubits = tuple(range(16)) + rows = [qubits[4 * row : 4 * (row + 1)] for row in range(4)] + columns = [qubits[col::4] for col in range(4)] + squares = [ + (0, 1, 4, 5), + (5, 6, 9, 10), + (1, 2, 5, 6), + (4, 5, 8, 9), + ] + generator_supports = [ + rows[0] + rows[1], + rows[1] + rows[2], + rows[2] + rows[3], + columns[0] + columns[1], + columns[1] + columns[2], + ] + generators = [_pauli_on(support, "Z") for support in generator_supports] + generators += [_pauli_on(support, "X") for support in generator_supports] + logicals = [ + _pauli_on(rows[0], "X"), + _pauli_on(columns[0], "Z"), + _pauli_on(columns[0], "X"), + _pauli_on(rows[0], "Z"), + _pauli_on(squares[0], "X"), + _pauli_on(squares[1], "Z"), + _pauli_on(squares[1], "X"), + _pauli_on(squares[0], "Z"), + _pauli_on(squares[2], "X"), + _pauli_on(squares[3], "Z"), + _pauli_on(squares[3], "X"), + _pauli_on(squares[2], "Z"), + ] + return StabilizerCode(generators, logical_basis=logicals) + + +def make_carbon_code() -> StabilizerCode: + return StabilizerCode( + [ + Pauli.from_string("XXXX"), + Pauli.from_string("IIIIXXXX"), + Pauli.from_string("IIIIIIIIXXXX"), + Pauli.from_string("ZZZZ"), + Pauli.from_string("IIIIZZZZ"), + Pauli.from_string("IIIIIIIIZZZZ"), + Pauli.from_string("XXIIIXIXXIIX"), + Pauli.from_string("XIIXXXIIIXIX"), + Pauli.from_string("ZIZIIIZZZIIZ"), + Pauli.from_string("ZIIZZIZIIIZZ"), + ] + ) + + +def _pauli_on(support: Iterable[int], character: PauliCharacter) -> Pauli: + return Pauli({index: character for index in support}) diff --git a/source/qdk_package/tests/ec_tests/testing/code_catalog/subsystem_codes.py b/source/qdk_package/tests/ec_tests/testing/code_catalog/subsystem_codes.py new file mode 100644 index 00000000000..007584a7004 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/code_catalog/subsystem_codes.py @@ -0,0 +1,30 @@ +from itertools import product +from paulimer import centralizer_of +from paulimer import PauliGroup + +from qdk.ec._analysis.propagation.pauli import Pauli +from qdk.ec._analysis.code_algebra import SubsystemCode + + +def center_of(group: PauliGroup) -> PauliGroup: + """The center of ``group`` — the elements that commute with all of it.""" + return group & centralizer_of(group) + + +def make_bacon_shor_code(x_distance: int, z_distance: int) -> SubsystemCode: + qubit_index = { + (row, col): row * z_distance + col + for row, col in product(range(x_distance), range(z_distance)) + } + centralizers = [ + Pauli({qubit_index[(row, column)]: "Z", qubit_index[(row, column + 1)]: "Z"}) + for row, column in product(range(x_distance), range(z_distance - 1)) + ] + [ + Pauli({qubit_index[(row, column)]: "X", qubit_index[(row + 1, column)]: "X"}) + for row, column in product(range(x_distance - 1), range(z_distance)) + ] + logical_z = Pauli({qubit_index[(row, 0)]: "Z" for row in range(x_distance)}) + logical_x = Pauli({qubit_index[(0, column)]: "X" for column in range(z_distance)}) + stabilizer = center_of(PauliGroup(centralizers + [logical_x, logical_z])) + generators = [generator for generator in stabilizer.generators if generator.weight] + return SubsystemCode(generators, [logical_x, logical_z]) diff --git a/source/qdk_package/tests/ec_tests/testing/code_catalog/surface_codes.py b/source/qdk_package/tests/ec_tests/testing/code_catalog/surface_codes.py new file mode 100644 index 00000000000..2e08917baaa --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/code_catalog/surface_codes.py @@ -0,0 +1,118 @@ +from itertools import product +from qdk.ec._analysis.stabilizer_code import StabilizerCode +from typing import cast + +from qdk.ec._analysis.propagation.pauli import Pauli, PauliCharacter + +Coordinate = tuple[float, float] + + +def make_rotated_surface_code(*, x_distance: int, z_distance: int) -> StabilizerCode: + return make_rotated_surface_code_with_labels( + x_distance=x_distance, z_distance=z_distance + )[0] + + +def make_rotated_surface_code_with_labels( + *, x_distance: int, z_distance: int +) -> tuple[StabilizerCode, list[Coordinate]]: + data_qubits = _rotated_surface_code_data_qubits(x_distance, z_distance) + data_qubit_index = {coord: index for index, coord in enumerate(sorted(data_qubits))} + + labeled_generators = _rotated_surface_code_stabilizer_generators( + x_distance, z_distance + ) + generators = [ + _remap_pauli(pauli, data_qubit_index) for pauli in labeled_generators.values() + ] + labels = list(labeled_generators.keys()) + return StabilizerCode(generators), labels + + +def _remap_pauli( + coord_pauli: dict[Coordinate, str], index_of: dict[Coordinate, int] +) -> Pauli: + return Pauli( + { + index_of[coord]: cast(PauliCharacter, char) + for coord, char in coord_pauli.items() + } + ) + + +def _rotated_surface_code_data_qubits( + x_distance: int, z_distance: int +) -> set[Coordinate]: + if x_distance % 2 == 0 or z_distance % 2 == 0: + raise ValueError( + f"Invalid distances {x_distance, z_distance}. Both distances must be odd." + ) + return set((row, col) for row, col in product(range(z_distance), range(x_distance))) + + +def _rotated_surface_code_x_ancilla_qubits( + x_distance: int, z_distance: int +) -> set[Coordinate]: + if x_distance % 2 == 0 or z_distance % 2 == 0: + raise ValueError( + f"Invalid distances {x_distance, z_distance}. Both distances must be odd." + ) + return set( + (row + 0.5, col + 0.5) + for row, col in product(range(z_distance - 1), range(-1, x_distance)) + if (row + col) % 2 == 0 + ) + + +def _rotated_surface_code_z_ancilla_qubits( + x_distance: int, z_distance: int +) -> set[Coordinate]: + if x_distance % 2 == 0 or z_distance % 2 == 0: + raise ValueError( + f"Invalid distances {x_distance, z_distance}. Both distances must be odd." + ) + return set( + (row + 0.5, col + 0.5) + for row, col in product(range(-1, z_distance), range(x_distance - 1)) + if (row + col) % 2 == 1 + ) + + +def _rotated_surface_code_x_stabilizer_generators( + x_distance: int, z_distance: int +) -> dict[Coordinate, dict[Coordinate, str]]: + data_qubits = _rotated_surface_code_data_qubits(x_distance, z_distance) + x_ancillas = _rotated_surface_code_x_ancilla_qubits(x_distance, z_distance) + x_generators = {} + for ancilla in x_ancillas: + generator_characters: dict[Coordinate, str] = {} + for direction in [(0.5, 0.5), (0.5, -0.5), (-0.5, 0.5), (-0.5, -0.5)]: + neighbor = (ancilla[0] + direction[0], ancilla[1] + direction[1]) + if neighbor in data_qubits: + generator_characters[neighbor] = "X" + x_generators[ancilla] = generator_characters + return x_generators + + +def _rotated_surface_code_z_stabilizer_generators( + x_distance: int, z_distance: int +) -> dict[Coordinate, dict[Coordinate, str]]: + data_qubits = _rotated_surface_code_data_qubits(x_distance, z_distance) + z_ancillas = _rotated_surface_code_z_ancilla_qubits(x_distance, z_distance) + z_generators = {} + for ancilla in z_ancillas: + generator_characters: dict[Coordinate, str] = {} + for direction in [(0.5, 0.5), (0.5, -0.5), (-0.5, 0.5), (-0.5, -0.5)]: + neighbor = (ancilla[0] + direction[0], ancilla[1] + direction[1]) + if neighbor in data_qubits: + generator_characters[neighbor] = "Z" + z_generators[ancilla] = generator_characters + return z_generators + + +def _rotated_surface_code_stabilizer_generators( + x_distance: int, z_distance: int +) -> dict[Coordinate, dict[Coordinate, str]]: + x_generators = _rotated_surface_code_x_stabilizer_generators(x_distance, z_distance) + z_generators = _rotated_surface_code_z_stabilizer_generators(x_distance, z_distance) + return x_generators | z_generators diff --git a/source/qdk_package/tests/ec_tests/testing/optional.py b/source/qdk_package/tests/ec_tests/testing/optional.py new file mode 100644 index 00000000000..9e1e1dc5b39 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/optional.py @@ -0,0 +1,28 @@ +"""Skip markers for dependencies that may be absent in source environments. + +Published ``qdk[ec]`` installs HiGHS. MWPF requires a separate installation. +Stim comes with ``qodec[parsers]``, which ``qdk[ec]`` omits on platforms without +Stim wheels (Linux aarch64, Windows ARM64); tests that parse ``format="stim"`` +circuits, directly or through ``build_qodec``, need it. Tests for these +dependencies carry a marker when the package may be absent. +""" + +from __future__ import annotations + +from importlib.util import find_spec + +import pytest + + +def _requires(module: str, package: str) -> pytest.MarkDecorator: + return pytest.mark.skipif( + find_spec(module) is None, + reason=f"{module} is not installed (pip install '{package}')", + ) + + +requires_mwpf = _requires("mwpf", "mwpf") +requires_highs = _requires("highspy", "qdk[ec]") +requires_stim = _requires("stim", "stim") + +__all__ = ["requires_mwpf", "requires_highs", "requires_stim"] diff --git a/source/qdk_package/tests/ec_tests/testing/pauli_enumeration.py b/source/qdk_package/tests/ec_tests/testing/pauli_enumeration.py new file mode 100644 index 00000000000..74f79058e11 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/pauli_enumeration.py @@ -0,0 +1,52 @@ +"""Exhaustive sparse-Pauli enumeration, for tests that need small error sets. + +Lives here rather than in ``qdk.ec``: nothing in the package enumerates Paulis +by weight, since distance search goes through ``_analysis.distance_solvers``. +""" + +from __future__ import annotations + +import math +from typing import Iterable, Iterator, cast + +from more_itertools import nth_combination, nth_product +from paulimer import SparsePauli + +from qdk.ec._analysis.propagation.pauli import Pauli, PauliCharacter + + +class PauliEnumerator: + """Enumerate sparse Paulis by support and weight.""" + + def __init__(self, support: Iterable[int], characters: str = "XYZ"): + self._support = tuple(sorted(support)) + self._types = characters + + def of_weight(self, weight: int) -> Iterator[Pauli]: + if weight == 0: + yield SparsePauli({}) + return + support_count = math.comb(len(self._support), weight) + character_count = len(self._types) ** weight + total_count = support_count * character_count + repeated_types = [self._types] * weight + + def getitem(index: int) -> Pauli: + support_index, character_index = divmod(index, character_count) + support = nth_combination(self._support, weight, support_index) + chars = nth_product(character_index, *repeated_types) + return Pauli(cast("dict[int, PauliCharacter]", dict(zip(support, chars)))) + + yield from (getitem(index) for index in range(total_count)) + + def by_weight(self, weights: Iterable[int] | None = None) -> Iterator[Pauli]: + if weights is None: + weights = range(len(self._support)) + for weight in weights: + yield from self.of_weight(weight) + + def up_to_weight(self, maximum: int) -> Iterator[Pauli]: + return self.by_weight(range(maximum + 1)) + + +__all__ = ["PauliEnumerator"] diff --git a/source/qdk_package/tests/ec_tests/testing/persistence.py b/source/qdk_package/tests/ec_tests/testing/persistence.py new file mode 100644 index 00000000000..6b3249c3047 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/persistence.py @@ -0,0 +1,13 @@ +from collections.abc import Hashable +from typing import Collection +import multiprocessing +from concurrent.futures import ProcessPoolExecutor + + +def collection_is_persistent(collection: Collection[Hashable]) -> bool: + with ProcessPoolExecutor( + 1, mp_context=multiprocessing.get_context("spawn") + ) as executor: + persisted = executor.submit(set, collection).result() + local = set(collection) + return local == persisted diff --git a/source/qdk_package/tests/ec_tests/testing/qodecs/__init__.py b/source/qdk_package/tests/ec_tests/testing/qodecs/__init__.py new file mode 100644 index 00000000000..03a053f87fa --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/qodecs/__init__.py @@ -0,0 +1,25 @@ +"""Vendored qodec fixtures for qdk.ec tests. + +A self-contained, single-file qodec snapshot, kept here so tests have a +concrete qodec to sample, decode, and analyze without a bespoke qodec generator +in the qdk.ec package itself. ``c4`` is a saved snapshot of the retired +``qdk.ec.qodecs.c4()`` output. Regenerate with +``qodec.save(path, single_file=True)``. +""" + +from __future__ import annotations + +from pathlib import Path + +import qodec as qc + +_fixtures_dir = Path(__file__).parent + + +def _load(name: str) -> qc.Qodec: + return qc.Qodec.load(str(_fixtures_dir / f"{name}.qodec.yaml")) + + +def c4() -> qc.Qodec: + """The C4 [[4,2,2]] error-detecting qodec (two logical qubits).""" + return _load("c4") diff --git a/source/qdk_package/tests/ec_tests/testing/qodecs/c4.qodec.yaml b/source/qdk_package/tests/ec_tests/testing/qodecs/c4.qodec.yaml new file mode 100644 index 00000000000..0dfe11db129 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/testing/qodecs/c4.qodec.yaml @@ -0,0 +1,438 @@ +--- +qodec.yaml: + schema_version: 1 + name: c4 + layers: + - instruction_set: C4.isa.yaml + codes: + c4: C4.code.yaml + gadgets: + idle: idle.gadget.yaml + measure_xx: measure_xx.gadget.yaml + measure_zz: measure_zz.gadget.yaml + prepare_xx: prepare_xx.gadget.yaml + prepare_zz: prepare_zz.gadget.yaml + transversal_cx: transversal_cx.gadget.yaml + x0: x0.gadget.yaml + x1: x1.gadget.yaml + z0: z0.gadget.yaml + z1: z1.gadget.yaml + - instruction_set: stim.isa.yaml +--- +C4.isa.yaml: + name: C4 + blocks: + c4: 2 + instructions: + - mnemonic: prepare_zz + description: '' + out: + - c4 + action: + - stabilize: + - Z_0 + - Z_1 + flags: + - reject + - mnemonic: idle + description: '' + in: + - c4 + out: + - c4 + - mnemonic: measure_zz + description: '' + in: + - c4 + action: + - observe: + - Z_0 + - Z_1 + - mnemonic: prepare_xx + description: '' + out: + - c4 + action: + - stabilize: + - X_0 + - X_1 + flags: + - reject + - mnemonic: measure_xx + description: '' + in: + - c4 + action: + - observe: + - X_0 + - X_1 + - mnemonic: transversal_cx + description: '' + in: + - c4 + - c4 + out: + - c4 + - c4 + action: + - clifford: + X_0: X_0 X_2 + X_1: X_1 X_3 + Z_2: Z_0 Z_2 + Z_3: Z_1 Z_3 + - mnemonic: x0 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: X_0 + - mnemonic: x1 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: X_1 + - mnemonic: z0 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: Z_0 + - mnemonic: z1 + description: '' + in: + - c4 + out: + - c4 + action: + - pauli: Z_1 +--- +stim.isa.yaml: + name: stim + blocks: + qubit: 1 + instructions: + - mnemonic: R + description: '' + out: + - qubit + action: + - stabilize: + - Z_0 + - mnemonic: H + description: '' + in: + - qubit + out: + - qubit + action: + - clifford: + X_0: Z_0 + Z_0: X_0 + - mnemonic: CX + description: '' + in: + - qubit + - qubit + out: + - qubit + - qubit + action: + - clifford: + X_0: X_0 X_1 + Z_1: Z_0 Z_1 + - mnemonic: M + description: '' + in: + - qubit + action: + - observe: Z_0 + - mnemonic: X + description: '' + in: + - qubit + out: + - qubit + action: + - pauli: X_0 + - mnemonic: Z + description: '' + in: + - qubit + out: + - qubit + action: + - pauli: Z_0 +--- +C4.code.yaml: + name: C4 + stabilizers: + - X_0 X_1 X_2 X_3 + - Z_0 Z_1 Z_2 Z_3 + x: + - X_0 X_1 + - X_0 X_2 + z: + - Z_0 Z_2 + - Z_0 Z_1 +--- +idle.gadget.yaml: + implements: ./C4.isa.yaml#idle + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Data qubits: 0-3; X-stabilizer ancilla: 4; Z-stabilizer ancilla: 5 + R 4 5 + H 4 + CX 4 0 4 1 4 2 4 3 + H 4 + CX 0 5 1 5 2 5 3 5 + M 4 5 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - in[0].stabilizers[0] + - - circuit.readouts[1] + - in[0].stabilizers[1] + - - circuit.readouts[0] + - out[0].stabilizers[0] + - - circuit.readouts[1] + - out[0].stabilizers[1] +--- +measure_xx.gadget.yaml: + implements: ./C4.isa.yaml#measure_xx + circuit: + instruction_set: ./stim.isa.yaml + source: | + H 0 1 2 3 + M 0 1 2 3 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - circuit.readouts[1] + - circuit.readouts[2] + - circuit.readouts[3] + - in[0].stabilizers[0] + readouts: + - - circuit.readouts[0] + - circuit.readouts[1] + - - circuit.readouts[0] + - circuit.readouts[2] +--- +measure_zz.gadget.yaml: + implements: ./C4.isa.yaml#measure_zz + circuit: + instruction_set: ./stim.isa.yaml + source: | + M 0 1 2 3 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - circuit.readouts[0] + - circuit.readouts[1] + - circuit.readouts[2] + - circuit.readouts[3] + - in[0].stabilizers[1] + readouts: + - - circuit.readouts[0] + - circuit.readouts[2] + - - circuit.readouts[0] + - circuit.readouts[1] +--- +prepare_xx.gadget.yaml: + implements: ./C4.isa.yaml#prepare_xx + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Fault-tolerant preparation of |++⟩_L in XX basis + R 0 1 2 3 + H 0 + CX 0 4 + CX 0 1 + CX 0 2 + CX 0 3 + CX 0 4 + H 0 1 2 3 + # Flag = reject bit + M 4 + format: stim + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - out[0].stabilizers[0] + - - out[0].stabilizers[1] + readouts: + - reject: + - circuit.readouts[0] +--- +prepare_zz.gadget.yaml: + implements: ./C4.isa.yaml#prepare_zz + circuit: + instruction_set: ./stim.isa.yaml + source: | + # Fault-tolerant preparation of |00⟩_L in ZZ basis + R 0 1 2 3 + H 0 + CX 0 4 + CX 0 1 + CX 0 2 + CX 0 3 + CX 0 4 + # Flag = reject bit + M 4 + format: stim + out: + - c4: + - 0 + - 1 + - 2 + - 3 + checks: + - - out[0].stabilizers[0] + - - out[0].stabilizers[1] + readouts: + - reject: + - circuit.readouts[0] +--- +transversal_cx.gadget.yaml: + implements: ./C4.isa.yaml#transversal_cx + circuit: + instruction_set: ./stim.isa.yaml + source: | + CX 0 4 1 5 2 6 3 7 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + - c4: + - 4 + - 5 + - 6 + - 7 + out: + - c4: + - 0 + - 1 + - 2 + - 3 + - c4: + - 4 + - 5 + - 6 + - 7 +--- +x0.gadget.yaml: + implements: ./C4.isa.yaml#x0 + circuit: + instruction_set: ./stim.isa.yaml + source: | + X 0 1 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +x1.gadget.yaml: + implements: ./C4.isa.yaml#x1 + circuit: + instruction_set: ./stim.isa.yaml + source: | + X 0 2 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +z0.gadget.yaml: + implements: ./C4.isa.yaml#z0 + circuit: + instruction_set: ./stim.isa.yaml + source: | + Z 0 2 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 +--- +z1.gadget.yaml: + implements: ./C4.isa.yaml#z1 + circuit: + instruction_set: ./stim.isa.yaml + source: | + Z 0 1 + format: stim + in: + - c4: + - 0 + - 1 + - 2 + - 3 + out: + - c4: + - 0 + - 1 + - 2 + - 3 diff --git a/source/qdk_package/tests/ec_tests/validation/__init__.py b/source/qdk_package/tests/ec_tests/validation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/validation/audit/__init__.py b/source/qdk_package/tests/ec_tests/validation/audit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/validation/audit/fixtures/repetition3.qodec.yaml b/source/qdk_package/tests/ec_tests/validation/audit/fixtures/repetition3.qodec.yaml new file mode 100644 index 00000000000..648d6dfeca0 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/audit/fixtures/repetition3.qodec.yaml @@ -0,0 +1,107 @@ +# repetition3 — single-file qodec bundle (vendored audit fixture). +# +# A small, current-model qodec used by the audit tests. It is authored with +# only the stim gates qodec's parser supports (R / M / CX), so the semantic +# audit rules (action-mismatch, readout-mismatch) actually run rather than +# skipping. Copied from the qodec repo's examples/repetition3; if that example +# changes shape, refresh this copy. +--- +qodec.yaml: + schema_version: 1 + name: repetition3 + description: Z-basis bit-flip repetition code (distance 3) — the simplest non-trivial QEC example in qodec. + layers: + - instruction_set: repetition3.isa.yaml + codes: + repetition3: repetition3.code.yaml + gadgets: + prepare_z: prepare_z.gadget.yaml + idle: idle.gadget.yaml + measure_z: measure_z.gadget.yaml + rotate_z: rotate_z.gadget.yaml + - instruction_set: stim+rz.isa.yaml +--- +repetition3.isa.yaml: + name: repetition3 + description: Logical instruction set for the 3-qubit bit-flip repetition code. + blocks: {repetition3: 1} + instructions: + - mnemonic: prepare_z + description: Prepare the logical |0⟩ state. + out: [repetition3] + action: [stabilize: Z_0] + - mnemonic: idle + description: One syndrome-extraction round (identity logical action). + in: [repetition3] + out: [repetition3] + - mnemonic: measure_z + description: Destructive Z-basis measurement of the logical Z observable. + in: [repetition3] + action: [observe: Z_0] + - mnemonic: rotate_z + description: Logical Z-axis rotation by theta radians (non-Clifford for generic theta). + in: [repetition3] + out: [repetition3] + parameters: {theta: number} + action: [rotate: {pauli: Z_0, angle: theta}] +--- +stim+rz.isa.yaml: + name: stim+rz + description: Compact stim-like physical ISA plus a parameterized rotate_z(theta). + blocks: {qubit: 1} + instructions: + - mnemonic: R + description: Reset qubit to |0⟩. + out: [qubit] + action: [stabilize: Z_0] + - mnemonic: M + description: Destructive Z-basis measurement. + in: [qubit] + action: [observe: Z_0] + - mnemonic: CX + description: Controlled-X (CNOT). + in: [qubit, qubit] + out: [qubit, qubit] + action: [clifford: {X_0: X_0 X_1, Z_1: Z_0 Z_1}] + - mnemonic: rotate_z + description: Rotation by exp(-i theta/2 Z). + in: [qubit] + out: [qubit] + parameters: {theta: number} + action: [rotate: {pauli: Z_0, angle: theta}] +--- +repetition3.code.yaml: + name: repetition3 + description: 3-qubit bit-flip repetition code (Z-basis). + stabilizers: [Z_0 Z_1, Z_1 Z_2] + x: [X_0 X_1 X_2] + z: [Z_0] +--- +prepare_z.gadget.yaml: + circuit: {format: stim, source: "R 0 1 2"} +--- +idle.gadget.yaml: + circuit: + format: stim + source: |- + R 3 4 + CX 0 3 1 3 + CX 1 4 2 4 + M 3 4 + checks: + - ["circuit.readouts[0]", "in[0].stabilizers[0]"] + - ["circuit.readouts[1]", "in[0].stabilizers[1]"] + - ["circuit.readouts[0]", "out[0].stabilizers[0]"] + - ["circuit.readouts[1]", "out[0].stabilizers[1]"] +--- +measure_z.gadget.yaml: + circuit: {format: stim, source: "M 0 1 2"} + checks: + - ["circuit.readouts[0]", "circuit.readouts[1]", "in[0].stabilizers[0]"] + - ["circuit.readouts[1]", "circuit.readouts[2]", "in[0].stabilizers[1]"] + readouts: [["circuit.readouts[0]", "in[0].z[0]"]] +--- +rotate_z.gadget.yaml: + circuit: + source: [rotate_z: {operands: [0], arguments: {theta: theta}}] + parameter_bindings: {theta: circuit.source.theta} diff --git a/source/qdk_package/tests/ec_tests/validation/audit/rules/__init__.py b/source/qdk_package/tests/ec_tests/validation/audit/rules/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/qdk_package/tests/ec_tests/validation/audit/rules/test_isa_rules.py b/source/qdk_package/tests/ec_tests/validation/audit/rules/test_isa_rules.py new file mode 100644 index 00000000000..d666c49512f --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/audit/rules/test_isa_rules.py @@ -0,0 +1,71 @@ +"""Tests for the instruction-set unreferenced-block audit rule. + +The rule flags a block type that an ISA declares but no instruction operand +encodes into — a leftover qodec does not reject at load. It is skipped for +ISAs whose instructions use no block operands at all (e.g. a physical gate +ISA), where the block model does not apply. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import qodec as qc +from qdk.ec._audit import Diagnostic, Severity, audit +from qdk.ec._audit.rules.instruction_set import UnreferencedBlockRule + + +def _placeholder_qodec() -> qc.Qodec: + return qc.Qodec(layers=[qc.Layer(qc.InstructionSet("_placeholder"))]) + + +def _diags(rule: object, isa: qc.InstructionSet) -> list[Diagnostic]: + iterator: Iterator[Diagnostic] = rule( # type: ignore[operator] + isa, qodec=_placeholder_qodec() + ) + return list(iterator) + + +def test_unreferenced_block_clean_on_repetition3(rep3_qodec: qc.Qodec) -> None: + rule = UnreferencedBlockRule() + for isa in rep3_qodec.instruction_sets.values(): + assert _diags(rule, isa) == [], f"unexpected diagnostics in {isa.name}" + + +def test_unreferenced_block_fires_for_unused_block() -> None: + operand = qc.instructions.BlockOperand("used") + isa = qc.InstructionSet( + name="two_blocks", + blocks=[ + qc.instructions.Block("used", encodes=1), + qc.instructions.Block("spare", encodes=1), + ], + instructions=[ + qc.Instruction(mnemonic="op", inputs=[operand], outputs=[operand]), + ], + ) + diagnostics = _diags(UnreferencedBlockRule(), isa) + assert any("'spare'" in d.summary for d in diagnostics) + assert all(d.severity is Severity.INFO for d in diagnostics) + assert {d.rule for d in diagnostics} == {"instruction-set/unreferenced-block"} + protocol = qc.Qodec(layers=[qc.Layer(isa), qc.Layer(qc.InstructionSet("physical"))]) + assert {item.rule for item in audit(protocol).informational} == { + "instruction-set/unreferenced-block", + "gadget/missing-realization", + } + assert "instruction-set/unreferenced-block" not in { + item.rule + for item in audit( + protocol, disabled=("instruction-set/unreferenced-block",) + ).diagnostics + } + + +def test_unreferenced_block_skipped_when_no_block_operands() -> None: + """A gate ISA whose instructions use no block operands is not block-modelled.""" + isa = qc.InstructionSet( + name="gates", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[qc.Instruction(mnemonic="noop")], + ) + assert _diags(UnreferencedBlockRule(), isa) == [] diff --git a/source/qdk_package/tests/ec_tests/validation/audit/rules/test_qodec_rules.py b/source/qdk_package/tests/ec_tests/validation/audit/rules/test_qodec_rules.py new file mode 100644 index 00000000000..1958b9935e7 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/audit/rules/test_qodec_rules.py @@ -0,0 +1,67 @@ +"""Tests for whole-qodec audit rules.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import qodec as qc +from qdk.ec._audit import Diagnostic, Severity, audit +from qdk.ec._audit.rules import default_rules +from qdk.ec._audit.rules.qodec import ( + MissingRealizationRule, + StructuralValidationRule, +) + + +def _diags(rule: object, qodec: qc.Qodec) -> list[Diagnostic]: + iterator: Iterator[Diagnostic] = rule(qodec, qodec=qodec) # type: ignore[operator] + return list(iterator) + + +# --------------------------------------------------------------------------- +# Happy paths +# --------------------------------------------------------------------------- + + +def test_missing_source_instruction_clean(rep3_qodec: qc.Qodec) -> None: + assert _diags(StructuralValidationRule(), rep3_qodec) == [] + + +def test_missing_realization_clean(rep3_qodec: qc.Qodec) -> None: + assert _diags(MissingRealizationRule(), rep3_qodec) == [] + + +# --------------------------------------------------------------------------- +# Negatives +# --------------------------------------------------------------------------- + + +def test_missing_realization_fires_when_gadget_omitted( + rep3_qodec: qc.Qodec, +) -> None: + layer0 = rep3_qodec.layers[0] + kept = {name: gadget for name, gadget in layer0.gadgets.items() if name != "idle"} + partial = qc.Qodec( + layers=[ + qc.Layer(layer0.instruction_set, gadgets=kept), + rep3_qodec.layers[1], + ], + name="rep3_partial", + ) + rule = MissingRealizationRule() + diagnostics = _diags(rule, partial) + assert len(diagnostics) == 1 + assert diagnostics[0].severity is Severity.INFO + assert diagnostics[0].summary == "No explicit gadget for instruction 'idle'" + assert diagnostics[0].detail == "No entry: layers[0].gadgets['idle']" + + disabled = tuple(item.name for item in default_rules() if item.name != rule.name) + for promote_warnings in (False, True): + report = audit(partial, disabled=disabled, promote_warnings=promote_warnings) + assert report.ok + assert report.errors == report.warnings == () + assert report.informational == tuple(diagnostics) + + assert rule.name not in { + item.rule for item in audit(partial, disabled=(rule.name,)).diagnostics + } diff --git a/source/qdk_package/tests/ec_tests/validation/audit/test_diagnostic.py b/source/qdk_package/tests/ec_tests/validation/audit/test_diagnostic.py new file mode 100644 index 00000000000..663a44be750 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/audit/test_diagnostic.py @@ -0,0 +1,60 @@ +"""Tests for `Diagnostic`, `Severity`, and `Phase`.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from qdk.ec._audit import Diagnostic, Phase, Severity + + +def test_severity_enum_values() -> None: + assert {s.value for s in Severity} == {"info", "warning", "error"} + + +def test_diagnostic_is_frozen() -> None: + diag = Diagnostic(rule="r/x", severity=Severity.ERROR, summary="x", where="y") + with pytest.raises(dataclasses.FrozenInstanceError): + diag.summary = "modified" # type: ignore[misc] + + +def test_diagnostic_default_detail_is_empty() -> None: + diag = Diagnostic(rule="r/x", severity=Severity.WARNING, summary="x", where="y") + assert diag.detail == "" + + +def test_diagnostic_dataclass_replace_preserves_other_fields() -> None: + """`Auditor`'s strict mode uses dataclasses.replace to promote + severity. Pin that the rest of the fields ride along.""" + original = Diagnostic( + rule="r/x", + severity=Severity.WARNING, + summary="x", + where="y", + detail="z", + ) + promoted = dataclasses.replace(original, severity=Severity.ERROR) + assert promoted.rule == original.rule + assert promoted.summary == original.summary + assert promoted.where == original.where + assert promoted.detail == original.detail + assert promoted.severity is Severity.ERROR + + +def test_phase_enum_values() -> None: + assert {p.value for p in Phase} == {"structural", "semantic", "informational"} + + +@pytest.mark.parametrize("severity", list(Severity)) +@pytest.mark.parametrize("detail", ["", "line one\nline two"]) +def test_diagnostic_str_formats_severity_location_and_detail( + severity: Severity, detail: str +) -> None: + diagnostic = Diagnostic("test/rule", severity, "message", "location", detail) + expected = f"[{severity.name}] test/rule\nlocation\nmessage" + if detail: + expected += "\n line one\n line two" + + assert str(diagnostic) == expected + assert repr(diagnostic).startswith("Diagnostic(rule=") diff --git a/source/qdk_package/tests/ec_tests/validation/audit/test_report.py b/source/qdk_package/tests/ec_tests/validation/audit/test_report.py new file mode 100644 index 00000000000..205f0843c2e --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/audit/test_report.py @@ -0,0 +1,219 @@ +"""Tests for the private audit report implementation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import qodec as qc + +from qdk.ec._audit import Diagnostic, Phase, Report, Severity +from qdk.ec._audit._auditor import Auditor +from qdk.ec._audit.rules.gadget import CheckMismatchRule +from ec_tests.testing.optional import requires_stim + + +def _make(rule: str, severity: Severity, where: str = "x") -> Diagnostic: + return Diagnostic(rule=rule, severity=severity, summary="x", where=where) + + +def test_empty_report_is_ok() -> None: + report = Report() + assert report.ok + assert report.errors == () + assert report.warnings == () + assert report.informational == () + + +def test_report_with_only_warnings_is_ok() -> None: + report = Report(diagnostics=(_make("a", Severity.WARNING),)) + assert report.ok + assert report.warnings == (_make("a", Severity.WARNING),) + assert report.errors == () + + +def test_report_with_error_is_not_ok() -> None: + report = Report( + diagnostics=( + _make("a", Severity.WARNING), + _make("b", Severity.ERROR), + ) + ) + assert not report.ok + assert len(report.errors) == 1 + assert len(report.warnings) == 1 + + +def test_by_rule_groups_diagnostics() -> None: + report = Report( + diagnostics=( + _make("rule/x", Severity.ERROR), + _make("rule/y", Severity.WARNING), + _make("rule/x", Severity.INFO), + ) + ) + grouped = report.by_rule() + assert set(grouped.keys()) == {"rule/x", "rule/y"} + assert len(grouped["rule/x"]) == 2 + assert len(grouped["rule/y"]) == 1 + + +def test_by_artifact_groups_diagnostics() -> None: + report = Report( + diagnostics=( + _make("a", Severity.ERROR, where="gadget[1]"), + _make("a", Severity.ERROR, where="gadget[1]"), + _make("b", Severity.ERROR, where="gadget[2]"), + ) + ) + grouped = report.by_artifact() + assert set(grouped.keys()) == {"gadget[1]", "gadget[2]"} + assert len(grouped["gadget[1]"]) == 2 + + +def test_str_summary_includes_counts() -> None: + report = Report( + diagnostics=( + _make("a", Severity.ERROR), + _make("b", Severity.WARNING), + ) + ) + text = str(report) + assert "1 error(s)" in text + assert "1 warning(s)" in text + assert "0 informational" in text + + +def test_str_empty_is_ok_message() -> None: + assert str(Report()) == "audit: ok" + + +def test_str_includes_diagnostic_detail_indented() -> None: + diag = Diagnostic( + rule="r/x", + severity=Severity.ERROR, + summary="boom", + where="here", + detail="line one\nline two", + ) + text = str(Report(diagnostics=(diag,))) + assert " line one" in text + assert " line two" in text + + +def test_informational_split() -> None: + report = Report( + diagnostics=( + _make("a", Severity.INFO), + _make("b", Severity.WARNING), + ) + ) + assert len(report.informational) == 1 + assert report.ok + + +def test_report_formats_location_and_evidence_without_extra_advice() -> None: + diagnostic = Diagnostic( + rule="gadget/readout-mismatch", + severity=Severity.ERROR, + summary="readouts[0] does not report the required logical X_0 measurement", + where="layers[0].gadgets['measure_xx'] (C4 -> stim)", + detail='Declared equation: ["circuit.readouts[0]"]\nVerified readout equation: ["circuit.readouts[0]", "circuit.readouts[1]"]', + ) + assert str(Report((diagnostic,))) == ( + "[ERROR] gadget/readout-mismatch\n" + "layers[0].gadgets['measure_xx'] (C4 -> stim)\n" + "readouts[0] does not report the required logical X_0 measurement\n" + ' Declared equation: ["circuit.readouts[0]"]\n' + ' Verified readout equation: ["circuit.readouts[0]", "circuit.readouts[1]"]\n\n' + "audit: 1 error(s), 0 warning(s), 0 informational" + ) + + +def test_report_reuses_diagnostic_text_in_severity_order() -> None: + error = _make("test/error", Severity.ERROR) + warning = _make("test/warning", Severity.WARNING) + informational = _make("test/info", Severity.INFO) + report = Report((informational, warning, error)) + + assert str(report) == ( + f"{error}\n\n{warning}\n\n" "audit: 1 error(s), 1 warning(s), 1 informational" + ) + assert str(Report((informational,))) == ( + "audit: 0 error(s), 0 warning(s), 1 informational" + ) + + +def test_diagnostic_phase_enum_values() -> None: + """Phase enum is used by rules; sanity-check the three members exist.""" + members = {p.name for p in Phase} + assert members == {"STRUCTURAL", "SEMANTIC", "INFORMATIONAL"} + + +@pytest.mark.parametrize("severity", [Severity.ERROR, Severity.WARNING]) +@pytest.mark.parametrize("home_kind", ["parent", "prefix", "unavailable"]) +def test_report_abbreviates_home_only_for_display( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + severity: Severity, + home_kind: str, +) -> None: + directory = tmp_path / "home-extra" + directory.mkdir() + file = directory / "protocol.bundle" + file.write_text('{"layers": []}') + location = qc.Qodec.load(file).resolve("").source_location + assert location is not None + + def home() -> Path: + if home_kind == "unavailable": + raise RuntimeError("Cannot determine home directory") + return directory if home_kind == "parent" else tmp_path / "home" + + monkeypatch.setattr(Path, "home", home) + diagnostic = Diagnostic( + "test/location", severity, "message", "qodec", source_location=location + ) + display_path = "~/protocol.bundle" if home_kind == "parent" else str(file) + assert str(Report((diagnostic,))).splitlines()[:4] == [ + f"[{severity.name}] test/location", + f"{display_path}:{location.line}", + "qodec", + "message", + ] + assert str(diagnostic).splitlines() == str(Report((diagnostic,))).splitlines()[:4] + assert diagnostic.source_location is location + assert location.path == file and location.path.is_absolute() + + +@requires_stim +def test_report_points_to_loaded_check_equation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + protocol = qc.Qodec.load(Path(__file__).parents[2] / "testing/qodecs/c4.qodec.yaml") + gadget = protocol.layers[0].gadgets["measure_zz"] + gadget.checks = [["circuit.readouts[0]"]] + file = tmp_path / "protocol.bundle" + file.write_text(protocol.dumps(), encoding="utf-8") + loaded = qc.Qodec.load(file) + report = Auditor(rules=[CheckMismatchRule()]).audit(loaded) + diagnostic = report.errors[0] + location = diagnostic.source_location + assert location is not None and location.path == file + assert ( + "circuit.readouts[0]" + in file.read_text(encoding="utf-8").splitlines()[location.line - 1] + ) + assert str(report).splitlines()[:4] == [ + "[ERROR] gadget/check-mismatch", + f"{file}:{location.line}", + diagnostic.where, + diagnostic.summary, + ] + assert diagnostic.where == "layers[0].gadgets['measure_zz'] (C4 -> stim)" + assert Auditor(rules=[CheckMismatchRule()]).audit(loaded) == report + assert ( + Auditor(rules=[CheckMismatchRule()]).audit(protocol).errors[0].source_location + is None + ) diff --git a/source/qdk_package/tests/ec_tests/validation/conftest.py b/source/qdk_package/tests/ec_tests/validation/conftest.py new file mode 100644 index 00000000000..05c069b20db --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/conftest.py @@ -0,0 +1,28 @@ +"""Fixtures for validation tests. + +The audit tests exercise against a vendored, current-model ``repetition3`` +qodec kept under ``tests/validation/audit/fixtures/``. +""" + +from pathlib import Path + +import pytest +import qodec as qc + +_AUDIT_FIXTURES = Path(__file__).parent / "audit" / "fixtures" + + +@pytest.fixture(scope="package") +def rep3_path() -> str: + """Filesystem path to the vendored, current-model ``repetition3`` qodec.""" + return str(_AUDIT_FIXTURES / "repetition3.qodec.yaml") + + +@pytest.fixture +def rep3_qodec(rep3_path: str) -> qc.Qodec: + """A freshly loaded ``repetition3`` qodec. + + Function-scoped so individual tests may mutate the returned object (e.g. + swap a gadget) without affecting others. + """ + return qc.Qodec.load(rep3_path) diff --git a/source/qdk_package/tests/ec_tests/validation/test_algebra_validation.py b/source/qdk_package/tests/ec_tests/validation/test_algebra_validation.py new file mode 100644 index 00000000000..6e668ffb971 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_algebra_validation.py @@ -0,0 +1,180 @@ +"""Algebra is checked by analysis, not by qodec persistence.""" + +from collections.abc import Mapping +from types import MappingProxyType + +import pytest +import qodec as qc + +from qdk.ec import CodeProfile +from qdk.ec._analysis.propagation.isa_actions import build_clifford_images +from qdk.ec._audit.rules.code import CodeAlgebraRule +from qdk.ec._audit.rules.instruction_set import CliffordAlgebraRule +from qdk.ec._audit import Auditor, audit +from qdk.ec._audit import Diagnostic, Phase, Severity +from qdk.ec._audit.rules.qodec import StructuralValidationRule + + +def test_clifford_images_validate_implicit_identity_before_native_constructor() -> None: + with pytest.raises(ValueError, match="must anticommute"): + build_clifford_images({"X_0": "Z_0"}, {0: 0}, {0: 0}, 1) + images = build_clifford_images({"X_0": "Z_0", "Z_0": "X_0"}, {0: 0}, {0: 0}, 1) + assert [str(image) for image in images] == ["Z", "X"] + + +def test_clifford_images_accept_read_only_mapping_with_string_subclass_keys() -> None: + class PauliText(str): + pass + + generators: Mapping[PauliText, PauliText] = MappingProxyType( + {PauliText("X_0"): PauliText("Z_0"), PauliText("Z_0"): PauliText("X_0")} + ) + images = build_clifford_images(generators, {0: 0}, {0: 0}, 1) + assert [str(image) for image in images] == ["Z", "X"] + + +def test_clifford_images_reject_conflicting_aliases() -> None: + with pytest.raises(ValueError, match="conflicting images"): + build_clifford_images({"X_0": "X_0", "X_00": "Z_0"}, {0: 0}, {0: 0}, 1) + + +@pytest.mark.parametrize( + ("stabilizers", "logical_x", "logical_z", "evidence"), + [ + (["X_0", "Z_0"], [], [], "stabilizers[0] and stabilizers[1]"), + ([], ["X_0"], ["X_0"], "x[0] and z[0]"), + (["Z_0"], ["X_0"], ["Z_0"], "stabilizers[0] and x[0]"), + ([], ["X_0", "Z_0"], ["Z_0", "X_0"], "x[0] and x[1]"), + ], +) +def test_code_algebra_has_pair_evidence( + stabilizers: list[str], logical_x: list[str], logical_z: list[str], evidence: str +) -> None: + code = qc.Code("draft", stabilizers=stabilizers, x=logical_x, z=logical_z) + diagnostics = list(CodeAlgebraRule()(code, qodec=qc.Qodec([]))) + assert diagnostics + assert all(item.rule == "code/invalid-algebra" for item in diagnostics) + assert any(evidence in item.summary for item in diagnostics) + with pytest.raises(ValueError): + CodeProfile(code) + + +def test_clifford_algebra_rule_checks_implicit_images() -> None: + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "bad", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Clifford({"X_0": "Z_0"})], + ) + instruction_set = qc.InstructionSet( + "draft", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[instruction], + ) + diagnostics = list(CliffordAlgebraRule()(instruction_set, qodec=qc.Qodec([]))) + assert len(diagnostics) == 1 + assert diagnostics[0].rule == "instruction-set/invalid-clifford" + assert "identity images" in diagnostics[0].detail + + +def test_invalid_code_blocks_dependent_gadgets(rep3_qodec: qc.Qodec) -> None: + code = rep3_qodec.codes["repetition3"] + code.z = list(code.x) + report = audit(rep3_qodec) + assert {item.rule for item in report.errors} == {"code/invalid-algebra"} + assert not [ + item + for item in report.diagnostics + if item.rule.startswith("gadget/") and item.severity.name != "INFO" + ] + + +def test_structural_findings_are_located_in_audit(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.checks = [["in[9].x[0]"]] + report = audit(rep3_qodec) + diagnostic = next( + item for item in report.errors if item.rule == "qodec/invalid-structure" + ) + assert diagnostic.where.startswith("layers[0].gadgets['idle']") + assert "in[9].x[0]" in diagnostic.summary + assert not [item for item in report.warnings if item.where == diagnostic.where] + + +def test_detached_gadget_uses_qodec_bounds_check(rep3_qodec: qc.Qodec) -> None: + original = rep3_qodec.layers[0].gadgets["idle"] + detached = qc.Gadget( + original.implements, + original.circuit, + inputs=original.inputs, + outputs=original.outputs, + checks=[["in[9].x[0]"]], + ) + report = Auditor().audit_gadget(detached, qodec=rep3_qodec) + assert len(report.errors) == 1 + assert report.errors[0].rule == "qodec/invalid-structure" + assert report.errors[0].where == "gadget['idle']" + assert "in[9].x[0]" in report.errors[0].summary + + +def test_shared_invalid_instruction_set_blocks_all_occurrences( + rep3_qodec: qc.Qodec, +) -> None: + source, physical = rep3_qodec.layers + source.instruction_set.blocks = [] + protocol = qc.Qodec([source, physical, source, physical]) + report = audit(protocol) + assert any(item.rule == "qodec/invalid-structure" for item in report.errors) + assert not [ + item + for item in report.diagnostics + if item.rule.startswith("gadget/") and item.severity is not Severity.INFO + ] + + +def test_algebra_failure_does_not_block_independent_gadget( + rep3_qodec: qc.Qodec, +) -> None: + from collections.abc import Iterator + + class RecordAnalysis: + name = "test/analyzed" + severity = Severity.INFO + phase = Phase.SEMANTIC + target = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + assert isinstance(target, qc.Gadget) + yield Diagnostic(self.name, self.severity, target.implements.mnemonic, "") + + source, physical = rep3_qodec.layers + source.instruction_set.blocks = [ + *source.instruction_set.blocks, + qc.instructions.Block("unencoded", encodes=1), + ] + operand = qc.instructions.BlockOperand("unencoded") + instruction = qc.Instruction("independent", inputs=[operand], outputs=[operand]) + source.instruction_set.instructions = [ + *source.instruction_set.instructions.values(), + instruction, + ] + encoding = qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical.instruction_set, "[]", format="yaml"), + inputs=[encoding], + outputs=[encoding], + ) + source.gadgets = {**source.gadgets, "independent": gadget} + code = rep3_qodec.codes["repetition3"] + code.z = list(code.x) + report = Auditor( + rules=[StructuralValidationRule(), CodeAlgebraRule(), RecordAnalysis()] + ).audit(rep3_qodec) + assert {item.rule for item in report.errors} == {"code/invalid-algebra"}, str( + report + ) + assert [item.summary for item in report.informational] == ["independent"] diff --git a/source/qdk_package/tests/ec_tests/validation/test_auditor.py b/source/qdk_package/tests/ec_tests/validation/test_auditor.py new file mode 100644 index 00000000000..c5f56ef76f4 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_auditor.py @@ -0,0 +1,535 @@ +"""Tests for the private audit framework and built-in rules. + +Inputs come from the vendored, current-model ``repetition3`` qodec +(``tests/analysis/audit/fixtures/repetition3.qodec.yaml``, exposed by the +``rep3_qodec`` fixture), so these tests exercise the audit against a real +loaded qodec. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import json +from pathlib import Path + +import qodec as qc +from qdk.ec._audit import ( + Auditor, + Diagnostic, + Phase, + Severity, + audit, +) +from ec_tests.testing.optional import requires_stim + +# ---------------------------------------------------------------------------- +# Helpers: rebuild a gadget with the current API, optionally corrupting it. +# ---------------------------------------------------------------------------- + + +def _atoms(readout: qc.gadgets.Readout) -> list[str]: + """The authored parity terms of a typed readout.""" + return [str(atom) for atom in readout.equation] + + +def _clone( + gadget: qc.Gadget, + *, + checks: list[list[str]] | None = None, + readouts: list[list[str]] | None = None, +) -> qc.Gadget: + """A copy of ``gadget`` with its ``checks`` / ``readouts`` optionally replaced.""" + return qc.Gadget( + gadget.implements, + gadget.circuit, + inputs=list(gadget.inputs), + outputs=list(gadget.outputs), + checks=( + [[str(atom) for atom in check] for check in gadget.checks] + if checks is None + else checks + ), + readouts=( + [_atoms(readout) for readout in gadget.readouts] + if readouts is None + else readouts + ), + ) + + +# ---------------------------------------------------------------------------- +# Smoke: the shipped qodec audits with no errors. +# ---------------------------------------------------------------------------- + + +def test_repetition3_audits_without_errors(rep3_qodec: qc.Qodec) -> None: + report = audit(rep3_qodec) + assert report.ok, str(report) + + +def test_repetition3_audits_clean_with_informational( + rep3_qodec: qc.Qodec, +) -> None: + report = Auditor(include_informational=True).audit(rep3_qodec) + assert report.ok, str(report) + + +def test_c4_measure_xx_readouts_are_consistent() -> None: + protocol = qc.Qodec.load( + str(Path(__file__).parents[1] / "testing" / "qodecs" / "c4.qodec.yaml") + ) + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [ + (*readout.equation, f"in[0].x[{index}]") + for index, readout in enumerate(gadget.readouts) + ] + report = Auditor().audit_gadget(gadget, qodec=protocol) + assert not [ + item for item in report.errors if item.rule == "gadget/readout-mismatch" + ], str(report) + + +@requires_stim +def test_readout_message_has_layer_observable_and_verified_parity() -> None: + protocol = qc.Qodec.load( + str(Path(__file__).parents[1] / "testing" / "qodecs" / "c4.qodec.yaml") + ) + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [ + ["circuit.readouts[0]", "in[0].x[0]"], + ["circuit.readouts[0]", "circuit.readouts[2]", "in[0].x[1]"], + ] + errors = [ + item + for item in audit(protocol).errors + if item.rule == "gadget/readout-mismatch" + and "gadgets['measure_xx']" in item.where + ] + assert len(errors) == 1 + diagnostic = errors[0] + assert diagnostic.where == "layers[0].gadgets['measure_xx'] (C4 -> stim)" + assert ( + diagnostic.summary + == "readouts[0] does not report the required logical X_0 measurement" + ) + assert ( + 'Declared equation: ["circuit.readouts[0]", "in[0].x[0]"]' in diagnostic.detail + ) + assert ( + 'Verified readout equation: ["in[0].x[0]", "circuit.readouts[0]", "circuit.readouts[1]"]' + in diagnostic.detail + ) + expected = json.loads(diagnostic.detail.splitlines()[1].split(": ", 1)[1]) + gadget.readouts = [expected, list(gadget.readouts[1].equation)] + assert not [ + item + for item in Auditor().audit_gadget(gadget, qodec=protocol).errors + if item.rule == "gadget/readout-mismatch" + ] + assert len(diagnostic.detail.splitlines()) == 2 + + +def test_same_gadget_at_two_layers_has_distinct_locations(rep3_qodec: qc.Qodec) -> None: + source, target = rep3_qodec.layers + gadget = source.gadgets["idle"] + gadget.checks = [] + protocol = qc.Qodec(layers=[source, source, target]) + report = audit(protocol) + locations = { + item.where + for item in report.warnings + if item.rule == "gadget/incomplete-output-frame" + and ".gadgets['idle']" in item.where + } + assert locations == { + f"layers[1].gadgets['idle'] (repetition3 -> {target.instruction_set.name})", + } + assert any( + item.rule == "qodec/invalid-structure" + and item.where == "layers[0].gadgets['idle'] (repetition3 -> repetition3)" + for item in report.errors + ) + + +def test_detached_gadget_location_does_not_guess_layer(rep3_qodec: qc.Qodec) -> None: + detached = _clone(rep3_qodec.layers[0].gadgets["idle"], checks=[]) + report = Auditor().audit_gadget(detached, qodec=rep3_qodec) + assert {item.where for item in report.warnings} == {"gadget['idle']"} + + +def test_c4_readout_equivalent_modulo_check_is_accepted() -> None: + protocol = qc.Qodec.load( + str(Path(__file__).parents[1] / "testing" / "qodecs" / "c4.qodec.yaml") + ) + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [ + [ + "circuit.readouts[2]", + "circuit.readouts[3]", + "in[0].x[0]", + "in[0].stabilizers[0]", + ], + [ + "circuit.readouts[1]", + "circuit.readouts[3]", + "in[0].x[1]", + "in[0].stabilizers[0]", + ], + ] + report = Auditor().audit_gadget(gadget, qodec=protocol) + assert not [ + item for item in report.errors if item.rule == "gadget/readout-mismatch" + ] + + +@requires_stim +def test_output_frame_message_contains_verified_relation() -> None: + protocol = qc.Qodec.load( + str(Path(__file__).parents[1] / "testing" / "qodecs" / "c4.qodec.yaml") + ) + gadget = protocol.layers[0].gadgets["transversal_cx"] + report = Auditor().audit_gadget(gadget, qodec=protocol) + diagnostic = next( + item for item in report.warnings if "out[0].stabilizers[0]" in item.summary + ) + assert diagnostic.where == "layers[0].gadgets['transversal_cx'] (C4 -> stim)" + assert "X_0 X_1 X_2 X_3" in diagnostic.summary + assert ( + 'Verified relation: ["out[0].stabilizers[0]", "in[0].stabilizers[0]", "in[1].stabilizers[0]"]' + in diagnostic.detail + ) + relation = json.loads(diagnostic.detail.split("Verified relation: ", 1)[1]) + gadget.checks = [relation] + assert [str(term) for term in gadget.checks[0]] == relation + assert "Include" not in diagnostic.detail + + +@requires_stim +def test_output_frame_relation_preserves_constant_sign() -> None: + protocol = qc.Qodec.load( + str(Path(__file__).parents[1] / "testing" / "qodecs" / "c4.qodec.yaml") + ) + gadget = protocol.layers[0].gadgets["x0"] + gadget.circuit = qc.gadgets.Circuit( + gadget.circuit.instruction_set, "X 0", format="stim" + ) + report = Auditor().audit_gadget(gadget, qodec=protocol) + diagnostic = next( + item for item in report.warnings if "out[0].stabilizers[1]" in item.summary + ) + assert ( + 'Verified relation: ["out[0].stabilizers[1]", "in[0].stabilizers[1]", 1]' + in diagnostic.detail + ) + + +# ---------------------------------------------------------------------------- +# Per-artifact entry points +# ---------------------------------------------------------------------------- + + +def test_audit_gadget_only_runs_gadget_rules(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + report = Auditor(include_informational=True).audit_gadget(gadget, qodec=rep3_qodec) + assert report.ok, str(report) + assert all(d.rule.startswith("gadget/") for d in report.diagnostics) + + +# ---------------------------------------------------------------------------- +# Negative: gadget/missing-observable (a measure gadget's readout is dropped) +# ---------------------------------------------------------------------------- + + +def test_dropped_readouts_triggers_missing_observable( + rep3_qodec: qc.Qodec, +) -> None: + measure_z = rep3_qodec.layers[0].gadgets["measure_z"] + stripped = _clone(measure_z, readouts=[]) + for strict in (False, True): + report = Auditor(strict=strict).audit_gadget(stripped, qodec=rep3_qodec) + assert not report.ok + assert "gadget/missing-observable" in {d.rule for d in report.errors} + assert "gadget/missing-observable" not in {d.rule for d in report.informational} + + +# ---------------------------------------------------------------------------- +# Negative: gadget/readout-mismatch (a readout's outcome atom is dropped) +# ---------------------------------------------------------------------------- + + +@requires_stim +def test_truncated_readout_triggers_readout_mismatch( + rep3_qodec: qc.Qodec, +) -> None: + measure_z = rep3_qodec.layers[0].gadgets["measure_z"] + truncated: list[list[str]] = [] + for readout in measure_z.readouts: + atoms = _atoms(readout) + record_atoms = [a for a in atoms if a.startswith("circuit.readouts")] + other = [a for a in atoms if not a.startswith("circuit.readouts")] + truncated.append(other + record_atoms[1:]) + corrupted = _clone(measure_z, readouts=truncated) + report = Auditor().audit_gadget(corrupted, qodec=rep3_qodec) + assert not report.ok + assert "gadget/readout-mismatch" in {d.rule for d in report.errors} + + +# ---------------------------------------------------------------------------- +# Negative: qodec reference bounds +# ---------------------------------------------------------------------------- + + +def test_out_of_range_encoding_entry_is_flagged( + rep3_qodec: qc.Qodec, +) -> None: + """``measure_z`` destroys its logical, so it has no output encoding; an + ``out[...]`` reference is therefore out of range.""" + measure_z = rep3_qodec.layers[0].gadgets["measure_z"] + checks = [[str(a) for a in check] for check in measure_z.checks] + checks.append(["out[5].stabilizers[0]"]) + corrupted = _clone(measure_z, checks=checks) + report = Auditor().audit_gadget(corrupted, qodec=rep3_qodec) + assert not report.ok + assert "qodec/invalid-structure" in {d.rule for d in report.errors} + + +def test_out_of_range_stabilizer_index_is_flagged( + rep3_qodec: qc.Qodec, +) -> None: + """The repetition code has two stabilizers, so ``stabilizers[9]`` is out + of range even though the entry index is valid.""" + idle = rep3_qodec.layers[0].gadgets["idle"] + checks = [[str(a) for a in check] for check in idle.checks] + checks.append(["in[0].stabilizers[9]"]) + corrupted = _clone(idle, checks=checks) + report = Auditor().audit_gadget(corrupted, qodec=rep3_qodec) + assert "qodec/invalid-structure" in {d.rule for d in report.errors} + + +@requires_stim +def test_all_reference_targets_are_bounds_checked(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + for reference, valid_range in ( + ("circuit.readouts[0,999]", "out of bounds for 3 entries"), + ("readouts[0:2]", "out of bounds for 1 entries"), + ): + for field in ("checks", "readouts"): + changed = _clone(gadget, **{field: [[reference]]}) + report = Auditor().audit_gadget(changed, qodec=rep3_qodec) + errors = [ + item for item in report.errors if item.rule == "qodec/invalid-structure" + ] + assert len(errors) == 1 + assert f"{field}[0]" in errors[0].summary + assert valid_range in errors[0].summary + + +# ---------------------------------------------------------------------------- +# Negative: gadget/missing-flag (an instruction declares a flag the gadget's +# readouts do not bind) +# ---------------------------------------------------------------------------- + + +def test_unbound_flag_triggers_missing_flag(rep3_qodec: qc.Qodec) -> None: + stim_isa = rep3_qodec.layers[1].instruction_set + code = rep3_qodec.codes["repetition3"] + operand = qc.instructions.BlockOperand("repetition3") + flagged = qc.Instruction( + "prepare_flagged", + outputs=[operand], + flags=["reject"], + action=[qc.actions.Stabilize(["Z_0"])], + ) + circuit = qc.gadgets.Circuit(stim_isa, "R 0 1 2", format="stim") + encoding = qc.gadgets.Encoding(code, support=["0", "1", "2"]) + # readouts=[] leaves the declared 'reject' flag unbound. + gadget = qc.Gadget(flagged, circuit, outputs=[encoding], readouts=[]) + report = Auditor().audit_gadget(gadget, qodec=rep3_qodec) + assert not report.ok + diagnostic = next(d for d in report.errors if d.rule == "gadget/missing-flag") + assert diagnostic.summary == "readouts[0] has no equation for flag 'reject'" + assert diagnostic.detail == "" + strict_report = Auditor(strict=True).audit_gadget(gadget, qodec=rep3_qodec) + assert not strict_report.ok + assert "gadget/missing-flag" in {d.rule for d in strict_report.errors} + gadget.readouts = [{"reject": []}] + assert "gadget/missing-flag" not in { + d.rule for d in Auditor().audit_gadget(gadget, qodec=rep3_qodec).diagnostics + } + + +@requires_stim +def test_reset_of_declared_input_is_checked_against_instruction() -> None: + block = qc.instructions.Block("qubit", encodes=1) + operand = qc.instructions.BlockOperand("qubit") + reset = qc.Instruction( + "reset_z", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Stabilize(["Z_0"])], + ) + idle = qc.Instruction("idle", inputs=[operand], outputs=[operand]) + physical_reset = qc.Instruction( + "R", outputs=[operand], action=[qc.actions.Stabilize(["Z_0"])] + ) + physical = qc.InstructionSet( + "physical", blocks=[block], instructions=[physical_reset] + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + for instruction, expected_rules in ( + (reset, set()), + (idle, {"gadget/action-mismatch"}), + ): + logical = qc.InstructionSet( + "logical", blocks=[block], instructions=[instruction] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "R 0", format="stim"), + inputs=[encoding], + outputs=[encoding], + ) + protocol = qc.Qodec([qc.Layer(logical, gadgets=[gadget]), qc.Layer(physical)]) + protocol.validate() + + report = audit(protocol) + assert {item.rule for item in report.diagnostics} == expected_rules, str(report) + assert {item.rule for item in report.errors} == expected_rules, str(report) + + +# ---------------------------------------------------------------------------- +# Phase ordering: structural errors short-circuit the semantic phase +# ---------------------------------------------------------------------------- + + +def test_structural_error_skips_semantic_phase(rep3_qodec: qc.Qodec) -> None: + """An unresolved reference skips dependent semantic checks.""" + measure_z = rep3_qodec.layers[0].gadgets["measure_z"] + stripped = _clone(measure_z, readouts=[["in[9].x[0]"]]) + report = Auditor().audit_gadget(stripped, qodec=rep3_qodec) + rules_fired = {d.rule for d in report.diagnostics} + assert "qodec/invalid-structure" in rules_fired + assert "gadget/action-mismatch" not in rules_fired + assert "gadget/readout-mismatch" not in rules_fired + + +def test_structural_error_only_skips_semantics_for_its_target( + rep3_qodec: qc.Qodec, +) -> None: + class _StructuralOnIdle: + name = "test/structural-idle" + severity = Severity.ERROR + phase = Phase.STRUCTURAL + target = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if isinstance(target, qc.Gadget) and target.implements.mnemonic == "idle": + yield Diagnostic(self.name, self.severity, "invalid idle", "idle") + + class _SemanticOnMeasure: + name = "test/semantic-measure" + severity = Severity.ERROR + phase = Phase.SEMANTIC + target = qc.Gadget + + def __call__(self, target: object, *, qodec: qc.Qodec) -> Iterator[Diagnostic]: + if ( + isinstance(target, qc.Gadget) + and target.implements.mnemonic == "measure_z" + ): + yield Diagnostic( + self.name, + self.severity, + "invalid measurement", + "measure_z", + ) + + report = Auditor(rules=[_StructuralOnIdle(), _SemanticOnMeasure()]).audit_layer( + rep3_qodec.layers[0], + qodec=rep3_qodec, + ) + + assert {(item.rule, item.where) for item in report.diagnostics} == { + ("test/structural-idle", "idle"), + ("test/semantic-measure", "measure_z"), + } + + +# ---------------------------------------------------------------------------- +# gadget/incomplete-output-frame +# ---------------------------------------------------------------------------- + + +@requires_stim +def test_incomplete_output_frame_quiet_for_complete_gadget( + rep3_qodec: qc.Qodec, +) -> None: + # ``idle`` declares an out[0].stabilizers[i] sign for every stabilizer. + idle = rep3_qodec.layers[0].gadgets["idle"] + report = Auditor(include_informational=True).audit_gadget(idle, qodec=rep3_qodec) + fired = [ + d for d in report.diagnostics if d.rule == "gadget/incomplete-output-frame" + ] + assert not fired, str(report) + + +@requires_stim +def test_incomplete_output_frame_fires_when_out_frames_dropped( + rep3_qodec: qc.Qodec, +) -> None: + idle = rep3_qodec.layers[0].gadgets["idle"] + stripped = _clone(idle, checks=[]) + report = Auditor().audit_gadget(stripped, qodec=rep3_qodec) + fired = [ + d for d in report.diagnostics if d.rule == "gadget/incomplete-output-frame" + ] + assert fired, str(report) + assert all(d.severity is Severity.WARNING for d in fired) + assert all(".stabilizers[" in d.summary for d in fired), str(report) + + +# ---------------------------------------------------------------------------- +# Strict mode promotes warnings to errors +# ---------------------------------------------------------------------------- + + +def test_strict_mode_promotes_warnings(rep3_qodec: qc.Qodec) -> None: + """Strict mode turns every WARNING into ERROR.""" + + class _AlwaysWarn: + name = "test/always-warn" + severity = Severity.WARNING + phase = Phase.STRUCTURAL + target = qc.Gadget + + def __call__( + self, target: object, *, qodec: qc.Qodec + ) -> "Iterator[Diagnostic]": + yield Diagnostic( + rule=self.name, + severity=self.severity, + summary="always warn", + where="test", + ) + + auditor = Auditor(rules=[_AlwaysWarn()], strict=True) + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + report = auditor.audit_gadget(gadget, qodec=rep3_qodec) + assert not report.ok + assert all(d.severity is Severity.ERROR for d in report.diagnostics) + + +# ---------------------------------------------------------------------------- +# Disabled rules +# ---------------------------------------------------------------------------- + + +def test_disabled_rule_is_skipped(rep3_qodec: qc.Qodec) -> None: + measure_z = rep3_qodec.layers[0].gadgets["measure_z"] + stripped = _clone(measure_z, readouts=[]) + auditor = Auditor(disabled={"gadget/missing-observable"}) + report = auditor.audit_gadget(stripped, qodec=rep3_qodec) + rules_fired = {d.rule for d in report.diagnostics} + assert "gadget/missing-observable" not in rules_fired diff --git a/source/qdk_package/tests/ec_tests/validation/test_declaration_issues.py b/source/qdk_package/tests/ec_tests/validation/test_declaration_issues.py new file mode 100644 index 00000000000..96c0ded86ae --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_declaration_issues.py @@ -0,0 +1,141 @@ +"""Tests for structural declaration issues.""" + +from __future__ import annotations + +import qodec as qc +import pytest + +from ec_tests.testing.qodecs import c4 +from qdk.ec._analysis.declaration_issues import declaration_issues +from qdk.ec._audit import Auditor, Severity +from qdk.ec._audit.rules.gadget import UnsupportedActionStepRule +from ec_tests.testing.optional import requires_stim + + +def test_complete_measurement_declaration_has_no_issues() -> None: + gadget = c4().layers[0].gadgets["measure_zz"] + + assert declaration_issues(gadget).missing_observables == () + + +def test_bound_flag_is_reported_independently() -> None: + gadget = c4().layers[0].gadgets["prepare_zz"] + + assert declaration_issues(gadget).bound_flags == ("reject",) + + +def test_missing_observables_are_structural_issues() -> None: + original = c4().layers[0].gadgets["measure_zz"] + gadget = qc.Gadget( + original.implements, + original.circuit, + inputs=list(original.inputs), + checks=[list(check) for check in original.checks], + readouts=[], + ) + + assert declaration_issues(gadget).missing_observables == ("0", "1") + + +def test_unsupported_action_is_reported_without_computing_an_action() -> None: + original = c4().layers[0].gadgets["measure_zz"] + instruction = qc.Instruction( + mnemonic="rotated", + inputs=[qc.instructions.BlockOperand("c4")], + action=[qc.actions.Rotate("Z_0 Z_1", angle=0.5)], + ) + gadget = qc.Gadget( + instruction, + original.circuit, + inputs=list(original.inputs), + checks=[list(check) for check in original.checks], + ) + + assert declaration_issues(gadget).unsupported_steps == ("Rotate",) + + +def test_conditional_pauli_is_not_supported_by_declaration_checks() -> None: + original = c4().layers[0].gadgets["idle"] + operand = qc.instructions.BlockOperand("c4") + instruction = qc.Instruction( + mnemonic="conditional", + inputs=[operand], + outputs=[operand], + flags=["flag"], + action=[qc.actions.Pauli("X_0", condition=qc.actions.Condition(["flag"]))], + ) + gadget = qc.Gadget( + instruction, + original.circuit, + inputs=list(original.inputs), + outputs=list(original.outputs), + checks=[list(check) for check in original.checks], + readouts=[{"flag": ["circuit.readouts[0]"]}], + ) + + assert declaration_issues(gadget).unsupported_steps == ("Pauli",) + + +def test_unsupported_action_step_rule_name_and_filter() -> None: + protocol = c4() + original = protocol.layers[0].gadgets["measure_zz"] + instruction = qc.Instruction( + "rotated", + inputs=original.implements.inputs, + action=[qc.actions.Rotate("Z_0", angle=0.5)], + ) + gadget = qc.Gadget(instruction, original.circuit, inputs=original.inputs) + rule = UnsupportedActionStepRule() + report = Auditor(rules=[rule]).audit_gadget(gadget, qodec=protocol) + assert len(report.warnings) == 1 + diagnostic = report.warnings[0] + assert diagnostic.rule == "gadget/unsupported-action-step" + assert diagnostic.severity is Severity.WARNING + assert ( + diagnostic.summary + == "implements.action[0] (Rotate) is not supported by the action verifier" + ) + assert ( + not Auditor(rules=[rule], disabled=(rule.name,)) + .audit_gadget(gadget, qodec=protocol) + .diagnostics + ) + + +@requires_stim +@pytest.mark.parametrize("side", ["inputs", "outputs"]) +@pytest.mark.parametrize("same_entry", [False, True]) +def test_overlapping_supports_are_errors_before_analysis( + side: str, same_entry: bool +) -> None: + protocol = c4() + gadget = protocol.layers[0].gadgets["transversal_cx"] + encodings = getattr(gadget, side) + entry, position = (0, 1) if same_entry else (1, 0) + support = list(encodings[entry].support) + label = encodings[0].support[0] + support[position] = label + encodings[entry].support = support + setattr(gadget, side, encodings) + + report = Auditor(include_informational=True).audit(protocol) + diagnostics = [ + item for item in report.diagnostics if "gadgets['transversal_cx']" in item.where + ] + boundary = "in" if side == "inputs" else "out" + assert len(diagnostics) == 1 + assert diagnostics[0].rule == "qodec/invalid-structure" + assert diagnostics[0].severity is Severity.ERROR + assert diagnostics[0].summary == ( + f"{boundary}[{entry}].support[{position}] and {boundary}[0].support[0] " + f"both use circuit label {label!r}" + ) + assert any("gadgets['measure_xx']" in item.where for item in report.errors) + + +def test_supports_may_be_reused_across_boundaries() -> None: + protocol = c4() + gadget = protocol.layers[0].gadgets["transversal_cx"] + assert gadget.inputs[0].support == gadget.outputs[0].support + report = Auditor().audit(protocol) + assert not any(item.rule == "qodec/invalid-structure" for item in report.errors) diff --git a/source/qdk_package/tests/ec_tests/validation/test_distance_code.py b/source/qdk_package/tests/ec_tests/validation/test_distance_code.py new file mode 100644 index 00000000000..64af6dbb18c --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_distance_code.py @@ -0,0 +1,165 @@ +"""Tests for stabilizer-code distance estimation.""" + +from __future__ import annotations +from typing import Iterable, Literal +from importlib import import_module +import operator +from functools import reduce +from unittest.mock import patch +import pytest +import qodec as qc +from qdk.ec import CodeProfile +from qdk.ec._analysis.stabilizer_code import StabilizerCode +from ec_tests.testing import code_catalog as catalog +from qdk.ec._analysis.propagation.pauli import Pauli +from qdk.ec._distance import ( + MwpfSolverOptions, + code_distance_bounds_of, + code_distance_of, +) +from ec_tests.testing.optional import requires_highs, requires_mwpf + +enumeration_cases: list[tuple[str, StabilizerCode, int]] = [ + ("five_qubit", catalog.make_five_qubit_code(), 3), + ("steane", catalog.make_steane_code(), 3), + ("shor", catalog.make_shor_code(), 3), + ("repetition_3", catalog.make_repetition_code(3), 1), + ("repetition_9", catalog.make_repetition_code(9), 1), + ("hamming_3", catalog.make_quantum_hamming_code(3), 3), + ("hamming_4", catalog.make_quantum_hamming_code(4), 3), + ("extended_hamming_4", catalog.make_quantum_extended_hamming_code(4), 4), + ("422", catalog.make_422_code(), 2), + ("iceberg_8", catalog.make_iceberg_code(8), 2), + ("color_832", catalog.make_color_code_832(), 2), + ("tesseract", catalog.make_tesseract_code(), 4), + ("carbon", catalog.make_carbon_code(), 4), +] + +mwpf_cases: list[tuple[str, StabilizerCode, int]] = enumeration_cases + [ + ("golay", catalog.make_quantum_golay_code(), 7), + ("surface_3", catalog.make_rotated_surface_code(x_distance=3, z_distance=3), 3), + ("surface_5", catalog.make_rotated_surface_code(x_distance=5, z_distance=5), 5), +] + + +@pytest.mark.parametrize("name, code, expected", enumeration_cases) +def test_enumeration_code_distance_matches_known_value( + name: str, code: StabilizerCode, expected: int +) -> None: + distance, witness = code_distance_of(code, solver="enumeration") + assert distance == expected, name + assert code.is_non_trivial_logical_error(product_of(witness)) + assert len(witness) == expected + + +@requires_mwpf +@pytest.mark.parametrize("name, code, expected", mwpf_cases) +def test_mwpf_upper_bound_matches_known_distance( + name: str, code: StabilizerCode, expected: int +) -> None: + lower, upper, witness = code_distance_bounds_of(code, solver=MwpfSolverOptions()) + assert upper == expected, name + assert lower <= upper + assert code.is_non_trivial_logical_error(product_of(witness)) + + +@requires_mwpf +@pytest.mark.parametrize("name, code, expected", enumeration_cases) +def test_mwpf_agrees_with_enumeration_oracle( + name: str, code: StabilizerCode, expected: int +) -> None: + exact, _ = code_distance_of(code, solver="enumeration") + _, upper, _ = code_distance_bounds_of(code, solver=MwpfSolverOptions()) + assert upper == exact, name + assert exact == expected, name + + +def test_per_basis_distance_for_css_code() -> None: + code = catalog.make_steane_code() + distance_x, error_x = code_distance_of(code, errors="X") + distance_z, error_z = code_distance_of(code, errors="Z") + assert distance_x == 3 + assert distance_z == 3 + assert code.is_non_trivial_logical_error(product_of(error_x)) + assert code.is_non_trivial_logical_error(product_of(error_z)) + + +def test_distance_upper_bound_short_circuits_search() -> None: + code = catalog.make_five_qubit_code() + with pytest.raises(RuntimeError, match="exact distance"): + code_distance_of(code, distance_upper_bound=2) + lower, upper, witness = code_distance_bounds_of( + code, solver="enumeration", distance_upper_bound=2 + ) + assert lower == 3 and upper > lower and witness == [] + + +@requires_highs +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_code_profile_defaults_to_highs(method: str) -> None: + profile = CodeProfile( + qc.Code("rep3", ["Z_0 Z_1", "Z_1 Z_2"], ["X_0 X_1 X_2"], ["Z_0"]) + ) + with patch( + "qdk.ec._analysis.distance_solvers.import_module", wraps=import_module + ) as backend: + distance = getattr(profile, method)(errors="X") + assert distance == 3 + assert profile.is_logical(distance.witness.product) + backend.assert_called_once_with("highspy") + + +@requires_highs +@pytest.mark.parametrize("solver", [None, "highs"]) +@pytest.mark.parametrize("name, code, expected", enumeration_cases) +def test_highs_code_distance_matches_known_value( + name: str, code: StabilizerCode, expected: int, solver: Literal["highs"] | None +) -> None: + distance, witness = code_distance_of(code, solver=solver) + lower, upper, bounded = code_distance_bounds_of(code, solver=solver) + assert distance == lower == upper == expected, name + assert len(witness) == len(bounded) == expected + assert code.is_non_trivial_logical_error(product_of(witness)) + assert code.is_non_trivial_logical_error(product_of(bounded)) + + +def test_distance_and_bounds_default_to_unit_cost_y_errors() -> None: + code = CodeProfile(qc.Code("Y repetition", ["Y_0 Y_1"], ["Y_0"], ["X_0 X_1"])) + for distance in (code.distance(), code.distance_bounds()): + assert distance == 1 + assert len(distance.witness.factors) == 1 + assert distance.witness.product in (Pauli("Y_0"), Pauli("Y_1")) + assert code.is_logical(distance.witness.product) + distance, witness = code_distance_of(code) + assert distance == len(witness) == 1 + lower, upper, witness = code_distance_bounds_of(code) + assert lower == upper == len(witness) == 1 + assert code.distance(errors="XZ") == 2 + assert code.distance_bounds(errors="XZ") == 2 + assert code_distance_of(code, errors="XZ")[0] == 2 + assert code_distance_bounds_of(code, errors="XZ")[:2] == (2, 2) + + +def test_correlated_errors_remain_single_witness_factors() -> None: + code = CodeProfile( + qc.Code( + "C4", + ["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + ["X_0 X_1", "X_0 X_2"], + ["Z_0 Z_2", "Z_0 Z_1"], + ) + ) + errors = [Pauli("X_0 X_1")] + for distance in ( + code.distance(errors=errors), + code.distance_bounds(errors=errors), + ): + assert distance == 1 + assert distance.witness.factors == tuple(errors) + assert distance.witness.product.weight == 2 + assert code_distance_of(code, errors=errors) == (1, errors) + assert code_distance_bounds_of(code, errors=errors) == (1, 1, errors) + + +def product_of(paulis: Iterable[Pauli]) -> Pauli: + return reduce(operator.mul, paulis, Pauli({})) diff --git a/source/qdk_package/tests/ec_tests/validation/test_distance_gadget.py b/source/qdk_package/tests/ec_tests/validation/test_distance_gadget.py new file mode 100644 index 00000000000..09eac9596f2 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_distance_gadget.py @@ -0,0 +1,1153 @@ +"""Distance counts allowed circuit faults, not single-qubit Pauli factors.""" + +from __future__ import annotations + +from copy import deepcopy +from itertools import product +from functools import reduce +from importlib import import_module +from operator import mul +from unittest.mock import patch + +from binar import BitMatrix +import qodec as qc +import pytest + +from qdk.ec import ( + ChannelAction, + FaultEffect, + FaultEvent, + GadgetProfile, + Pauli, + CodeProfile, +) +from qdk.ec._analysis.distance_solvers import EnumerationSolverOptions +from qdk.ec._analysis.propagation.frames import FrameGroup, PauliFrame +from qdk.ec._analysis.propagation.interpreter import propagate_faults +from qdk.ec._profile import _fault_observables +from ec_tests.testing.optional import requires_highs, requires_stim +from ec_tests.testing.qodecs import c4 + + +def _flipped_indices(effect: FaultEffect, field: str) -> set[int]: + return { + reference.segments[1].value + for reference in effect + if reference.segments[0] == qc.Reference.Field(field) + and isinstance(reference.segments[1], qc.Reference.Index) + } + + +def test_fault_observables_collect_outcome_indexed_generators() -> None: + stabilizer = PauliFrame(Pauli("Z_0"), frozenset({1})) + image = PauliFrame(Pauli("X_1"), frozenset({2})) + measurement = PauliFrame(Pauli("Z_2"), frozenset({3})) + action = ChannelAction._create( + FrameGroup([measurement]), FrameGroup([stabilizer]), {Pauli("X_0"): image} + ) + assert _fault_observables(action) == FrameGroup( + [stabilizer, image, PauliFrame(Pauli.identity(), frozenset({3}))] + ) + + +def test_distance_constraints_do_not_use_display_iteration() -> None: + from qdk.ec._distance import _FaultDistanceData + + constraints = [ + "checks[0]", + "readouts[1]", + "out[0].stabilizers[0]", + "out[1].stabilizers[0]", + ] + effects = [FaultEffect([reference]) for reference in constraints] + effects.extend( + [ + FaultEffect(["readouts[0]", "out[0].x[0]", "out[0].z[0]"]), + FaultEffect(constraints), + ] + ) + faults = tuple( + FaultEvent.after(index, Pauli("X_0")) for index in range(len(effects)) + ) + indicators = tuple(frozenset({index}) for index in range(len(effects))) + with ( + patch.object( + FaultEffect, "__iter__", side_effect=AssertionError("display iteration") + ), + patch("qdk.ec._distance.OddCycles") as solver_input, + ): + _FaultDistanceData.of( + faults, effects, indicators, flag_positions=frozenset({1}) + ) + matrix, actual_indicators = solver_input.call_args.args + assert len(matrix) == len(effects) + assert all(len(column) == 1 for column in matrix[:4]) + assert len(set(matrix[:4])) == 4 + assert not matrix[4] + assert matrix[5] == frozenset.union(*matrix[:4]) + assert actual_indicators == indicators + + +def _measurement_gadget(physical: qc.InstructionSet, qubit_count: int) -> qc.Gadget: + qubit = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction("idle", inputs=[qubit], outputs=[qubit]), + ] + code = qc.Code( + "repetition", + [f"Z_{index} Z_{index + 1}" for index in range(qubit_count - 1)], + [" ".join(f"X_{index}" for index in range(qubit_count))], + ["Z_0"], + ) + return qc.Gadget( + qc.Instruction( + "measure", + inputs=[qc.instructions.BlockOperand("repetition")], + action=[qc.actions.Observe(["Z_0"])], + ), + qc.gadgets.Circuit( + physical, + "\n".join( + f"- {mnemonic}: [{index}]" + for mnemonic in ("idle", "M") + for index in range(qubit_count) + ), + format="yaml", + ), + inputs=[ + qc.gadgets.Encoding( + code, support=[str(index) for index in range(qubit_count)] + ) + ], + checks=[ + [ + f"circuit.readouts[{index}]", + f"circuit.readouts[{index + 1}]", + f"in[0].stabilizers[{index}]", + ] + for index in range(qubit_count - 1) + ], + readouts=[["circuit.readouts[0]"]], + ) + + +@requires_stim +def test_measurement_only_distance_includes_readout_flips(rep3_qodec: qc.Qodec) -> None: + physical = rep3_qodec.layers[1].instruction_set + gadget = _measurement_gadget(physical, 3) + gadget.circuit = qc.gadgets.Circuit(physical, "M 0 1 2", format="stim") + profile = GadgetProfile(gadget) + + distance = profile.distance() + witness = distance.witness.factors + assert distance == len(witness) == 3 + bounds = profile.distance_bounds(solver=EnumerationSolverOptions()) + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert lower == upper == len(bounded) == 3 + (effect,) = profile.effects_of([distance.witness.product]) + assert effect == FaultEffect(["readouts[0]"]) + + +@pytest.mark.parametrize( + "observable,error,width", + [("Z_0", "X_0", 1), ("X_0", "Z_0", 1), ("Z_0 Z_1", "X_1", 2)], +) +def test_nondestructive_readout_flip_matches_pauli_sandwich( + observable: str, error: str, width: int +) -> None: + operands = [qc.instructions.BlockOperand("qubit") for _ in range(width)] + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[ + qc.Instruction("wait", inputs=operands, outputs=operands), + qc.Instruction( + "measure", + inputs=operands, + outputs=operands, + action=[qc.actions.Observe([observable])], + ), + ], + ) + targets = ", ".join(str(index) for index in range(width)) + circuit = qc.gadgets.Circuit( + physical, f"- wait: [{targets}]\n- measure: [{targets}]", format="yaml" + ) + code = qc.Code( + "data", + [], + [f"X_{index}" for index in range(width)], + [f"Z_{index}" for index in range(width)], + ) + encoding = qc.gadgets.Encoding(code, support=[str(index) for index in range(width)]) + logical = qc.instructions.BlockOperand("data") + gadget = qc.Gadget( + qc.Instruction( + "measure", + inputs=[logical], + outputs=[logical], + action=[qc.actions.Observe([observable])], + ), + circuit, + inputs=[encoding], + outputs=[encoding], + readouts=[["circuit.readouts[0]"]], + ) + profile = GadgetProfile(gadget) + readout_fault = FaultEvent.after(1, readout_flips=0) + sandwich = FaultEvent.after(0, Pauli(error)) * FaultEvent.after(1, Pauli(error)) + direct_effect, sandwich_effect = profile.effects_of([readout_fault, sandwich]) + assert direct_effect == sandwich_effect + assert direct_effect == FaultEffect(["readouts[0]"]) + distance = profile.distance(faults=[readout_fault]) + assert distance == 1 + assert distance.witness.factors == (readout_fault,) + post_fault = FaultEvent.after(1, Pauli(error)) + (post_effect,) = profile.effects_of([post_fault]) + assert not _flipped_indices(post_effect, "readouts") + assert any( + reference.segments[0] == qc.Reference.Field("out") for reference in post_effect + ) + assert readout_fault in profile._circuit_faults() + assert readout_fault * post_fault in profile._circuit_faults() + assert readout_fault in dict(profile.fault_effects) + + +@requires_stim +def test_readout_noise_uses_call_local_positions_not_reset_rows( + rep3_qodec: qc.Qodec, +) -> None: + circuit = qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, "R 0\nM 0\nR 1\nM 1", format="stim" + ) + profile = GadgetProfile(circuit) + first, second, both = profile.effects_of( + [ + FaultEvent.after(1, readout_flips=0), + FaultEvent.after(3, readout_flips=[0]), + FaultEvent.after(1, readout_flips=0) * FaultEvent.after(3, readout_flips=0), + ] + ) + assert first == FaultEffect(["checks[0]", "readouts[0]"]) + assert second == FaultEffect(["checks[1]", "readouts[1]"]) + assert both == first ^ second + for index in (-1, 1): + with pytest.raises(ValueError, match="fault readout index"): + profile.effects_of([FaultEvent.after(3, readout_flips=index)]) + with pytest.raises(ValueError, match="call 0 with 0 readouts"): + profile.effects_of([FaultEvent.after(0, readout_flips=0)]) + for call in (-1, 4): + with pytest.raises(ValueError, match="fault call index"): + profile.effects_of([FaultEvent.after(call, readout_flips=0)]) + + +@pytest.mark.parametrize( + "readout_flips,expected", [(0, {1}), ([1], {2}), ([0, 1], {1, 2}), ([0, 0], {1})] +) +def test_local_readouts_can_address_repeated_measurements_in_one_call( + rep3_qodec: qc.Qodec, readout_flips: int | list[int], expected: set[int] +) -> None: + physical = rep3_qodec.layers[1].instruction_set + qubit = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction( + "twice", + inputs=[qubit], + outputs=[qubit], + action=[qc.actions.Observe(["Z_0"]), qc.actions.Observe(["Z_0"])], + ), + ] + circuit = qc.gadgets.Circuit( + physical, "- R: [0]\n- M: [0]\n- twice: [1]", format="yaml" + ) + profile = GadgetProfile(circuit) + fault = FaultEvent.after(2, readout_flips=readout_flips) + (effect,) = profile.effects_of([fault]) + assert _flipped_indices(effect, "readouts") == expected + assert not any( + reference.segments[0] == qc.Reference.Field("out") for reference in effect + ) + assert fault in profile._circuit_faults() + with pytest.raises(ValueError, match="call 2 with 2 readouts"): + profile.effects_of([FaultEvent.after(2, readout_flips=2)]) + + +def test_one_call_can_corrupt_several_readouts_as_one_fault( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + gadget = _measurement_gadget(physical, 2) + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction( + "measure_pair", + inputs=[ + qc.instructions.BlockOperand("qubit"), + qc.instructions.BlockOperand("qubit"), + ], + action=[qc.actions.Observe(["Z_0", "Z_1"])], + ), + ] + gadget.circuit = qc.gadgets.Circuit( + physical, "- measure_pair: [0, 1]", format="yaml" + ) + profile = GadgetProfile(gadget) + fault = FaultEvent.after(0, readout_flips=[0, 1]) + assert fault in profile._circuit_faults() + distance = profile.distance() + witness = distance.witness.factors + assert distance == len(witness) == 1 + distance = profile.distance(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + (effect,) = profile.effects_of(witness) + assert effect == FaultEffect(["readouts[0]"]) + + +@requires_stim +def test_c4_z_measurement_has_distance_two_with_readout_noise() -> None: + gadget = c4().layers[0].gadgets["measure_zz"] + gadget.readouts = [ + [*readout.equation, f"in[0].z[{index}]"] + for index, readout in enumerate(gadget.readouts) + ] + profile = GadgetProfile(gadget) + distance = profile.distance() + witness = distance.witness.factors + assert distance == len(witness) == 2 + (effect,) = profile.effects_of([distance.witness.product]) + assert not _flipped_indices(effect, "checks") and _flipped_indices( + effect, "readouts" + ) + + +@requires_highs +def test_highs_gadget_distance_returns_replayable_witness(rep3_qodec: qc.Qodec) -> None: + gadget = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 3) + profile = GadgetProfile(gadget) + distance = profile.distance(solver="highs") + witness = distance.witness.factors + bounds = profile.distance_bounds(solver="highs") + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert distance == lower == upper == 3 + for factors in (witness, bounded): + assert len(factors) == 3 + (effect,) = profile.effects_of([reduce(mul, factors, FaultEvent({}))]) + assert effect == FaultEffect(["readouts[0]"]) + with pytest.raises(RuntimeError, match="exact distance"): + profile.distance(solver="highs", upper_bound=2) + + +@requires_stim +def test_preparation_distance_uses_the_prepared_logical_state( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["prepare_z"] + profile = GadgetProfile(gadget) + assert profile.objective is not None + assert profile.action.is_equivalent_to(profile.objective) + last = len(gadget.circuit.calls()) - 1 + harmless = FaultEvent.after(last, Pauli("Z_0")) + harmful = FaultEvent.after(last, Pauli("X_0 X_1 X_2")) + assert profile.distance(faults=[harmless]).lower_bound is None + assert profile.distance_bounds(faults=[harmless]).lower_bound is None + distance = profile.distance(faults=[harmful]) + assert distance == 1 + assert distance.witness.factors == (harmful,) + distance = profile.distance_bounds(faults=[harmful]) + assert distance == 1 + assert distance.witness.factors == (harmful,) + distance = profile.distance() + witness = distance.witness.factors + bounds = profile.distance_bounds(solver=EnumerationSolverOptions()) + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert distance == lower == upper == 3 + assert len(witness) == len(bounded) == 3 + + +@pytest.mark.parametrize("bare", [False, True]) +@pytest.mark.parametrize("bounded", [False, True]) +def test_distance_propagates_faults_once_without_direct_readout_solves( + rep3_qodec: qc.Qodec, bare: bool, bounded: bool +) -> None: + gadget = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 3) + profile = GadgetProfile(gadget.circuit if bare else gadget) + with ( + patch( + "qdk.ec._faults.propagate_faults", wraps=propagate_faults + ) as gadget_propagation, + patch( + "qdk.ec._profile.propagate_faults", wraps=propagate_faults + ) as circuit_propagation, + patch("qdk.ec._faults.BitMatrix", wraps=BitMatrix) as readout_matrix, + ): + if bounded: + profile.distance_bounds(solver=EnumerationSolverOptions()) + else: + profile.distance() + assert gadget_propagation.call_count + circuit_propagation.call_count == 1 + readout_matrix.zeros.assert_not_called() + + +def test_readout_dependencies_are_reduced_once_for_all_faults( + rep3_qodec: qc.Qodec, +) -> None: + base = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 1) + gadget = qc.Gadget( + qc.Instruction( + "measure", + inputs=list(base.implements.inputs), + flags=["reject"], + action=list(base.implements.action), + ), + base.circuit, + inputs=base.inputs, + readouts=[["readouts[1]"], ["circuit.readouts[0]"]], + ) + profile = GadgetProfile(gadget) + faults = [ + FaultEvent.after(0, Pauli({0: character})) for character in ("X", "Y", "Z") + ] + with patch("qdk.ec._faults.BitMatrix", wraps=BitMatrix) as readout_matrix: + effects = profile.effects_of(faults) + readout_matrix.zeros.assert_called_once_with(2, 2 + len(faults)) + assert effects == ( + FaultEffect(["readouts[0:2]"]), + FaultEffect(["readouts[0:2]"]), + FaultEffect(), + ) + assert profile.distance(faults=faults).value is None + + +@requires_stim +@pytest.mark.parametrize("preserves_input", [False, True]) +def test_action_signs_combine_measurement_and_output_faults( + rep3_qodec: qc.Qodec, preserves_input: bool +) -> None: + physical = rep3_qodec.layers[1].instruction_set + qubit = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction( + "H", + inputs=[qubit], + outputs=[qubit], + action=[qc.actions.Clifford({"X_0": "Z_0", "Z_0": "X_0"})], + ), + ] + code = qc.Code("qubit", [], ["X_0"], ["Z_0"]) + if preserves_input: + source = "R 1 2\nH 1\nCX 1 2\nCX 0 1\nH 0\nM 0\nR 3\nM 1" + measured_qubit, output_qubit = 1, 2 + else: + source = "R 0 1\nH 0\nCX 0 1\nM 0" + measured_qubit, output_qubit = 0, 1 + gadget = qc.Gadget( + qc.Instruction( + "transfer" if preserves_input else "prepare", + inputs=[qubit] if preserves_input else [], + outputs=[qubit], + ), + qc.gadgets.Circuit(physical, source, format="stim"), + inputs=[qc.gadgets.Encoding(code, support=["0"])] if preserves_input else [], + outputs=[qc.gadgets.Encoding(code, support=[str(output_qubit)])], + ) + profile = GadgetProfile(gadget) + before_measurement = ( + next( + index + for index, call in enumerate(gadget.circuit.calls()) + if call.mnemonic == "M" + ) + - 1 + ) + record_fault = FaultEvent.after(before_measurement, Pauli({measured_qubit: "X"})) + measurement_call = next( + index + for index, call in enumerate(gadget.circuit.calls()) + if call.mnemonic == "M" and call.operands == [measured_qubit] + ) + readout_fault = FaultEvent.after(measurement_call, readout_flips=0) + output_fault = FaultEvent.after(before_measurement, Pauli({output_qubit: "X"})) + assert not gadget.readouts and not gadget.checks + for fault in (record_fault, readout_fault, output_fault): + distance = profile.distance(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + distance = profile.distance_bounds(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + combined = record_fault * output_fault + assert profile.distance(faults=[combined]).lower_bound is None + assert profile.distance_bounds(faults=[combined]).lower_bound is None + combined_readout = readout_fault * output_fault + assert profile.distance(faults=[combined_readout]).lower_bound is None + assert profile.distance_bounds(faults=[combined_readout]).lower_bound is None + + +def test_action_probes_follow_output_encoding_order(rep3_qodec: qc.Qodec) -> None: + physical = rep3_qodec.layers[1].instruction_set + qubit = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction("idle", inputs=[qubit], outputs=[qubit]), + ] + code = qc.Code("qubit", [], ["X_0"], ["Z_0"]) + gadget = qc.Gadget( + qc.Instruction("prepare_and_preserve", inputs=[qubit], outputs=[qubit, qubit]), + qc.gadgets.Circuit(physical, "- idle: [2]\n- R: [5]", format="yaml"), + inputs=[qc.gadgets.Encoding(code, support=["2"])], + outputs=[ + qc.gadgets.Encoding(code, support=["5"]), + qc.gadgets.Encoding(code, support=["2"]), + ], + ) + profile = GadgetProfile(gadget) + harmless = FaultEvent.after(1, Pauli("Z_5")) + harmful = FaultEvent.after(1, Pauli("Z_2")) + assert profile.distance(faults=[harmless]).lower_bound is None + assert profile.distance_bounds(faults=[harmless]).lower_bound is None + distance = profile.distance(faults=[harmful]) + assert distance == 1 + assert distance.witness.factors == (harmful,) + distance = profile.distance_bounds(faults=[harmful]) + assert distance == 1 + assert distance.witness.factors == (harmful,) + + +def test_profile_keeps_readout_dependencies_in_snapshot(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.readouts = [["readouts[0]", "circuit.readouts[0]"]] + profile = GadgetProfile(gadget) + assert isinstance(profile._target, qc.Gadget) + assert profile._target.readouts == gadget.readouts + + +@requires_stim +def test_fault_measurement_rows_allow_interleaved_resets(rep3_qodec: qc.Qodec) -> None: + circuit = qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, + "R 0\nM 0\nR 1\nM 1", + format="stim", + ) + gadget = qc.Gadget( + qc.Instruction("measure", action=[qc.actions.Observe(["Z_0", "Z_1"])]), + circuit, + readouts=[["circuit.readouts[0]"], ["circuit.readouts[1]"]], + ) + (effect,) = GadgetProfile(gadget).effects_of([FaultEvent.after(0, Pauli("X_0"))]) + assert effect == FaultEffect(["readouts[0]"]) + + +def test_default_circuit_faults_cover_each_call_support() -> None: + operand = qc.instructions.BlockOperand("qubit") + instruction_set = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[ + qc.Instruction("one", inputs=[operand], outputs=[operand]), + qc.Instruction( + "two", inputs=[operand, operand], outputs=[operand, operand] + ), + qc.Instruction("tick"), + ], + ) + circuit = qc.gadgets.Circuit( + instruction_set, "- one: [2]\n- two: [2, 5]\n- tick: []\n", format="yaml" + ) + faults = GadgetProfile(circuit)._circuit_faults() + assert len(faults) == 18 + assert set(faults[:3]) == { + FaultEvent.after(0, Pauli({2: character})) for character in ("X", "Y", "Z") + } + assert set(faults[3:]) == { + FaultEvent.after( + 1, + Pauli( + { + qubit: character + for qubit, character in zip((2, 5), characters) + if character != "I" + } + ), + ) + for characters in product(("I", "X", "Y", "Z"), repeat=2) + if characters != ("I", "I") + } + + +def test_circuit_faults_use_qubit_support_not_operand_count() -> None: + operand = qc.instructions.BlockOperand("pair") + instruction_set = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("pair", encodes=2)], + instructions=[qc.Instruction("idle", inputs=[operand], outputs=[operand])], + ) + profile = GadgetProfile( + qc.gadgets.Circuit(instruction_set, "- idle: [data]\n", format="yaml") + ) + faults = profile._circuit_faults() + assert len(faults) == 15 + assert set(faults) == { + FaultEvent.after( + 0, + Pauli( + { + qubit: character + for qubit, character in enumerate(characters) + if character != "I" + } + ), + ) + for characters in product(("I", "X", "Y", "Z"), repeat=2) + if characters != ("I", "I") + } + + +def test_default_correlated_fault_is_cheaper_than_single_qubit_events( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction("pair", inputs=[operand, operand], outputs=[operand, operand]), + ] + gadget = _measurement_gadget(physical, 2) + gadget.circuit = qc.gadgets.Circuit( + physical, "- pair: [0, 1]\n- M: [0]\n- M: [1]\n", format="yaml" + ) + profile = GadgetProfile(gadget) + single_qubit = [ + FaultEvent.after(0, Pauli({qubit: basis})) + for qubit in (0, 1) + for basis in ("X", "Y", "Z") + ] + distance = profile.distance() + witness = distance.witness.factors + assert distance == 1 and len(witness) == 1 + assert witness[0].weight == 2 + assert witness[0] in { + FaultEvent.after(0, Pauli({0: control, 1: target})) + for control, target in product(("X", "Y"), repeat=2) + } + assert profile.distance(faults=single_qubit).value == 2 + assert profile.distance_bounds() == 1 + assert profile.distance_bounds(faults=single_qubit) == 2 + (effect,) = profile.effects_of(witness) + assert effect == FaultEffect(["readouts[0]"]) + + +def test_profile_distance_counts_correlated_gate_error_once( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + code = qc.Code("pair", ["Z_0 Z_1"], ["X_0 X_1"], ["Z_0"]) + operand = qc.instructions.BlockOperand("pair") + qubit = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction("pair", inputs=[qubit, qubit], outputs=[qubit, qubit]), + ] + gadget = qc.Gadget( + qc.Instruction("idle", inputs=[operand], outputs=[operand]), + qc.gadgets.Circuit(physical, "- pair: [0, 1]", format="yaml"), + inputs=[qc.gadgets.Encoding(code, support=["0", "1"])], + outputs=[qc.gadgets.Encoding(code, support=["0", "1"])], + ) + profile = GadgetProfile(gadget) + fault = FaultEvent.after(0, Pauli("X_0 X_1")) + distance = profile.distance(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + distance = profile.distance_bounds(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + assert fault.weight == 2 + distance = profile.distance() + witness = distance.witness.factors + bounds = profile.distance_bounds() + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert distance == lower == upper == 1 + assert len(witness) == len(bounded) == 1 + + +def test_distance_finds_combinations_and_returns_replayable_faults( + rep3_qodec: qc.Qodec, +) -> None: + gadget = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 2) + faults = [FaultEvent.after(0, Pauli("X_0")), FaultEvent.after(1, Pauli("X_1"))] + profile = GadgetProfile(gadget) + distance = profile.distance(faults=faults) + witness = distance.witness.factors + assert distance == 2 and witness == tuple(faults) + distance = profile.distance_bounds(faults=faults) + assert distance == 2 + assert distance.witness.factors == tuple(faults) + distance = profile.distance_bounds(faults=faults, solver=EnumerationSolverOptions()) + assert distance == 2 + assert distance.witness.factors == tuple(faults) + (combined,) = profile.effects_of([witness[0] * witness[1]]) + assert combined == FaultEffect(["readouts[0]"]) + event = witness[0] * witness[1] + assert event == FaultEvent({0: Pauli("X_0"), 1: Pauli("X_1")}) + distance = profile.distance(faults=[event]) + assert distance == 1 + assert distance.witness.factors == (event,) + distance = profile.distance_bounds(faults=[event]) + assert distance == 1 + assert distance.witness.factors == (event,) + assert profile.distance(faults=[]).lower_bound is None + assert profile.distance_bounds(faults=[]).lower_bound is None + + +@requires_stim +def test_flag_alone_is_not_a_logical_failure( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + fault = FaultEvent.after(0, Pauli("X_0")) + circuit = qc.gadgets.Circuit(physical, "R 0\nM 0", format="stim") + flag_only = qc.Gadget( + qc.Instruction("flag", flags=["reject"]), + circuit, + readouts=[{"reject": ["circuit.readouts[0]"]}], + ) + assert GadgetProfile(flag_only).distance(faults=[fault]).lower_bound is None + assert GadgetProfile(flag_only).distance_bounds(faults=[fault]).lower_bound is None + + +def _flagged_measurement_gadget(physical: qc.InstructionSet) -> qc.Gadget: + base = _measurement_gadget(physical, 1) + return qc.Gadget( + qc.Instruction( + "measure", + inputs=list(base.implements.inputs), + flags=["reject_first", "reject_second"], + action=[qc.actions.Observe(["Z_0"])], + ), + qc.gadgets.Circuit( + physical, + "- R: [1]\n- R: [2]\n- idle: [0]\n- M: [0]\n- M: [1]\n- M: [2]", + format="yaml", + ), + inputs=base.inputs, + readouts=[ + ["circuit.readouts[0]", "in[0].z[0]"], + {"reject_first": ["circuit.readouts[1]"]}, + {"reject_second": ["circuit.readouts[2]"]}, + ], + ) + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_distance_requires_each_flag_zero_in_the_combined_fault( + rep3_qodec: qc.Qodec, method: str +) -> None: + gadget = _flagged_measurement_gadget(rep3_qodec.layers[1].instruction_set) + profile = GadgetProfile(gadget) + search = getattr(profile, method) + logical_fault = FaultEvent.after(2, Pauli("X_0 X_1 X_2")) + first_mask = FaultEvent.after(4, readout_flips=0) + second_mask = FaultEvent.after(5, readout_flips=0) + faults = [logical_fault, first_mask, second_mask] + (effect,) = profile.effects_of([logical_fault]) + assert effect == FaultEffect(["readouts[0:3]"]) + for incomplete in ([logical_fault], faults[:2], [logical_fault, second_mask]): + assert search(faults=incomplete, solver="enumeration").lower_bound is None + + distance = search(faults=faults, solver="enumeration") + + assert distance == 3 + assert distance.witness.factors == tuple(faults) + for witness in distance.witnesses: + (combined,) = profile.effects_of([witness.product]) + assert combined == FaultEffect(["readouts[0]"]) + assert not gadget.checks + assert search(faults=[distance.witness.product], solver="enumeration") == 1 + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_distance_allows_two_faults_to_cancel_flags( + rep3_qodec: qc.Qodec, method: str +) -> None: + gadget = _flagged_measurement_gadget(rep3_qodec.layers[1].instruction_set) + profile = GadgetProfile(gadget) + faults = [ + FaultEvent.after(2, Pauli("X_0 X_1 X_2")), + FaultEvent.after(2, Pauli("X_1 X_2")), + ] + assert all( + "readouts[1]" in effect and "readouts[2]" in effect + for effect in profile.effects_of(faults) + ) + + distance = getattr(profile, method)(faults=faults, solver="enumeration") + + assert distance == 2 + (combined,) = profile.effects_of([distance.witness.product]) + assert combined == FaultEffect(["readouts[0]"]) + + +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +@pytest.mark.parametrize("bound_flags", [0, 1]) +def test_distance_requires_all_flag_equations_even_without_faults( + rep3_qodec: qc.Qodec, method: str, bound_flags: int +) -> None: + gadget = _flagged_measurement_gadget(rep3_qodec.layers[1].instruction_set) + gadget.readouts = [ + list(readout.equation) for readout in gadget.readouts[: 1 + bound_flags] + ] + + with pytest.raises(ValueError, match="every flag readout to be bound"): + getattr(GadgetProfile(gadget), method)(faults=[], solver="enumeration") + + +@requires_stim +def test_output_codespace_is_required_without_adding_declared_checks( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + code = qc.Code("pair", ["Z_0 Z_1"], ["X_0 X_1"], ["Z_0"]) + encoding = qc.gadgets.Encoding(code, support=["0", "1"]) + gadget = qc.Gadget( + qc.Instruction("idle", outputs=[qc.instructions.BlockOperand("pair")]), + qc.gadgets.Circuit(physical, "R 0 1", format="stim"), + outputs=[encoding], + ) + fault = FaultEvent.after(1, Pauli("X_0")) + profile = GadgetProfile(gadget) + (effect,) = profile.effects_of([fault]) + assert effect == FaultEffect(["out[0].z[0]", "out[0].stabilizers[0]"]) + assert gadget.resolve("out[0].code.z[0]").value(str) == "Z_0" + for reference in effect: + gadget.resolve(reference) + assert profile.distance(faults=[fault]).lower_bound is None + assert profile.distance_bounds(faults=[fault]).lower_bound is None + gadget.checks = [["out[0].stabilizers[0]"]] + profile = GadgetProfile(gadget) + (effect,) = profile.effects_of([fault]) + assert effect == FaultEffect(["checks[0]", "out[0].z[0]", "out[0].stabilizers[0]"]) + assert gadget.resolve(qc.Reference("checks[0]")).value(tuple) == gadget.checks[0] + with pytest.raises(ValueError): + gadget.checks = [[qc.Reference("checks[0]")]] + assert profile.distance(faults=[fault]).lower_bound is None + assert profile.distance_bounds(faults=[fault]).lower_bound is None + gadget.checks = [["out[0].stabilizers[0:1]", "out[0].stabilizers[00]"]] + assert GadgetProfile(gadget).distance(faults=[fault]).lower_bound is None + + +def test_readout_free_distance_requires_combined_logical_residual() -> None: + operand = qc.instructions.BlockOperand("qubit") + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[qc.Instruction("idle", inputs=[operand], outputs=[operand])], + ) + code = qc.Code( + "C4", + ["X_0 X_1 X_2 X_3", "Z_0 Z_1 Z_2 Z_3"], + ["X_0 X_1", "X_0 X_2"], + ["Z_0 Z_2", "Z_0 Z_1"], + ) + encoding = qc.gadgets.Encoding(code, support=["0", "1", "2", "3"]) + logical = qc.instructions.BlockOperand("C4") + gadget = qc.Gadget( + qc.Instruction("idle", inputs=[logical], outputs=[logical]), + qc.gadgets.Circuit( + physical, + "- idle: [0]\n- idle: [1]\n- idle: [2]\n- idle: [3]\n", + format="yaml", + ), + inputs=[encoding], + outputs=[encoding], + ) + profile = GadgetProfile(gadget) + faults = [FaultEvent.after(index, Pauli({index: "X"})) for index in (0, 1)] + assert not gadget.readouts and not gadget.checks + assert all( + not _flipped_indices(effect, "checks") for effect in profile.effects_of(faults) + ) + for fault in faults: + assert profile.distance(faults=[fault]).lower_bound is None + assert profile.distance_bounds(faults=[fault]).lower_bound is None + distance = profile.distance(faults=faults) + witness = distance.witness.factors + bounds = profile.distance_bounds(faults=faults) + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert distance == lower == upper == 2 + assert witness == bounded == tuple(faults) + residual = reduce( + mul, + (Pauli({faults.index(fault): "X"}) for fault in witness), + Pauli.identity(), + ) + assert CodeProfile(code).is_logical(residual) + logical_fault = FaultEvent.after(0, Pauli("X_0 X_1")) + distance = profile.distance(faults=[logical_fault]) + assert distance == 1 + assert distance.witness.factors == (logical_fault,) + stabilizer_fault = FaultEvent.after(0, Pauli("X_0 X_1 X_2 X_3")) + assert profile.distance(faults=[stabilizer_fault]).lower_bound is None + assert profile.distance_bounds(faults=[stabilizer_fault]).lower_bound is None + assert profile.distance().value == 2 + + +@requires_stim +def test_output_syndromes_on_different_blocks_do_not_cancel() -> None: + gadget = c4().layers[0].gadgets["transversal_cx"] + gadget.checks = [] + last = len(gadget.circuit.calls()) - 1 + faults = [FaultEvent.after(last, Pauli({qubit: "X"})) for qubit in (0, 4)] + profile = GadgetProfile(gadget) + effects = profile.effects_of(faults) + assert all(not _flipped_indices(effect, "checks") for effect in effects) + assert "out[0].z[0]" in effects[0] and "out[1].z[0]" in effects[1] + assert "out[0].stabilizers[1]" in effects[0] + assert "out[1].stabilizers[1]" in effects[1] + assert profile.distance(faults=faults).lower_bound is None + assert profile.distance_bounds(faults=faults).lower_bound is None + + +@requires_stim +def test_declared_checks_still_exclude_a_codespace_preserving_error() -> None: + gadget = c4().layers[0].gadgets["idle"] + last = len(gadget.circuit.calls()) - 1 + fault = FaultEvent.after(last, Pauli("X_0 X_1")) + profile = GadgetProfile(gadget) + distance = profile.distance(faults=[fault]) + assert distance == 1 + assert distance.witness.factors == (fault,) + gadget.checks = [*gadget.checks, ["out[0].z[0]", "in[0].z[0]"]] + profile = GadgetProfile(gadget) + (effect,) = profile.effects_of([fault]) + assert _flipped_indices(effect, "checks") == {len(gadget.checks) - 1} + assert profile.distance(faults=[fault]).lower_bound is None + assert profile.distance_bounds(faults=[fault]).lower_bound is None + + +def test_readout_dependencies_are_solved_for_fault_effects( + rep3_qodec: qc.Qodec, +) -> None: + base = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 1) + gadget = qc.Gadget( + qc.Instruction( + "measure", + inputs=list(base.implements.inputs), + flags=["reject"], + action=[qc.actions.Observe(["Z_0"])], + ), + base.circuit, + inputs=base.inputs, + readouts=[["readouts[1]"], ["circuit.readouts[0]"]], + ) + fault = FaultEvent.after(0, Pauli("X_0")) + (effect,) = GadgetProfile(gadget).effects_of([fault]) + assert effect == FaultEffect(["readouts[0:2]"]) + distance = GadgetProfile(gadget).distance(faults=[fault]) + assert distance.value is None + assert GadgetProfile(gadget).distance_bounds(faults=[fault]).value is None + gadget.readouts = [["readouts[0]"], []] + for method in ("distance", "distance_bounds"): + with pytest.raises(ValueError, match="uniquely determine"): + getattr(GadgetProfile(gadget), method)(faults=[fault]) + gadget.readouts = [] + with pytest.raises(ValueError, match="every logical measurement"): + GadgetProfile(gadget).distance(faults=[fault]) + + +@requires_stim +def test_identical_logical_effects_cancel_instead_of_forming_a_failure( + rep3_qodec: qc.Qodec, +) -> None: + gadget = qc.Gadget( + qc.Instruction("measure", action=[qc.actions.Observe(["Z_0"])]), + qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, "R 0 1\nM 0 1", format="stim" + ), + checks=[["circuit.readouts[0]", "circuit.readouts[1]"]], + readouts=[["circuit.readouts[0]", "circuit.readouts[1]"]], + ) + faults = [FaultEvent.after(0, Pauli("X_0")), FaultEvent.after(1, Pauli("X_1"))] + profile = GadgetProfile(gadget) + assert profile.distance(faults=faults).lower_bound is None + assert profile.distance_bounds(faults=faults).lower_bound is None + (effect,) = profile.effects_of([faults[0] * faults[1]]) + assert not effect + + +@requires_stim +@pytest.mark.parametrize( + "reference", ["circuit.readouts[9]", "readouts[9]", "in[0].z[0]"] +) +def test_distance_rejects_invalid_check_references( + rep3_qodec: qc.Qodec, reference: str +) -> None: + gadget = qc.Gadget( + qc.Instruction("measure", action=[qc.actions.Observe(["Z_0"])]), + qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, "R 0\nM 0", format="stim" + ), + checks=[[reference]], + readouts=[["circuit.readouts[0]"]], + ) + for method in ( + GadgetProfile(gadget).distance, + GadgetProfile(gadget).distance_bounds, + ): + with pytest.raises(ValueError, match="out of bounds"): + method(faults=[FaultEvent.after(0, Pauli("X_0"))]) + + +@requires_highs +@pytest.mark.parametrize("method", ["distance", "distance_bounds"]) +def test_gadget_profile_defaults_to_highs(rep3_qodec: qc.Qodec, method: str) -> None: + profile = GadgetProfile( + _measurement_gadget(rep3_qodec.layers[1].instruction_set, 3) + ) + with patch( + "qdk.ec._analysis.distance_solvers.import_module", wraps=import_module + ) as backend: + distance = getattr(profile, method)() + assert distance == 3 + (effect,) = profile.effects_of([distance.witness.product]) + assert effect == FaultEffect(["readouts[0]"]) + backend.assert_called_once_with("highspy") + + +@requires_highs +def test_distance_three_matches_bounds_and_cutoff(rep3_qodec: qc.Qodec) -> None: + gadget = _measurement_gadget(rep3_qodec.layers[1].instruction_set, 3) + faults = [FaultEvent.after(index, Pauli({index: "X"})) for index in range(3)] + profile = GadgetProfile(gadget) + for selected in (None, faults): + distance = profile.distance(faults=selected) + witness = distance.witness.factors + bounds = profile.distance_bounds(faults=selected) + lower, upper = bounds.lower_bound, bounds.upper_bound + bounded = bounds.witness.factors + assert distance == upper == 3 and lower <= 3 + assert len(witness) == len(bounded) == 3 + for factors in (witness, bounded): + combined = reduce(mul, factors, FaultEvent({})) + (effect,) = profile.effects_of([combined]) + assert effect == FaultEffect(["readouts[0]"]) + with pytest.raises(RuntimeError, match="exact distance"): + profile.distance(faults=selected, upper_bound=2) + distance = profile.distance_bounds(faults=faults, solver=EnumerationSolverOptions()) + assert distance == 3 + assert distance.witness.factors == tuple(faults) + + +@requires_stim +def test_bare_circuit_distance_uses_discovered_checks(rep3_qodec: qc.Qodec) -> None: + physical = rep3_qodec.layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction("idle", inputs=[operand], outputs=[operand]), + ] + circuit = qc.gadgets.Circuit(physical, "- idle: [5]\n", format="yaml") + profile = GadgetProfile(circuit) + distance = profile.distance() + witness = distance.witness.factors + assert distance == 1 and len(witness) == 1 + (effect,) = profile.effects_of(witness) + assert profile._circuit_outputs == tuple(range(6)) + assert effect and all(reference.path.startswith("out[5].") for reference in effect) + measured = GadgetProfile(qc.gadgets.Circuit(physical, "R 0\nM 0", format="stim")) + assert measured.checks + assert measured.distance().lower_bound is None + assert measured.distance_bounds().lower_bound is None + + +@requires_stim +@pytest.mark.parametrize("bare", [False, True]) +def test_fault_effect_xor_matches_replay_and_uses_snapshot( + idle_gadget: qc.Gadget, bare: bool +) -> None: + gadget = deepcopy(idle_gadget) + profile = GadgetProfile(gadget.circuit if bare else gadget) + last = len(gadget.circuit.calls()) - 1 + faults = [ + FaultEvent(), + FaultEvent.after(0, Pauli("X_0")), + FaultEvent.after(last, Pauli("Z_1")), + FaultEvent.after(0, Pauli("Y_0")), + ] + effects = profile.effects_of(faults) + pairs = list(product(range(len(faults)), repeat=2)) + combined = profile.effects_of( + [faults[left] * faults[right] for left, right in pairs] + ) + assert combined == tuple(effects[left] ^ effects[right] for left, right in pairs) + if not bare: + for effect in effects: + for reference in effect: + gadget.resolve(reference) + gadget.circuit.source = "invalid edited source" + assert profile.effects_of(faults) == effects + + +def test_bare_circuit_effect_output_positions_skip_prepared_slots( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + _measurement_gadget(physical, 1) + circuit = qc.gadgets.Circuit(physical, "- R: [0]\n- idle: [5]", format="yaml") + profile = GadgetProfile(circuit) + assert profile._circuit_outputs == (1, 2, 3, 4, 5) + effects = profile.effects_of( + [ + FaultEvent.after(1, Pauli("X_5")), + FaultEvent.after(1, Pauli("Z_5")), + FaultEvent.after(1, Pauli("Y_5")), + ] + ) + assert effects == ( + FaultEffect(["out[4].z[0]"]), + FaultEffect(["out[4].x[0]"]), + FaultEffect(["out[4].x[0]", "out[4].z[0]"]), + ) + + +@requires_stim +@pytest.mark.parametrize("location", [-1, 2]) +def test_invalid_fault_location_raises(rep3_qodec: qc.Qodec, location: int) -> None: + profile = GadgetProfile( + qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, "R 0\nM 0", format="stim" + ) + ) + for method in (profile.distance, profile.distance_bounds): + with pytest.raises(ValueError, match="fault call index"): + method(faults=[FaultEvent.after(location, Pauli("X_0"))]) + + +def test_conditional_circuit_rejected_instead_of_ignored() -> None: + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "conditional", + inputs=[operand], + outputs=[operand], + parameters=[qc.instructions.Parameter("enabled", "bit")], + action=[qc.actions.Pauli("X_0", condition=qc.actions.Condition(["enabled"]))], + ) + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[instruction], + ) + profile = GadgetProfile( + qc.gadgets.Circuit(physical, "- conditional: [0, enabled: 1]\n", format="yaml") + ) + for method in (profile.distance, profile.distance_bounds): + with pytest.raises(NotImplementedError, match="conditional or selected"): + method() diff --git a/source/qdk_package/tests/ec_tests/validation/test_distance_odd_cycle.py b/source/qdk_package/tests/ec_tests/validation/test_distance_odd_cycle.py new file mode 100644 index 00000000000..7f774833cce --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_distance_odd_cycle.py @@ -0,0 +1,401 @@ +"""Tests for the ``OddCycles`` distance engine and its solver backends.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from itertools import count +from random import Random +from importlib import import_module +from typing import cast + +import pytest + +from qdk.ec._analysis.distance_solvers import ( + CustomBoundsSolver, + CustomExactSolver, + EnumerationSolverOptions, + HighsSolverOptions, + MwpfSolverOptions, + Solver, +) +from qdk.ec._analysis.odd_cycles import OddCycles, unique_non_empty_elements_of +from ec_tests.testing.optional import requires_highs, requires_mwpf + + +class PanicException(BaseException): + pass + + +@requires_mwpf +@pytest.mark.parametrize("stage", ["solve", "subgraph", "subgraph_range"]) +def test_mwpf_failures_do_not_certify_remaining_logical_searches(stage: str) -> None: + data = OddCycles( + [ + frozenset(column) + for column in ({0, 1}, {1, 2}, {2, 0}, {3, 4}, {4, 5}, {5, 6}, {6, 3}) + ], + [frozenset(column) for column in ({0}, set(), set(), {1}, set(), set(), set())], + ) + assert data.shortest(EnumerationSolverOptions())[0] == 3 + failed = MagicMock() + failed.subgraph.return_value = [0, 1, 2] + getattr(failed, stage).side_effect = PanicException("backend failure") + completed = MagicMock() + completed.subgraph.return_value = [3, 4, 5, 6] + completed.subgraph_range.return_value = ( + [], + SimpleNamespace(lower=SimpleNamespace(float=lambda: 4.0)), + ) + factory = MagicMock(side_effect=[failed, completed]) + with patch( + "qdk.ec._analysis.distance_solvers._mwpf_solver_class", return_value=factory + ): + with pytest.raises(RuntimeError, match="MWPF"): + data.bounds(solver=MwpfSolverOptions()) + assert factory.call_count == 1 + + +@requires_mwpf +@pytest.mark.parametrize("witness", [[], [0], [0, 1, 9], [0, 1, 2, 2]]) +def test_mwpf_invalid_witness_raises(witness: list[int]) -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + solver = MagicMock() + solver.subgraph.return_value = witness + with patch( + "qdk.ec._analysis.distance_solvers._mwpf_solver_class", + return_value=MagicMock(return_value=solver), + ): + with pytest.raises(RuntimeError, match="invalid witness"): + data.bounds(solver=MwpfSolverOptions()) + + +@requires_mwpf +@pytest.mark.parametrize("lower", [float("nan"), float("inf"), -1.0, 4.0]) +def test_mwpf_invalid_lower_bound_raises(lower: float) -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + solver = MagicMock() + solver.subgraph.return_value = [0, 1, 2] + solver.subgraph_range.return_value = ( + [], + SimpleNamespace(lower=SimpleNamespace(float=lambda: lower)), + ) + with patch( + "qdk.ec._analysis.distance_solvers._mwpf_solver_class", + return_value=MagicMock(return_value=solver), + ): + with pytest.raises(RuntimeError, match="lower bound"): + data.bounds(solver=MwpfSolverOptions()) + + +@requires_mwpf +def test_mwpf_keeps_reachable_searches_alongside_impossible_ones() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0, 1}), frozenset(), frozenset({0})], + ) + lower, upper, witness = data.bounds(solver=MwpfSolverOptions()) + assert lower <= upper == 3 + assert set(witness) == {0, 1, 2} + + +@pytest.mark.parametrize("indicators", [[{0}, set()], [{0}, {0}]]) +def test_mwpf_skips_only_provably_unreachable_indicators( + indicators: list[set[int]], +) -> None: + data = OddCycles( + [frozenset({0}), frozenset({1})], [frozenset(item) for item in indicators] + ) + with patch("qdk.ec._analysis.distance_solvers._solve_observable") as backend: + assert data.bounds(solver=MwpfSolverOptions()) == (3, 3, []) + backend.assert_not_called() + + +def test_distance_one_fast_path_detects_undetectable_logical() -> None: + check_matrix = [frozenset({0}), frozenset()] + parity_indicators = [frozenset(), frozenset({0})] + odd_cycles = OddCycles(check_matrix, parity_indicators) + assert odd_cycles.odd_cycle_length == 1 + size, cycle = odd_cycles.shortest(EnumerationSolverOptions()) + assert size == 1 + assert cycle == [1] + + +def test_distance_two_fast_path_detects_equal_checks_distinct_parity() -> None: + check_matrix = [frozenset({0}), frozenset({0})] + parity_indicators = [frozenset(), frozenset({0})] + odd_cycles = OddCycles(check_matrix, parity_indicators) + assert odd_cycles.odd_cycle_length == 2 + size, cycle = odd_cycles.shortest(EnumerationSolverOptions()) + assert size == 2 + assert set(cycle) == {0, 1} + + +def test_enumeration_finds_size_three_triangle_cycle() -> None: + check_matrix = [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})] + parity_indicators = [frozenset({0}), frozenset(), frozenset()] + odd_cycles = OddCycles(check_matrix, parity_indicators) + assert odd_cycles.odd_cycle_length is None + size, cycle = odd_cycles.shortest(EnumerationSolverOptions()) + assert size == 3 + assert set(cycle) == {0, 1, 2} + + +@requires_mwpf +def test_mwpf_matches_enumeration_on_triangle_cycle() -> None: + check_matrix = [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})] + parity_indicators = [frozenset({0}), frozenset(), frozenset()] + odd_cycles = OddCycles(check_matrix, parity_indicators) + lower, upper, cycle = odd_cycles.bounds(solver=MwpfSolverOptions()) + assert lower <= upper == 3 + assert set(cycle) == {0, 1, 2} + + +def test_duplicate_columns_are_deduplicated_but_witness_uses_original_ids() -> None: + check_matrix = [ + frozenset({0, 1}), + frozenset({0, 1}), + frozenset({1, 2}), + frozenset({0, 2}), + ] + parity_indicators = [frozenset({0}), frozenset({0}), frozenset(), frozenset()] + odd_cycles = OddCycles(check_matrix, parity_indicators) + assert len(odd_cycles.check_matrix) == 3 + assert odd_cycles.unique_columns_ids == [0, 2, 3] + size, cycle = odd_cycles.shortest(EnumerationSolverOptions()) + assert size == 3 + assert set(cycle) == {0, 2, 3} + + +def test_unique_non_empty_elements_of_groups_and_collects_empties() -> None: + sets = [frozenset({0}), frozenset(), frozenset({0}), frozenset({1})] + unique, groups, empties = unique_non_empty_elements_of(sets) + assert unique == [frozenset({0}), frozenset({1})] + assert groups == [[0, 2], [3]] + assert empties == [1] + + +def test_custom_exact_solver_seam_is_dispatched() -> None: + check_matrix = [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})] + parity_indicators = [frozenset({0}), frozenset(), frozenset()] + odd_cycles = OddCycles(check_matrix, parity_indicators) + + def fixed_solver( + _data: OddCycles, + _bound: int | None, + _coset: frozenset[int] | None, + ) -> tuple[int, list[int]]: + return 1, [0] + + size, cycle = odd_cycles.shortest(CustomExactSolver(fixed_solver)) + assert size == 1 + assert cycle == [0] + + +def test_no_logical_returns_empty_witness() -> None: + check_matrix = [frozenset({0}), frozenset({1})] + parity_indicators: list[frozenset[int]] = [frozenset(), frozenset()] + odd_cycles = OddCycles(check_matrix, parity_indicators) + size, cycle = odd_cycles.shortest(EnumerationSolverOptions()) + assert cycle == [] + assert size > len(check_matrix) + + +def test_exact_search_requires_closed_bounds() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + solver = CustomBoundsSolver(lambda *_: (2, 3, [0, 1, 2])) + assert data.bounds(solver=solver) == (2, 3, [0, 1, 2]) + with pytest.raises(RuntimeError, match="exact distance"): + data.shortest(solver) + + +def test_enumeration_cutoff_returns_bounds_not_false_exactness() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({2, 3}), frozenset({3, 0})], + [frozenset({0}), frozenset(), frozenset(), frozenset()], + ) + assert data.bounds( + odd_cycle_length_upper_bound=2, solver=EnumerationSolverOptions() + ) == (3, 5, []) + with pytest.raises(RuntimeError, match="exact distance"): + data.shortest(EnumerationSolverOptions(), cycle_size_upper_bound=2) + + +@requires_highs +def test_default_bounds_uses_highs() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + with patch( + "qdk.ec._analysis.distance_solvers.import_module", wraps=import_module + ) as backend: + lower, upper, witness = data.bounds() + assert lower == upper == 3 + assert set(witness) == {0, 1, 2} + backend.assert_called_once_with("highspy") + + +@requires_highs +def test_highs_matches_enumeration_and_handles_cutoff() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({2, 3}), frozenset({3, 0})], + [frozenset({0}), frozenset(), frozenset(), frozenset()], + ) + assert data.shortest("highs")[0] == data.shortest("enumeration")[0] == 4 + lower, upper, witness = data.bounds(solver="highs") + assert lower == upper == 4 + assert set(witness) == {0, 1, 2, 3} + assert data.bounds(odd_cycle_length_upper_bound=2, solver="highs") == (3, 5, []) + with pytest.raises(RuntimeError, match="exact distance"): + data.shortest("highs", cycle_size_upper_bound=2) + + +@requires_highs +def test_highs_proves_infeasibility() -> None: + data = OddCycles([frozenset({0}), frozenset({1})], [frozenset({0}), frozenset({0})]) + assert data.bounds(solver="highs") == (3, 3, []) + assert data.shortest("highs") == (3, []) + + +def test_highs_missing_package_has_install_hint() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + with patch( + "qdk.ec._analysis.distance_solvers.import_module", + side_effect=ModuleNotFoundError(name="highspy"), + ): + assert data.shortest("enumeration")[0] == 3 + with pytest.raises(ImportError, match="qdk\\[ec\\]"): + data.shortest("highs") + + +@requires_highs +def test_highs_limited_result_retains_gap() -> None: + backend = import_module("highspy") + model = MagicMock() + indices = count() + model.setOptionValue.return_value = backend.HighsStatus.kOk + model.addRow.return_value = backend.HighsStatus.kOk + model.addBinary.side_effect = lambda **_: next(indices) + model.addIntegral.side_effect = lambda **_: next(indices) + model.run.return_value = backend.HighsStatus.kWarning + model.getModelStatus.return_value = backend.HighsModelStatus.kTimeLimit + model.getInfo.return_value = SimpleNamespace(valid=True, mip_dual_bound=2.0) + model.getSolution.return_value = SimpleNamespace( + value_valid=True, col_value=[1.0, 1.0, 1.0] + ) + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + with patch.object(backend, "Highs", return_value=model): + assert data.bounds(solver=HighsSolverOptions(timeout=0.1)) == (2, 3, [0, 1, 2]) + model.setOptionValue.assert_any_call("time_limit", 0.1) + indices = count() + with pytest.raises(RuntimeError, match="exact distance"): + data.shortest("highs") + indices = count() + model.getSolution.return_value = SimpleNamespace(value_valid=False) + with pytest.raises(RuntimeError, match="without a feasible"): + data.bounds(solver="highs") + indices = count() + model.getModelStatus.return_value = backend.HighsModelStatus.kSolveError + with pytest.raises(RuntimeError, match="usable distance bounds"): + data.bounds(solver="highs") + + +@requires_highs +@pytest.mark.parametrize( + "values,bound", + [ + ([0.5, 1.0, 1.0], 2.0), + ([1.0, 0.0, 0.0], 1.0), + ([1.0, 1.0, 1.0], 4.0), + ([1.0, 1.0, 1.0], float("nan")), + ], +) +def test_highs_rejects_invalid_results(values: list[float], bound: float) -> None: + backend = import_module("highspy") + model = MagicMock() + indices = count() + model.setOptionValue.return_value = backend.HighsStatus.kOk + model.addRow.return_value = backend.HighsStatus.kOk + model.addBinary.side_effect = lambda **_: next(indices) + model.addIntegral.side_effect = lambda **_: next(indices) + model.run.return_value = backend.HighsStatus.kOk + model.getModelStatus.return_value = backend.HighsModelStatus.kOptimal + model.getInfo.return_value = SimpleNamespace(valid=True, mip_dual_bound=bound) + model.getSolution.return_value = SimpleNamespace(value_valid=True, col_value=values) + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + with patch.object(backend, "Highs", return_value=model), pytest.raises( + RuntimeError, match="HiGHS" + ): + data.bounds(solver="highs") + + +@requires_highs +def test_highs_matches_small_enumeration_instances() -> None: + random = Random(1827) + for _ in range(20): + checks = [ + frozenset(index for index in range(4) if random.getrandbits(1)) + for _ in range(7) + ] + parities = [ + frozenset(index for index in range(3) if random.getrandbits(1)) + for _ in checks + ] + data = OddCycles(checks, parities) + for coset in (None, frozenset({0, 2})): + expected, _ = data.shortest("enumeration", coset_indicator=coset) + lower, upper, witness = data.bounds(solver="highs", coset_indicator=coset) + assert lower == upper == expected + syndrome: set[int] = set() + logical: set[int] = set() + for index in witness: + syndrome ^= checks[index] + logical ^= parities[index] + assert not syndrome + if witness: + assert logical if coset is None else len(logical & coset) % 2 + + +@pytest.mark.parametrize( + "solver", ["enumeration", "mwpf", pytest.param("highs", marks=requires_highs)] +) +def test_shared_coset_selection_precedes_shortcuts(solver: str) -> None: + data = OddCycles([frozenset(), frozenset()], [frozenset({0, 1}), frozenset({1})]) + selected = cast(Solver, solver) + assert data.shortest(selected, coset_indicator=frozenset({0, 1})) == (1, [1]) + assert data.bounds(solver=selected, coset_indicator=frozenset()) == (1, 1, []) + + +@requires_mwpf +def test_mwpf_can_supply_an_exact_result() -> None: + data = OddCycles( + [frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})], + [frozenset({0}), frozenset(), frozenset()], + ) + assert data.shortest("mwpf")[0] == 3 + + +@pytest.mark.parametrize("name", ["other", "", "exhaustive"]) +def test_invalid_solver_is_rejected_even_for_shortcuts(name: str) -> None: + data = OddCycles([frozenset()], [frozenset({0})]) + with pytest.raises(ValueError, match="solver must be"): + data.shortest(cast(Solver, name)) diff --git a/source/qdk_package/tests/ec_tests/validation/test_equivalence.py b/source/qdk_package/tests/ec_tests/validation/test_equivalence.py new file mode 100644 index 00000000000..264d786e9c8 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_equivalence.py @@ -0,0 +1,40 @@ +"""Tests for gadget action profiling and equivalence.""" + +import qodec as qc +from qdk.ec._analysis.channel_action import ChannelAction, realized_action_of +from qdk.ec._analysis.equivalence import gadgets_equivalent, why_not_equivalent +from ec_tests.testing.optional import requires_stim + +pytestmark = requires_stim + + +def test_gadget_is_equivalent_to_itself(translation: qc.Layer) -> None: + for name in ("idle", "measure_zz", "prepare_zz"): + g = translation.gadgets[name] + assert gadgets_equivalent(g, g) + assert why_not_equivalent(g, g) == "" + + +def test_distinct_gadgets_are_not_equivalent( + idle_gadget: qc.Gadget, measure_xx_gadget: qc.Gadget, measure_zz_gadget: qc.Gadget +) -> None: + assert not gadgets_equivalent(idle_gadget, measure_xx_gadget) + assert not gadgets_equivalent(measure_xx_gadget, measure_zz_gadget) + assert "differ" in why_not_equivalent(measure_xx_gadget, measure_zz_gadget) + + +def test_distinct_preparations_are_not_equivalent( + prepare_xx_gadget: qc.Gadget, + prepare_zz_gadget: qc.Gadget, +) -> None: + assert not gadgets_equivalent(prepare_xx_gadget, prepare_zz_gadget) + assert why_not_equivalent(prepare_xx_gadget, prepare_zz_gadget) + + +def test_gadget_equivalence_uses_canonical_channel_actions( + idle_gadget: qc.Gadget, +) -> None: + action = realized_action_of(idle_gadget) + + assert isinstance(action, ChannelAction) + assert action.is_equivalent_to(realized_action_of(idle_gadget)) diff --git a/source/qdk_package/tests/ec_tests/validation/test_missing_checks.py b/source/qdk_package/tests/ec_tests/validation/test_missing_checks.py new file mode 100644 index 00000000000..73d61031fdf --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_missing_checks.py @@ -0,0 +1,226 @@ +"""Independent measurement-check omissions and the input-frame contract.""" + +from __future__ import annotations + +import json + +import pytest +import qodec as qc + +from qdk.ec import audit +from qdk.ec._audit._parity import ParityAnalysis +from qdk.ec._audit.rules import default_rules +from qdk.ec._audit.rules.gadget import MissingCheckRule +from ec_tests.testing.optional import requires_stim + + +def test_builtin_rule_ids_are_exact() -> None: + names = [rule.name for rule in default_rules()] + assert len(names) == len(set(names)) + assert set(names) == { + "qodec/invalid-structure", + "instruction-set/invalid-clifford", + "instruction-set/unreferenced-block", + "code/invalid-algebra", + "code/redundant-stabilizer", + "gadget/vacuous-check", + "gadget/redundant-check", + "gadget/missing-check", + "gadget/missing-observable", + "gadget/missing-flag", + "gadget/missing-realization", + "gadget/unsupported-action-step", + "gadget/check-mismatch", + "gadget/flag-mismatch", + "gadget/action-mismatch", + "gadget/readout-mismatch", + "gadget/incomplete-output-frame", + } + + +@requires_stim +def test_missing_checks_are_independent_verified_and_informational( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.checks = [] + disabled = tuple( + rule.name for rule in default_rules() if rule.name != "gadget/missing-check" + ) + report = audit(rep3_qodec, disabled=disabled, promote_warnings=True) + findings = [ + item for item in report.informational if "gadgets['measure_z']" in item.where + ] + assert len(findings) == 2 + assert report.ok and not report.warnings + assert all(item.rule == "gadget/missing-check" for item in findings) + analysis = ParityAnalysis(gadget) + checks: list[list[str]] = [] + for item in findings: + equation: list[str] = json.loads( + item.detail.splitlines()[0].removeprefix("Verified relation: ") + ) + assert analysis.value(equation).is_zero + assert any(path.startswith("in[") for path in equation) + checks.append(equation) + gadget.checks = checks + assert ParityAnalysis(gadget).missing_checks() == () + assert not [ + item + for item in audit(rep3_qodec, disabled=("gadget/missing-check",)).diagnostics + if item.rule == "gadget/missing-check" + ] + + +@requires_stim +def test_complete_and_equivalent_check_bases_do_not_report_omissions( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + assert ParityAnalysis(gadget).missing_checks() == () + first, second = gadget.checks + gadget.checks = [(*first, *second), first] + assert ParityAnalysis(gadget).missing_checks() == () + gadget.checks = [(*first, *second)] + assert len(ParityAnalysis(gadget).missing_checks()) == 1 + + +@requires_stim +def test_duplicate_vacuous_and_invalid_checks_do_not_hide_omissions( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + first = gadget.checks[0] + gadget.checks = [ + first, + first, + ["circuit.readouts[0]"], + ["circuit.readouts[00]", "circuit.readouts[0:1]"], + ] + missing = ParityAnalysis(gadget).missing_checks() + assert len(missing) == 1 + gadget.checks = [first, *missing] + assert ParityAnalysis(gadget).missing_checks() == () + + +@requires_stim +def test_readout_definitions_account_for_checks_written_through_readouts( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + first, second = gadget.checks + gadget.checks = [ + ["readouts[0]", "in[0].z[0]", "circuit.readouts[1]", "in[0].stabilizers[0]"], + second, + ] + assert ParityAnalysis(gadget).missing_checks() == () + gadget.checks = [first] + assert len(ParityAnalysis(gadget).missing_checks()) == 1 + + +@requires_stim +def test_zero_flags_and_readout_dependencies_are_not_duplicate_suggestions( + rep3_qodec: qc.Qodec, +) -> None: + physical = rep3_qodec.layers[1].instruction_set + gadget = qc.Gadget( + qc.Instruction("flagged", flags=["first", "second"]), + qc.gadgets.Circuit(physical, "R 0 1\nM 0 1", format="stim"), + readouts=[["readouts[1]"], ["circuit.readouts[0]"]], + ) + assert ParityAnalysis(gadget).missing_checks() == (("circuit.readouts[1]",),) + gadget.checks = [["circuit.readouts[1]"]] + assert ParityAnalysis(gadget).missing_checks() == () + + +@requires_stim +@pytest.mark.parametrize( + "source,expected", + [("R 0\nH 0\nM 0", ()), ("R 0\nM 0", (("circuit.readouts[0]",),))], +) +def test_random_bits_are_not_checks_but_constant_zero_bits_are( + rep3_qodec: qc.Qodec, + source: str, + expected: tuple[tuple[str, ...], ...], +) -> None: + physical = rep3_qodec.layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction( + "H", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Clifford({"X_0": "Z_0", "Z_0": "X_0"})], + ), + ] + gadget = qc.Gadget( + qc.Instruction("probe"), + qc.gadgets.Circuit(physical, source, format="stim"), + ) + assert ParityAnalysis(gadget).missing_checks() == expected + + +@requires_stim +def test_parity_one_relations_are_not_suggested(rep3_qodec: qc.Qodec) -> None: + physical = rep3_qodec.layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + physical.instructions = [ + *physical.instructions.values(), + qc.Instruction( + "X", inputs=[operand], outputs=[operand], action=[qc.actions.Pauli("X_0")] + ), + ] + gadget = qc.Gadget( + qc.Instruction("probe"), + qc.gadgets.Circuit(physical, "R 0\nX 0\nM 0", format="stim"), + ) + assert ParityAnalysis(gadget).missing_checks() == () + + +@requires_stim +def test_input_only_identities_do_not_add_missing_measurement_checks( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.inputs[0].code.stabilizers = ["Z_0 Z_1", "Z_1 Z_2", "Z_0 Z_2"] + gadget.checks = [] + analysis = ParityAnalysis(gadget) + missing = analysis.missing_checks() + assert len(missing) == 2 + assert all( + any(path.startswith("circuit.") for path in equation) for equation in missing + ) + assert all(analysis.value(equation).is_zero for equation in missing) + + +def test_input_frames_are_supplied_not_required_measurements( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.circuit = qc.gadgets.Circuit( + gadget.circuit.instruction_set, "[]", format="yaml" + ) + gadget.checks = [ + [f"in[0].stabilizers[{index}]", f"out[0].stabilizers[{index}]"] + for index in range(2) + ] + analysis = ParityAnalysis(gadget) + assert analysis.missing_checks() == () + assert analysis.unresolved_outputs() == () + gadget.checks = [] + analysis = ParityAnalysis(gadget) + assert analysis.missing_checks() == () + assert len(analysis.unresolved_outputs()) == 2 + + +def test_unavailable_analysis_is_informational_not_a_missing_equation( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.circuit.format = "custom" + findings = list(MissingCheckRule()(gadget, qodec=rep3_qodec)) + assert len(findings) == 1 + assert findings[0].severity.name == "INFO" + assert "analysis unavailable" in findings[0].summary + assert "Verified relation" not in findings[0].detail diff --git a/source/qdk_package/tests/ec_tests/validation/test_parity_analysis.py b/source/qdk_package/tests/ec_tests/validation/test_parity_analysis.py new file mode 100644 index 00000000000..089e2262c26 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_parity_analysis.py @@ -0,0 +1,910 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import pytest +import qodec as qc +import qdk.ec as ec + +from qdk.ec._audit._parity import ParityAnalysis +from qdk.ec._analysis.propagation.interpreter import _FramePropagator, walk_program +from qdk.ec._analysis.propagation.pauli import Pauli +from ec_tests.testing.optional import requires_stim + + +def _feedforward_instruction_set( + invert: bool, flagged: bool = False +) -> qc.InstructionSet: + operand = qc.instructions.BlockOperand("qubit") + return qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", 1)], + instructions=[ + qc.Instruction( + "R", outputs=[operand], action=[qc.actions.Stabilize(["Z_0"])] + ), + qc.Instruction( + "H", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Clifford({"X_0": "Z_0", "Z_0": "X_0"})], + ), + qc.Instruction( + "M", + inputs=[operand], + flags=["flag"] if flagged else [], + action=[qc.actions.Observe(["Z_0"])], + ), + qc.Instruction( + "correct", + inputs=[operand], + outputs=[operand], + parameters=[qc.instructions.Parameter("bit", "bit")], + action=[ + qc.actions.Pauli( + "X_0", condition=qc.actions.Condition(["bit"], invert=invert) + ) + ], + ), + ], + ) + + +@pytest.mark.parametrize( + "readout", + ["circuit.readouts[0]", "circuit.readouts[00:01]", "circuit.readouts[0:2:2]"], +) +def test_walker_honors_measurement_conditioned_pauli(readout: str) -> None: + for invert in (False, True): + physical = _feedforward_instruction_set(invert) + circuit = qc.gadgets.Circuit( + physical, + f'- R: [0]\n- H: [0]\n- M: [0]\n- correct: [0, bit: "{readout}"]', + format="yaml", + ) + assert circuit.calls()[-1].arguments["bit"] == "circuit.readouts[0]" + assert readout in circuit.source + frames = _FramePropagator(1) + frames.apply_pauli_to_shot(0, Pauli("X_0")) + simulation = walk_program(circuit, extra_engines=[frames]).simulation + assert simulation.is_stabilizer(Pauli("Z_0"), ignore_sign=True) + row = simulation.measure(Pauli("Z_0")) + assert not any( + simulation.outcome_matrix[row, index] + for index in range(simulation.outcome_matrix.column_count) + ) + assert bool(simulation.outcome_shift[row]) is invert + frame_row = frames.measure(Pauli("Z_0")) + assert not frames.outcome_deltas[frame_row, 0] + + +def test_feedforward_record_indices_include_preceding_flags() -> None: + physical = _feedforward_instruction_set(False, flagged=True) + circuit = qc.gadgets.Circuit( + physical, + '- R: [0]\n- H: [0]\n- M: [0]\n- R: [0]\n- M: [0]\n- correct: [0, bit: "circuit.readouts[2]"]', + format="yaml", + ) + simulation = walk_program(circuit).simulation + row = simulation.measure(Pauli("Z_0")) + assert not simulation.outcome_shift[row] + assert not any( + simulation.outcome_matrix[row, index] + for index in range(simulation.outcome_matrix.column_count) + ) + + +def _c4() -> qc.Qodec: + return qc.Qodec.load( + str(Path(__file__).parents[1] / "testing/qodecs/c4.qodec.yaml") + ) + + +def _framed_preparation() -> qc.Gadget: + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "prepare", outputs=[operand], action=[qc.actions.Stabilize(["Z_0"])] + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", [], ["X_0"], ["Z_0"]), support=["0"] + ) + return qc.Gadget( + instruction, + qc.gadgets.Circuit( + _c4().layers[1].instruction_set, "R 0\nR 1\nH 0\nCX 0 1\nM 1", format="stim" + ), + outputs=[encoding], + frames={"out[0].z[0]": ["circuit.readouts[0]"]}, + ) + + +@requires_stim +def test_declared_frame_makes_measurement_based_preparation_match() -> None: + profile = ec.GadgetProfile(_framed_preparation()) + assert profile.objective is not None + assert profile.action.is_equivalent_to(profile.objective) + + +@requires_stim +def test_missing_or_wrong_frame_does_not_fix_preparation() -> None: + gadget = _framed_preparation() + for frames in ({}, {"out[0].z[0]": []}, {"out[0].x[0]": ["circuit.readouts[0]"]}): + gadget.frames = frames + profile = ec.GadgetProfile(gadget) + assert profile.objective is not None + assert not profile.action.is_equivalent_to(profile.objective) + + +@requires_stim +def test_recorded_frame_bit_fault_changes_logical_output() -> None: + profile = ec.GadgetProfile(_framed_preparation()) + fault = ec.FaultEvent.after(4, readout_flips=0) + (effect,) = profile.effects_of([fault]) + assert effect == ec.FaultEffect(["out[0].z[0]"]) + assert profile.distance(faults=[fault]).value == 1 + + +@requires_stim +def test_constant_frame_flips_action_without_creating_faults() -> None: + gadget = _framed_preparation() + gadget.circuit.source = "R 0\nX 0" + gadget.frames = {"out[0].z[0]": [1]} + profile = ec.GadgetProfile(gadget) + assert profile.objective is not None + assert profile.action.is_equivalent_to(profile.objective) + (effect,) = profile.effects_of([ec.FaultEvent()]) + assert not effect + + +@pytest.mark.parametrize( + "frames, message", + [ + ({"in[0].z[0]": []}, "output logical"), + ({"out[0].stabilizers[0]": []}, "output logical"), + ({"out[1].z[0]": []}, "out of bounds"), + ({"out[0].z[1]": []}, "out of bounds"), + ({"out[0].z[0]": ["out[0].z[0]"]}, "not available"), + pytest.param( + {"out[0].z[0]": ["circuit.readouts[1]"]}, + "out of bounds", + marks=requires_stim, + ), + ], +) +def test_invalid_frames_are_rejected( + frames: dict[str, list[str | Literal[0, 1]]], message: str +) -> None: + gadget = _framed_preparation() + gadget.frames = frames + with pytest.raises(ValueError, match=message): + _ = ec.GadgetProfile(gadget).action + + +def test_duplicate_frame_targets_are_rejected_on_assignment() -> None: + gadget = _framed_preparation() + with pytest.raises(ValueError, match="duplicate frame target"): + gadget.frames = {"out[0].z[0]": [], "out[00].z[0]": []} + + +def test_frame_readout_cycles_are_rejected() -> None: + gadget = _framed_preparation() + gadget.readouts = [["readouts[0]"]] + gadget.frames = {"out[0].z[0]": ["readouts[0]"]} + with pytest.raises(ValueError, match="cyclic"): + _ = ec.GadgetProfile(gadget).action + + +@pytest.mark.parametrize("field", ["x", "z", "stabilizers"]) +@pytest.mark.parametrize("through_readout", [False, True]) +def test_frames_reject_incoming_signs(field: str, through_readout: bool) -> None: + from qdk.ec._audit import Auditor + from qdk.ec._frames import FrameMap + + gadget = _conditional_pauli_gadget() + reference = f"in[0].{field}[0]" + gadget.readouts = [{"reject": [reference, reference]}] + gadget.frames = {"out[0].z[0]": ["readouts[0]" if through_readout else reference]} + with pytest.raises( + ValueError, match="incoming signs are not allowed in frame deltas" + ): + FrameMap(gadget) + report = Auditor().audit_gadget(gadget, qodec=_c4()) + assert any(item.rule == "qodec/invalid-structure" for item in report.errors) + + +def test_local_frame_delta_tracks_measurements_flipped_by_incoming_signs() -> None: + gadget = _conditional_pauli_gadget() + gadget.circuit.source = "- M: [1]" + gadget.readouts = [{"reject": []}] + relation = ("out[0].z[0]", "in[0].z[0]", "circuit.readouts[0]") + gadget.checks = [relation] + gadget.frames = {"out[0].z[0]": ["circuit.readouts[0]"]} + analysis = ParityAnalysis(gadget) + assert not analysis.value(("circuit.readouts[0]",)).is_zero + assert analysis.value(relation).is_zero + assert not analysis.value(relation[:2]).is_zero + gadget.frames = {} + assert ParityAnalysis(gadget).value(relation[:2]).is_zero + + +@requires_stim +def test_frame_snapshot_and_readout_dependencies_preserve_constants() -> None: + gadget = _framed_preparation() + gadget.readouts = [["circuit.readouts[0]", 1]] + gadget.frames = {"out[0].z[0]": ["readouts[0]", 1]} + profile = ec.GadgetProfile(gadget) + gadget.frames = {} + assert profile.objective is not None + assert profile.action.is_equivalent_to(profile.objective) + + +@requires_stim +def test_physical_and_recorded_frame_faults_can_cancel() -> None: + profile = ec.GadgetProfile(_framed_preparation()) + fault = ec.FaultEvent.after(4, Pauli("X_0"), readout_flips=0) + (effect,) = profile.effects_of([fault]) + assert not effect + quantum, recorded = profile.effects_of( + [ + ec.FaultEvent.after(4, Pauli("X_0")), + ec.FaultEvent.after(4, readout_flips=0), + ] + ) + assert quantum == recorded == ec.FaultEffect(["out[0].z[0]"]) + assert quantum ^ recorded == effect + assert profile.distance(faults=[fault]).lower_bound is None + + +@requires_stim +def test_completion_preserves_explicit_frames() -> None: + gadget = _framed_preparation() + completed = ec.filled(gadget) + assert isinstance(completed, qc.Gadget) + assert completed.frames == gadget.frames + assert ec.GadgetProfile(completed).action.is_equivalent_to( + ec.GadgetProfile(gadget).action + ) + + +@requires_stim +def test_literal_parities_retain_affine_signs_and_cancel_pairs() -> None: + gadget = _framed_preparation() + gadget.checks = [[1], [1, 1], [0], ["circuit.readouts[0]", 1]] + analysis = ParityAnalysis(gadget) + assert not analysis.value(analysis.checks[0]).is_zero + assert analysis.value(analysis.checks[1]).is_zero + assert analysis.value(analysis.checks[2]).is_zero + assert ( + analysis.value(analysis.checks[3]) ^ analysis.value(("circuit.readouts[0]",)) + == analysis.values["1"] + ) + + +@requires_stim +def test_logical_preparation_is_independent_of_random_code_syndrome() -> None: + gadget = _framed_preparation() + gadget.frames = {} + gadget.implements = qc.Instruction( + gadget.implements.mnemonic, + outputs=[qc.instructions.BlockOperand("qubit")], + action=[qc.actions.Stabilize(["X_0"])], + ) + gadget.circuit.source = "R 0\nH 0\nR 1\nH 1\nM 1\nH 1" + gadget.outputs = [ + qc.gadgets.Encoding( + qc.Code("pair", ["X_0 X_1"], ["X_0"], ["Z_0 Z_1"]), support=["0", "1"] + ) + ] + profile = ec.GadgetProfile(gadget) + assert profile.objective is not None + assert profile.action.is_equivalent_to(profile.objective) + + +def _conditional_pauli_gadget() -> qc.Gadget: + physical = _feedforward_instruction_set(False) + operand = qc.instructions.BlockOperand("data") + instruction = qc.Instruction( + "correct_ancilla", inputs=[operand], outputs=[operand], flags=["reject"] + ) + code = qc.Code("data", ["Z_1"], ["X_0"], ["Z_0"]) + encoding = qc.gadgets.Encoding(code, support=["0", "1"]) + source = '- M: [1]\n- correct: [1, bit: "circuit.readouts[0]"]\n- M: [1]' + return qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, source, format="yaml"), + inputs=[encoding], + outputs=[encoding], + checks=[["out[0].stabilizers[0]"]], + readouts=[{"reject": ["circuit.readouts[1]"]}], + ) + + +def test_conditional_pauli_parities_include_incoming_frames() -> None: + analysis = ParityAnalysis(_conditional_pauli_gadget()) + assert analysis.value(analysis.checks[0]).is_zero + assert analysis.value(analysis.readouts[0]).is_zero + assert not analysis.value(("circuit.readouts[0]",)).is_zero + assert analysis.unresolved_outputs() == () + + +def _measure_then_hadamard(*, measurement_first: bool) -> qc.Gadget: + operand = qc.instructions.BlockOperand("qubit") + observe = qc.actions.Observe(["Z_0"]) + hadamard = qc.actions.Clifford({"X_0": "Z_0", "Z_0": "X_0"}) + physical = _feedforward_instruction_set(False) + instruction = qc.Instruction( + "measure_h", + inputs=[operand], + outputs=[operand], + action=[observe, hadamard] if measurement_first else [hadamard, observe], + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", ["Z_1"], ["X_0"], ["Z_0"]), support=["0", "1"] + ) + return qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "M 0\nH 0", format="stim"), + inputs=[encoding], + outputs=[encoding], + readouts=[["circuit.readouts[0]", "in[0].z[0]"]], + checks=[["out[0].stabilizers[0]", "in[0].stabilizers[0]"]], + ) + + +@requires_stim +def test_leading_observation_can_precede_other_logical_actions() -> None: + analysis = ParityAnalysis(_measure_then_hadamard(measurement_first=True)) + assert analysis.value(analysis.readouts[0]) == analysis.expected[0] + assert analysis.value(("circuit.readouts[0]",)) != analysis.expected[0] + + +@requires_stim +def test_observation_after_a_logical_transform_remains_unverified() -> None: + analysis = ParityAnalysis(_measure_then_hadamard(measurement_first=False)) + with pytest.raises(NotImplementedError, match="interleaved logical actions"): + _ = analysis.expected + + +@requires_stim +def test_output_checks_do_not_require_logical_readout_verification() -> None: + gadget = _measure_then_hadamard(measurement_first=False) + assert ParityAnalysis(gadget).unresolved_outputs() == () + gadget.checks = [["out[0].stabilizers[0]"]] + with pytest.raises(NotImplementedError, match="interleaved logical actions"): + ParityAnalysis(gadget).unresolved_outputs() + + +def _rotation_gadget(axis: str) -> qc.Gadget: + operand = qc.instructions.BlockOperand("qubit") + rotation = qc.Instruction( + "rotate", + inputs=[operand], + outputs=[operand], + parameters=[qc.instructions.Parameter("theta", "number")], + action=[qc.actions.Rotate(axis, "theta")], + ) + physical = qc.InstructionSet( + "physical", blocks=[qc.instructions.Block("qubit", 1)], instructions=[rotation] + ) + encoding = qc.gadgets.Encoding( + qc.Code("repetition", ["Z_0 Z_1"], ["X_0 X_1"], ["Z_0"]), support=["2", "5"] + ) + return qc.Gadget( + rotation, + qc.gadgets.Circuit(physical, "- rotate: [2, theta: theta]", format="yaml"), + inputs=[encoding], + outputs=[encoding], + checks=[["out[0].stabilizers[0]", "in[0].stabilizers[0]"]], + ) + + +def test_rotation_preserves_commuting_stabilizer_signs() -> None: + analysis = ParityAnalysis(_rotation_gadget("Z_0")) + assert analysis.value(analysis.checks[0]).is_zero + assert not analysis.value(("out[0].stabilizers[0]",)).is_zero + assert analysis.unresolved_outputs() == () + + +def test_noncommuting_rotation_is_not_treated_as_identity() -> None: + analysis = ParityAnalysis(_rotation_gadget("X_0")) + with pytest.raises(TypeError, match="Rotate"): + analysis.value(analysis.checks[0]) + + +def test_rotation_invariants_do_not_certify_logical_frames() -> None: + gadget = _rotation_gadget("Z_0") + gadget.checks = [["out[0].x[0]", "in[0].x[0]"]] + with pytest.raises(TypeError, match="Rotate"): + ParityAnalysis(gadget).value(("out[0].x[0]", "in[0].x[0]")) + + +def _selected_flag_gadget(selection: str) -> qc.Gadget: + physical = _feedforward_instruction_set(False, flagged=True) + source = ( + "- R: [0]\n- H: [0]\n" + f"- M: {{operands: [0], select: {selection}}}\n" + '- correct: [0, bit: "circuit.readouts[0]"]\n- M: [0]' + ) + return qc.Gadget( + qc.Instruction("flag", flags=["reject"]), + qc.gadgets.Circuit(physical, source, format="yaml"), + readouts=[ + { + "reject": [ + "circuit.readouts[1]", + "circuit.readouts[2]", + "circuit.readouts[3]", + ] + } + ], + ) + + +@pytest.mark.parametrize("selection", ["[{flag: 0}]", "[{flag: 1}, {flag: 0}]", "[{}]"]) +def test_zero_flag_selection_retains_measurement_record_positions( + selection: str, +) -> None: + analysis = ParityAnalysis(_selected_flag_gadget(selection)) + assert analysis.value(analysis.readouts[0]).is_zero + assert not analysis.value(("circuit.readouts[0]",)).is_zero + assert all( + analysis.value((f"circuit.readouts[{index}]",)).is_zero for index in (1, 2, 3) + ) + + +def test_selection_rejecting_noiseless_execution_stays_unverified() -> None: + analysis = ParityAnalysis(_selected_flag_gadget("[{flag: 1}]")) + with pytest.raises( + NotImplementedError, match="rejects the noiseless zero-flag branch" + ): + analysis.value(analysis.readouts[0]) + + +def test_selection_cannot_assume_an_unknown_flag_is_zero() -> None: + analysis = ParityAnalysis(_selected_flag_gadget("[{unknown: 0}]")) + with pytest.raises(ValueError, match="undeclared instruction flag"): + analysis.value(analysis.readouts[0]) + + +def test_missing_output_suggestions_include_preceding_flag_slots() -> None: + from qdk.ec._audit.rules.gadget import IncompleteOutputFrameRule + + gadget = _selected_flag_gadget("[{flag: 0}]") + gadget.circuit.source = "- R: [0]\n- M: [0]\n- H: [0]\n- M: [0]" + gadget.outputs = [ + qc.gadgets.Encoding(qc.Code("measured", ["Z_0"], [], []), support=["0"]) + ] + gadget.readouts = [] + diagnostics = list(IncompleteOutputFrameRule()(gadget, qodec=_c4())) + assert len(diagnostics) == 1 + assert ( + 'Verified relation: ["out[0].stabilizers[0]", "circuit.readouts[2]"]' + in diagnostics[0].detail + ) + + +def test_missing_output_relations_do_not_claim_unsupported_analysis_succeeded() -> None: + from qdk.ec._audit.rules.gadget import IncompleteOutputFrameRule + + protocol = _c4() + gadget = protocol.layers[0].gadgets["transversal_cx"] + gadget.checks = [] + gadget.circuit.format = "unknown" + diagnostics = list(IncompleteOutputFrameRule()(gadget, qodec=protocol)) + assert len(diagnostics) == 1 + assert diagnostics[0].summary == "Output frames not checked: analysis failed" + assert "Verified relation" not in diagnostics[0].detail + + +@requires_stim +def test_readout_requires_incoming_logical_frame() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + analysis = ParityAnalysis(gadget) + assert not (analysis.value(analysis.readouts[0]) ^ analysis.expected[0]).is_zero + candidate = analysis.candidate(analysis.expected[0]) + assert candidate is not None + assert "in[0].z[0]" in candidate + assert (analysis.value(candidate) ^ analysis.expected[0]).is_zero + + +@requires_stim +def test_check_validity_and_tautological_cancellation() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + analysis = ParityAnalysis(gadget) + assert all(analysis.value(equation).is_zero for equation in analysis.checks) + assert not analysis.value(("circuit.readouts[0]",)).is_zero + gadget.checks = [["circuit.readouts[0]", "circuit.readouts[0]"]] + assert ParityAnalysis(gadget).checks == ((),) + gadget.checks = [["circuit.readouts[00]", "circuit.readouts[0:1]"]] + assert ParityAnalysis(gadget).checks == ((),) + + +@requires_stim +def test_readout_dependencies_are_solved() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + gadget.readouts = [["readouts[1]"], ["circuit.readouts[0]"]] + analysis = ParityAnalysis(gadget) + resolution = analysis.resolution + assert not resolution.conflicts and not resolution.unresolved + assert ( + resolution.values[0] + == resolution.values[1] + == analysis.value(("circuit.readouts[0]",)) + ) + gadget.readouts = [["readouts[0]"]] + assert ParityAnalysis(gadget).resolution.unresolved == {0} + gadget.readouts = [["readouts[0]", "circuit.readouts[0]"]] + assert ParityAnalysis(gadget).resolution.conflicts == ((0,),) + + +@requires_stim +def test_readout_conflict_blocks_only_dependent_equations() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + gadget.implements.flags = ["reject"] + gadget.readouts = [ + ["readouts[0]", "circuit.readouts[0]"], + ["circuit.readouts[1]"], + ["readouts[0]"], + ] + analysis = ParityAnalysis(gadget) + resolution = analysis.resolution + assert resolution.conflicts == ((0,),) + assert resolution.blocked == {0, 2} + assert resolution.unresolved == set() + assert resolution.values == {1: analysis.value(("circuit.readouts[1]",))} + + +@requires_stim +def test_readout_cycle_is_reported_as_one_conflict() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + gadget.readouts = [["readouts[1]"], ["readouts[0]", 1]] + resolution = ParityAnalysis(gadget).resolution + assert resolution.conflicts == ((0, 1),) + assert resolution.blocked == {0, 1} + assert resolution.values == {} + + +@requires_stim +def test_output_frames_require_valid_independent_constraints() -> None: + gadget = _c4().layers[0].gadgets["transversal_cx"] + signs = [ + f"out[{entry}].stabilizers[{index}]" for entry in range(2) for index in range(2) + ] + gadget.checks = [[sign, sign] for sign in signs] + assert ParityAnalysis(gadget).unresolved_outputs() == tuple(signs) + gadget.checks = [ + [signs[0], "in[0].stabilizers[0]", "in[1].stabilizers[0]"], + [signs[1], "in[0].stabilizers[1]"], + [signs[2], "in[1].stabilizers[0]"], + [signs[3], "in[0].stabilizers[1]", "in[1].stabilizers[1]"], + ] + assert ParityAnalysis(gadget).unresolved_outputs() == () + gadget.checks = [ + [ + signs[0], + signs[1], + "in[0].stabilizers[0]", + "in[1].stabilizers[0]", + "in[0].stabilizers[1]", + ] + ] + assert ParityAnalysis(gadget).unresolved_outputs() == tuple(signs) + + +@requires_stim +def test_readout_sign_mismatch_and_dependency_errors_are_reported() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_zz"] + for readouts, expected in ( + ( + [["circuit.readouts[0]", "circuit.readouts[2]", "in[0].x[0]"]], + "Verified readout equation:", + ), + ([["readouts[0]"]], "not uniquely determined"), + ([["readouts[0]", "circuit.readouts[0]"]], "cannot all hold"), + ): + gadget.readouts = [ + *readouts, + ["circuit.readouts[0]", "circuit.readouts[1]", "in[0].z[1]"], + ] + errors = [ + item + for item in ec.audit(protocol).errors + if item.rule == "gadget/readout-mismatch" + and "gadgets['measure_zz']" in item.where + ] + assert errors + assert expected in errors[0].detail + + +@requires_stim +def test_invalid_check_has_concrete_witness() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_zz"] + gadget.checks = [["circuit.readouts[0]"]] + diagnostic = next( + item + for item in ec.audit(protocol).errors + if item.rule == "gadget/check-mismatch" + ) + assert 'Declared equation: ["circuit.readouts[0]"]' in diagnostic.detail + assert 'Equation term values: {"circuit.readouts[0]": 1}' in diagnostic.detail + assert "can fire without a fault" in diagnostic.summary + assert ( + "Declared equation produces: 1\nRequired noiseless value: 0" + in diagnostic.detail + ) + + +@requires_stim +def test_always_one_flag_fails_and_zero_flag_passes() -> None: + operand = qc.instructions.BlockOperand("qubit") + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[ + qc.Instruction( + "R", outputs=[operand], action=[qc.actions.Stabilize(["Z_0"])] + ), + qc.Instruction( + "X", + inputs=[operand], + outputs=[operand], + action=[qc.actions.Pauli("X_0")], + ), + qc.Instruction("M", inputs=[operand], action=[qc.actions.Observe(["Z_0"])]), + ], + ) + instruction = qc.Instruction("flag_only", flags=["reject"]) + for source, expected_errors in (("R 0\nX 0\nM 0", 1), ("R 0\nM 0", 0)): + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, source, format="stim"), + readouts=[{"reject": ["circuit.readouts[0]"]}], + ) + protocol = qc.Qodec( + [ + qc.Layer( + qc.InstructionSet("logical", instructions=[instruction]), + gadgets=[gadget], + ), + qc.Layer(physical), + ] + ) + errors = [ + item + for item in ec.audit(protocol).errors + if item.rule == "gadget/flag-mismatch" + ] + assert len(errors) == expected_errors + if errors: + assert "always fires, even without a fault" in errors[0].summary + assert ( + "Declared equation always produces: 1\nRequired noiseless value: 0" + in errors[0].detail + ) + + +@requires_stim +def test_solvable_readout_cycle_is_not_rejected() -> None: + gadget = _c4().layers[0].gadgets["measure_zz"] + gadget.readouts = [ + ["readouts[1]", "readouts[2]", "circuit.readouts[0]"], + ["readouts[0]", "circuit.readouts[1]"], + ["readouts[1]", "circuit.readouts[2]"], + ] + analysis = ParityAnalysis(gadget) + resolution = analysis.resolution + assert not resolution.unresolved and not resolution.conflicts + assert resolution.values[0] == analysis.value( + ("circuit.readouts[0]", "circuit.readouts[2]") + ) + assert resolution.values[2] == analysis.value( + ("circuit.readouts[0]", "circuit.readouts[1]") + ) + + +@requires_stim +def test_output_logical_frame_tracks_the_declared_operation() -> None: + gadget = _c4().layers[0].gadgets["x0"] + gadget.checks = [ + [f"in[0].{basis}[{index}]", f"out[0].{basis}[{index}]"] + for basis in ("x", "z") + for index in (0, 1) + ] + analysis = ParityAnalysis(gadget) + assert all(analysis.value(check).is_zero for check in analysis.checks) + gadget.checks = [["in[0].x[0]", "out[0].z[0]"]] + changed = ParityAnalysis(gadget) + assert not changed.value(changed.checks[0]).is_zero + + +@requires_stim +def test_reset_between_measurements_erases_the_previous_outcome() -> None: + physical = _c4().layers[1].instruction_set + instruction = qc.Instruction("reset_then_flag", flags=["reject"]) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "R 0\nH 0\nM 0\nR 0\nM 0", format="stim"), + readouts=[{"reject": ["circuit.readouts[1]"]}], + ) + analysis = ParityAnalysis(gadget) + assert analysis.value(("circuit.readouts[1]",)).is_zero + assert not analysis.value(("circuit.readouts[0]",)).is_zero + + +@requires_stim +def test_reset_of_known_input_stabilizer_removes_incoming_frame() -> None: + physical = _c4().layers[1].instruction_set + operand = qc.instructions.BlockOperand("data") + instruction = qc.Instruction("reset_ancilla", inputs=[operand], outputs=[operand]) + code = qc.Code("data-with-ancilla", stabilizers=["Z_1"], x=["X_0"], z=["Z_0"]) + encoding = qc.gadgets.Encoding(code, support=["0", "1"]) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "R 1", format="stim"), + inputs=[encoding], + outputs=[encoding], + checks=[["out[0].stabilizers[0]"]], + ) + analysis = ParityAnalysis(gadget) + assert analysis.value(analysis.checks[0]).is_zero + + +@requires_stim +def test_random_flag_and_constant_one_check_are_errors() -> None: + physical = _c4().layers[1].instruction_set + instruction = qc.Instruction("flag", flags=["reject"]) + for source, rule in ( + ("R 0\nH 0\nM 0", "gadget/flag-mismatch"), + ("R 0\nX 0\nM 0", "gadget/check-mismatch"), + ): + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, source, format="stim"), + checks=[["circuit.readouts[0]"]], + readouts=[{"reject": ["circuit.readouts[0]"]}], + ) + protocol = qc.Qodec( + [ + qc.Layer( + qc.InstructionSet("logical", instructions=[instruction]), + gadgets=[gadget], + ), + qc.Layer(physical), + ] + ) + diagnostic = next( + item for item in ec.audit(protocol).errors if item.rule == rule + ) + assert "Declared equation" in diagnostic.detail + assert "Required noiseless value: 0" in diagnostic.detail + + +@requires_stim +def test_logical_readout_reference_in_check_is_not_dropped() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_zz"] + gadget.readouts = [ + ["circuit.readouts[0]", "circuit.readouts[2]", "in[0].z[0]"], + ["circuit.readouts[0]", "circuit.readouts[1]", "in[0].z[1]"], + ] + gadget.checks = [["readouts[0]"]] + errors = [ + item + for item in ec.audit(protocol).errors + if "gadgets['measure_zz']" in item.where + ] + assert {item.rule for item in errors} == {"gadget/check-mismatch"} + + +def test_empty_equations_are_zero_even_for_opaque_circuit() -> None: + instruction = qc.Instruction("flag", flags=["reject"]) + physical = _c4().layers[1].instruction_set + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "opaque source", format="openqasm"), + checks=[[]], + readouts=[{"reject": []}], + ) + protocol = qc.Qodec( + [ + qc.Layer( + qc.InstructionSet("logical", instructions=[instruction]), + gadgets=[gadget], + ), + qc.Layer(physical), + ] + ) + assert not [ + item + for item in ec.audit(protocol).diagnostics + if item.rule in {"gadget/check-mismatch", "gadget/flag-mismatch"} + ] + + +def test_unsupported_circuit_yields_unverified_parity_warning() -> None: + from qdk.ec._audit.rules.gadget import CheckMismatchRule + + protocol = _c4() + gadget = protocol.layers[0].gadgets["idle"] + gadget.circuit = qc.gadgets.Circuit( + gadget.circuit.instruction_set, "opaque source", format="openqasm" + ) + diagnostic = next(iter(CheckMismatchRule()(gadget, qodec=protocol))) + assert diagnostic.severity is ec.Diagnostic.Severity.WARNING + assert "not checked" in diagnostic.summary + + +def test_y_readout_preserves_complex_conjugation_sign() -> None: + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "measure_y", inputs=[operand], action=[qc.actions.Observe(["Y_0"])] + ) + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[ + qc.Instruction("MY", inputs=[operand], action=[qc.actions.Observe(["Y_0"])]) + ], + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "- MY: [0]", format="yaml"), + inputs=[encoding], + readouts=[["circuit.readouts[0]", "in[0].x[0]", "in[0].z[0]"]], + ) + analysis = ParityAnalysis(gadget) + assert analysis.value(analysis.readouts[0]) == analysis.expected[0] + + +def test_composite_logical_observable_retains_product_phase() -> None: + encoding = _c4().layers[0].gadgets["measure_zz"].inputs[0] + operand = qc.instructions.BlockOperand("qubit") + physical = qc.InstructionSet( + "physical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[ + qc.Instruction("MY", inputs=[operand], action=[qc.actions.Observe(["Y_0"])]) + ], + ) + instruction = qc.Instruction( + "measure_xz", + inputs=[qc.instructions.BlockOperand("c4")], + action=[qc.actions.Observe(["X_0 Z_1"])], + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "- MY: [0]\n- MY: [1]", format="yaml"), + inputs=[encoding], + readouts=[ + ["circuit.readouts[0]", "circuit.readouts[1]", "in[0].x[0]", "in[0].z[1]"] + ], + ) + analysis = ParityAnalysis(gadget) + difference = analysis.value(analysis.readouts[0]) ^ analysis.expected[0] + assert not any(difference[index] for index in range(len(difference) - 1)) + assert difference[len(difference) - 1] + + +@requires_stim +def test_constant_one_check_with_incoming_frames() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["x0"] + gadget.circuit = qc.gadgets.Circuit( + gadget.circuit.instruction_set, "X 0", format="stim" + ) + gadget.checks = [["in[0].stabilizers[1]", "out[0].stabilizers[1]"]] + diagnostic = next( + item + for item in ec.audit(protocol).errors + if item.rule == "gadget/check-mismatch" and "gadgets['x0']" in item.where + ) + assert ( + "Declared equation always produces: 1\nRequired noiseless value: 0" + in diagnostic.detail + ) diff --git a/source/qdk_package/tests/ec_tests/validation/test_readout_messages.py b/source/qdk_package/tests/ec_tests/validation/test_readout_messages.py new file mode 100644 index 00000000000..b423adb7fa9 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_readout_messages.py @@ -0,0 +1,345 @@ +"""Diagnostic evidence distinguishes missing observables from incorrect equations.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from binar import BitVector +import pytest +import qodec as qc + +from qdk.ec._audit import Auditor +from qdk.ec._audit._parity import ParityAnalysis +from qdk.ec._audit.rules.gadget import ( + ActionMismatchRule, + MissingFlagRule, + MissingObservableRule, + FlagMismatchRule, + ReadoutMismatchRule, +) +from qdk.ec._audit.rules.qodec import StructuralValidationRule +from ec_tests.testing.optional import requires_stim + + +def _c4() -> qc.Qodec: + return qc.Qodec.load(Path(__file__).parents[1] / "testing/qodecs/c4.qodec.yaml") + + +@requires_stim +@pytest.mark.parametrize("retained", [0, 1]) +def test_missing_observables_supply_verified_equations(retained: int) -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [list(readout.equation) for readout in gadget.readouts[:retained]] + report = Auditor( + rules=[StructuralValidationRule(), MissingObservableRule()] + ).audit_gadget(gadget, qodec=protocol) + assert len(report.errors) == 2 - retained + analysis = ParityAnalysis(gadget) + for position, diagnostic in enumerate(report.errors, start=retained): + assert diagnostic.rule == "gadget/missing-observable" + assert diagnostic.summary == ( + f"readouts[{position}] has no equation for the required logical X_{position} measurement" + ) + assert diagnostic.detail.startswith("Verified readout equation: ") + candidate = json.loads( + diagnostic.detail.removeprefix("Verified readout equation: ") + ) + assert analysis.value(candidate) == analysis.expected[position] + + +@pytest.mark.parametrize("format,source", [("stim", "M 0 1 2 3"), ("opaque", "")]) +def test_missing_observables_remain_errors_without_candidates( + format: str, source: str +) -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts.clear() + gadget.circuit = qc.gadgets.Circuit( + gadget.circuit.instruction_set, source, format=format + ) + report = Auditor(rules=[MissingObservableRule()]).audit_gadget( + gadget, qodec=protocol + ) + assert len(report.errors) == 2 + assert all(diagnostic.detail == "" for diagnostic in report.errors) + + +@pytest.mark.parametrize("retained", [0, 1]) +def test_missing_flags_report_only_the_missing_equation(retained: int) -> None: + instruction = qc.Instruction("flag_pair", flags=["reject_x", "reject_z"]) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(qc.InstructionSet("physical"), "", format="stim"), + readouts=[[] for _ in range(retained)], + ) + report = Auditor( + rules=[StructuralValidationRule(), MissingFlagRule()] + ).audit_gadget(gadget, qodec=qc.Qodec([])) + assert len(report.errors) == 2 - retained + for position, diagnostic in enumerate(report.errors, start=retained): + assert diagnostic.rule == "gadget/missing-flag" + assert diagnostic.summary == ( + f"readouts[{position}] has no equation for flag {instruction.flags[position]!r}" + ) + assert diagnostic.detail == "" + + +@pytest.mark.parametrize( + "format", [pytest.param("stim", marks=requires_stim), "opaque"] +) +def test_preparation_without_inputs_reports_only_actual_analysis_failures( + format: str, +) -> None: + physical = _c4().layers[1].instruction_set + instruction = qc.Instruction( + "prepare_z", + outputs=[qc.instructions.BlockOperand("qubit")], + action=[qc.actions.Stabilize(["Z_0"])], + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "R 0", format=format), + outputs=[ + qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + ], + ) + report = Auditor(rules=[ActionMismatchRule()]).audit_gadget( + gadget, qodec=qc.Qodec([]) + ) + if format == "stim": + assert not report.diagnostics + else: + assert not report.errors and not report.informational + assert len(report.warnings) == 1 + assert ( + report.warnings[0].summary == "Logical action not checked: analysis failed" + ) + assert ( + report.warnings[0].detail + == "ValueError: No source parser registered for '.opaque'" + ) + + +@requires_stim +def test_wrong_observable_explains_unavailable_result_without_counterexamples() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.circuit.source = "M 0 1 2 3" + report = Auditor(rules=[ReadoutMismatchRule()]).audit_gadget(gadget, qodec=protocol) + assert len(report.errors) == 2 + diagnostic = report.errors[0] + assert ( + diagnostic.summary + == "The circuit does not provide the required logical X_0 measurement result for readouts[0]" + ) + assert diagnostic.detail.splitlines() == [ + 'Declared equation: ["circuit.readouts[0]", "circuit.readouts[1]"]', + "No readout formula using circuit bits and incoming frame signs can recover this result.", + ] + analysis = ParityAnalysis(gadget) + constant = BitVector( + index == len(analysis.expected[0]) - 1 + for index in range(len(analysis.expected[0])) + ) + assert analysis.candidate(analysis.expected[0]) is None + assert analysis.candidate(analysis.expected[0] ^ constant) is None + + +@requires_stim +def test_frame_correction_shows_only_declared_and_verified_equations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_witness(*args: object, **kwargs: object) -> str: + pytest.fail("A verified equation makes a counterexample unnecessary.") + + monkeypatch.setattr(ParityAnalysis, "witness", unexpected_witness) + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + diagnostic = next(iter(ReadoutMismatchRule()(gadget, qodec=protocol))) + assert ( + diagnostic.summary + == "readouts[0] does not report the required logical X_0 measurement" + ) + detail = diagnostic.detail.splitlines() + assert len(detail) == 2 + declared = json.loads(detail[0].removeprefix("Declared equation: ")) + required = json.loads(detail[1].removeprefix("Verified readout equation: ")) + analysis = ParityAnalysis(gadget) + assert analysis.value(declared) != analysis.expected[0] + assert analysis.value(required) == analysis.expected[0] + + +@requires_stim +def test_constant_inversion_is_not_reported_as_a_missing_observable() -> None: + physical = _c4().layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "measure_z", inputs=[operand], action=[qc.actions.Observe(["Z_0"])] + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "X 0\nM 0", format="stim"), + inputs=[encoding], + readouts=[["circuit.readouts[0]", "in[0].z[0]"]], + ) + protocol = qc.Qodec( + [ + qc.Layer( + qc.InstructionSet( + "logical", + blocks=[qc.instructions.Block("qubit", encodes=1)], + instructions=[instruction], + ), + gadgets=[gadget], + ), + qc.Layer(physical), + ] + ) + diagnostic = next(iter(ReadoutMismatchRule()(gadget, qodec=protocol))) + required = json.loads( + diagnostic.detail.splitlines()[1].removeprefix("Verified readout equation: ") + ) + assert 1 in required + gadget.readouts = [required] + assert not list(ReadoutMismatchRule()(gadget, qodec=protocol)) + assert "does not provide" not in diagnostic.summary + assert len(diagnostic.detail.splitlines()) == 2 + + +@requires_stim +@pytest.mark.parametrize( + "equation,summary,explanation", + [ + (["readouts[0]"], "an undetermined readout", "allow both 0 and 1"), + ( + ["readouts[0]", "circuit.readouts[0]"], + "equation is inconsistent", + "cannot all hold", + ), + ], +) +def test_dependency_failures_explain_why_no_bit_is_defined( + equation: list[str], + summary: str, + explanation: str, +) -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [ + equation, + ["circuit.readouts[0]", "circuit.readouts[2]", "in[0].x[1]"], + ] + diagnostic = next(iter(ReadoutMismatchRule()(gadget, qodec=protocol))) + assert summary in diagnostic.summary + assert explanation in diagnostic.detail + assert "Declared equation produces:" not in diagnostic.detail + assert "Verified readout equation:" in diagnostic.detail + + +@requires_stim +def test_contradiction_does_not_blame_an_independent_readout() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [ + ["readouts[0]", 1], + ["circuit.readouts[0]", "circuit.readouts[2]", "in[0].x[1]"], + ] + diagnostics = list(ReadoutMismatchRule()(gadget, qodec=protocol)) + assert len(diagnostics) == 1 + assert diagnostics[0].summary == "readouts[0].equation is inconsistent" + assert diagnostics[0]._path == "readouts[0].equation" + candidate = json.loads( + diagnostics[0] + .detail.splitlines()[1] + .removeprefix("Verified readout equation: ") + ) + analysis = ParityAnalysis(gadget) + assert analysis.value(candidate) == analysis.expected[0] + + +@requires_stim +def test_conflicting_observables_report_one_error_with_both_candidates() -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + gadget.readouts = [["readouts[1]"], ["readouts[0]", 1]] + diagnostics = list(ReadoutMismatchRule()(gadget, qodec=protocol)) + assert len(diagnostics) == 1 + assert diagnostics[0].summary == ( + "readouts[0].equation, readouts[1].equation are inconsistent" + ) + assert "Verified readout equation for readouts[0]:" in diagnostics[0].detail + assert "Verified readout equation for readouts[1]:" in diagnostics[0].detail + + +@requires_stim +@pytest.mark.parametrize("dependent_flag", [False, True]) +def test_conflict_dependents_are_unverified_not_mismatches( + dependent_flag: bool, +) -> None: + protocol = _c4() + gadget = protocol.layers[0].gadgets["measure_xx"] + if dependent_flag: + gadget.implements.flags = ["reject"] + gadget.readouts = [ + ["readouts[0]", 1], + ( + ["circuit.readouts[0]", "circuit.readouts[2]", "in[0].x[1]"] + if dependent_flag + else ["readouts[0]"] + ), + *([["readouts[0]"]] if dependent_flag else []), + ] + report = Auditor(rules=[ReadoutMismatchRule(), FlagMismatchRule()]).audit_gadget( + gadget, qodec=protocol + ) + assert len(report.errors) == 1 + assert len(report.warnings) == 1 + assert "depends on inconsistent readout equations" in report.warnings[0].detail + + +@requires_stim +def test_flag_conflict_is_reported_once_without_inventing_flag_equations() -> None: + instruction = qc.Instruction("flag_pair", flags=["reject_x", "reject_z"]) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(qc.InstructionSet("physical"), "", format="stim"), + readouts=[["readouts[1]"], ["readouts[0]", 1]], + ) + report = Auditor(rules=[ReadoutMismatchRule(), FlagMismatchRule()]).audit_gadget( + gadget, qodec=qc.Qodec([]) + ) + assert len(report.errors) == 1 + assert report.errors[0].rule == "gadget/flag-mismatch" + assert "readouts[0].equation, readouts[1].equation" in report.errors[0].summary + assert "Verified readout equation" not in report.errors[0].detail + + +@requires_stim +def test_constant_offset_can_still_have_a_verified_readout_equation() -> None: + physical = _c4().layers[1].instruction_set + operand = qc.instructions.BlockOperand("qubit") + instruction = qc.Instruction( + "measure_z", inputs=[operand], action=[qc.actions.Observe(["Z_0"])] + ) + encoding = qc.gadgets.Encoding( + qc.Code("qubit", stabilizers=[], x=["X_0"], z=["Z_0"]), support=["0"] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit(physical, "X 0\nM 0\nR 1\nX 1\nM 1", format="stim"), + inputs=[encoding], + readouts=[["circuit.readouts[0]", "in[0].z[0]"]], + ) + protocol = qc.Qodec([]) + diagnostic = next(iter(ReadoutMismatchRule()(gadget, qodec=protocol))) + assert "Verified readout equation:" in diagnostic.detail + assert "circuit.readouts[1]" in diagnostic.detail.splitlines()[1] + assert "constant inversion" not in diagnostic.summary + assert "does not provide" not in diagnostic.summary diff --git a/source/qdk_package/tests/ec_tests/validation/test_redundancy.py b/source/qdk_package/tests/ec_tests/validation/test_redundancy.py new file mode 100644 index 00000000000..69cf5433a11 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_redundancy.py @@ -0,0 +1,199 @@ +"""Declaration redundancy is distinct from noiseless measurement agreement.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import qodec as qc + +from qdk.ec import audit +from qdk.ec._audit import Auditor, Phase, Severity +from qdk.ec._audit._parity import terms_of +from qdk.ec._audit.rules.code import CodeAlgebraRule, RedundantStabilizerRule +from qdk.ec._audit.rules.gadget import RedundantCheckRule, VacuousCheckRule +from qdk.ec._analysis.propagation.pauli import Pauli + + +@pytest.mark.parametrize( + "equation", + [ + [], + ["circuit.readouts[0]", "circuit.readouts[00]"], + ["circuit.readouts[0:2]", "circuit.readouts[0,1]"], + ], +) +def test_vacuous_checks_are_informational( + rep3_qodec: qc.Qodec, equation: list[str] +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.checks = [equation] + rule = VacuousCheckRule() + report = Auditor(rules=[rule], include_informational=True, strict=True).audit( + rep3_qodec + ) + assert report.ok and not report.warnings + assert rule.phase is Phase.INFORMATIONAL + assert len(report.informational) == 1 + diagnostic = report.informational[0] + assert diagnostic.rule == "gadget/vacuous-check" + assert diagnostic.severity is Severity.INFO + assert diagnostic._path == "checks[0]" + assert ( + json.loads( + diagnostic.detail.splitlines()[0].removeprefix("Declared equation: ") + ) + == equation + ) + assert not list(RedundantCheckRule()(gadget, qodec=rep3_qodec)) + assert not Auditor(rules=[rule]).audit(rep3_qodec).diagnostics + assert ( + not Auditor(rules=[rule], include_informational=True, disabled=[rule.name]) + .audit(rep3_qodec) + .diagnostics + ) + + +def test_redundant_checks_name_earlier_independent_declarations( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + first, second = gadget.checks + gadget.checks = [[], first, first, second, (*first, *second), (*second, *first)] + diagnostics = list(RedundantCheckRule()(gadget, qodec=rep3_qodec)) + assert [item._path for item in diagnostics] == [ + "checks[2]", + "checks[4]", + "checks[5]", + ] + expected = [["checks[1]"], ["checks[1]", "checks[3]"], ["checks[1]", "checks[3]"]] + for diagnostic, factors in zip(diagnostics, expected): + assert diagnostic.severity is Severity.INFO + assert ( + json.loads( + diagnostic.detail.splitlines()[1].removeprefix("XOR of checks: ") + ) + == factors + ) + assert terms_of(gadget.checks[4]) == terms_of( + (*gadget.checks[1], *gadget.checks[3]) + ) + + +def test_selector_spellings_and_term_order_do_not_hide_redundancy( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.checks = [ + ["circuit.readouts[0:2]"], + ["circuit.readouts[01]", "circuit.readouts[0]"], + ] + assert len(list(RedundantCheckRule()(gadget, qodec=rep3_qodec))) == 1 + + +def test_equal_noiseless_measurements_are_not_formally_redundant( + rep3_qodec: qc.Qodec, +) -> None: + gadget = qc.Gadget( + qc.Instruction("probe"), + qc.gadgets.Circuit( + rep3_qodec.layers[1].instruction_set, "R 0\nM 0\nM 0", format="stim" + ), + checks=[["circuit.readouts[0]"], ["circuit.readouts[1]"]], + ) + for rule in (VacuousCheckRule(), RedundantCheckRule()): + assert not list(rule(gadget, qodec=rep3_qodec)) + gadget.circuit.format = "custom" + gadget.checks = [*gadget.checks, ["circuit.readouts[00]"]] + assert len(list(RedundantCheckRule()(gadget, qodec=rep3_qodec))) == 1 + + +def test_readout_terms_remain_distinct_without_substitution( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + gadget.checks = [["readouts[0]"], list(gadget.readouts[0].equation)] + assert not list(RedundantCheckRule()(gadget, qodec=rep3_qodec)) + + +def test_stabilizer_dependency_has_rank_and_exact_product() -> None: + code = qc.Code( + "repetition", + ["Z_0 Z_1", "Z_1 Z_2", "Z_0 Z_2", "Z_0 Z_1"], + ["X_0 X_1 X_2"], + ["Z_0"], + ) + rule = RedundantStabilizerRule() + assert rule.phase is Phase.INFORMATIONAL + report = Auditor( + rules=[CodeAlgebraRule(), rule], include_informational=True, strict=True + ).audit_code(code, qodec=qc.Qodec([])) + assert report.ok and not report.warnings + diagnostics = report.informational + assert [item._path for item in diagnostics] == ["stabilizers[2]", "stabilizers[3]"] + assert ( + 'Product of generators: ["stabilizers[0]", "stabilizers[1]"]' + in diagnostics[0].detail + ) + assert 'Product of generators: ["stabilizers[0]"]' in diagnostics[1].detail + assert all( + "Independent stabilizer rank: 2 of 4" in item.detail for item in diagnostics + ) + assert Pauli(code.stabilizers[0]) * Pauli(code.stabilizers[1]) == Pauli( + code.stabilizers[2] + ) + + +@pytest.mark.parametrize( + "stabilizers, count", [([], 0), (["Z_0", "Z_1"], 0), (["I"], 1)] +) +def test_empty_independent_and_identity_stabilizers( + stabilizers: list[str], count: int +) -> None: + code = qc.Code("code", [*stabilizers], [], []) + findings = list(RedundantStabilizerRule()(code, qodec=qc.Qodec([]))) + assert len(findings) == count + if findings: + assert "operator is +I" in findings[0].detail + assert "rank: 0 of 1" in findings[0].detail + + +@pytest.mark.parametrize( + "stabilizers", [["X_0", "Z_0", "X_0"], ["X_0 X_1", "Z_0 Z_1", "Y_0 Y_1"]] +) +def test_invalid_code_has_no_redundancy_notice(stabilizers: list[str]) -> None: + code = qc.Code("invalid", [*stabilizers], [], []) + report = Auditor( + rules=[CodeAlgebraRule(), RedundantStabilizerRule()], include_informational=True + ).audit_code(code, qodec=qc.Qodec([])) + assert report.errors and not report.informational + assert not list(RedundantStabilizerRule()(code, qodec=qc.Qodec([]))) + + +def test_new_rule_filtering_and_loaded_source_locations( + rep3_qodec: qc.Qodec, tmp_path: Path +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + first = gadget.checks[0] + gadget.checks = [[], *gadget.checks, first] + code = gadget.inputs[0].code + code.stabilizers = [*code.stabilizers, code.stabilizers[0]] + file = tmp_path / "protocol.bundle" + file.write_text(rep3_qodec.dumps(), encoding="utf-8") + protocol = qc.Qodec.load(file) + names = { + "gadget/vacuous-check", + "gadget/redundant-check", + "code/redundant-stabilizer", + } + report = audit(protocol, promote_warnings=True) + findings = [item for item in report.informational if item.rule in names] + assert {item.rule for item in findings} == names + assert all( + item.source_location is not None and item.source_location.path == file + for item in findings + ) + assert not names.intersection( + item.rule for item in audit(protocol, disabled=tuple(names)).diagnostics + ) diff --git a/source/qdk_package/tests/ec_tests/validation/test_structure.py b/source/qdk_package/tests/ec_tests/validation/test_structure.py new file mode 100644 index 00000000000..200716f9fd0 --- /dev/null +++ b/source/qdk_package/tests/ec_tests/validation/test_structure.py @@ -0,0 +1,391 @@ +"""Declaration checks owned by audit rather than persistence.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import qodec as qc + +from qdk.ec._audit import audit +from qdk.ec._audit._structure import _instruction_issues +from qdk.ec._audit._structure import structural_issues +from ec_tests.testing.optional import requires_stim + + +@pytest.mark.parametrize( + "action,parameters,expected", + [ + (qc.actions.Pauli("X_2"), [], "not been introduced"), + ( + qc.actions.Pauli("X_0", condition=qc.actions.Condition(["outcomes[0]"])), + [], + "preceding outcomes", + ), + ( + qc.actions.Pauli("X_0", condition=qc.actions.Condition(["enabled"])), + [], + "bit parameter", + ), + (qc.actions.Rotate("Z_0", "theta"), [], "number or integer"), + ( + qc.actions.Rotate("Z_0", "theta"), + [qc.instructions.Parameter("theta", "string")], + "number or integer", + ), + ], +) +def test_action_declaration_checks( + action: qc.Action, parameters: list[qc.instructions.Parameter], expected: str +) -> None: + instruction = qc.Instruction( + "draft", + inputs=[qc.instructions.BlockOperand("qubit")], + parameters=parameters, + action=[action], + ) + assert any( + expected in issue for issue in _instruction_issues(instruction, {"qubit": 1}) + ) + + +def test_valid_temporary_and_pauli_parameter() -> None: + instruction = qc.Instruction( + "draft", + inputs=[qc.instructions.BlockOperand("q")], + parameters=[qc.instructions.Parameter("p", "pauli")], + action=[ + qc.actions.Stabilize(["Z_3"]), + qc.actions.Pauli("X_3"), + qc.actions.Observe(["p"]), + ], + ) + assert list(_instruction_issues(instruction, {"q": 1})) == [] + + +@requires_stim +def test_invalid_source_and_reference_are_audit_findings(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.circuit.source = "NOT_A_GATE 0" + gadget.checks = [["in[9].x[0]"]] + report = audit(rep3_qodec) + errors = [item for item in report.errors if item.rule == "qodec/invalid-structure"] + assert any("circuit:" in item.summary for item in errors) + assert any("in[9].x[0]" in item.summary for item in errors) + assert not [item for item in report.warnings if "gadgets['idle']" in item.where] + + +def test_registered_formats_get_all_call_structure_checks(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + qc.register( + lambda source, target: [ + qc.instructions.InstructionCall( + "M", operands=[0], arguments={"typo": 1}, select=[{"missing": 0}] + ) + ], + format="custom-structure-checks", + ) + gadget.circuit.format = "custom-structure-checks" + gadget.checks = [["circuit.readouts[1]"]] + messages = [item.summary for item in audit(rep3_qodec).errors] + assert any( + "argument 'typo' is not declared" in message for message in messages + ), messages + assert any("unknown flag 'missing'" in message for message in messages), messages + assert any( + "index 1 is out of bounds for 1 entries" in message for message in messages + ), messages + + +def test_registered_parser_failure_is_a_structure_finding(rep3_qodec: qc.Qodec) -> None: + def fail( + source: str, target: qc.InstructionSet + ) -> list[qc.instructions.InstructionCall]: + raise ValueError("invalid custom source") + + qc.register(fail, format="custom-invalid-source") + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.circuit.format = "custom-invalid-source" + messages = [message for _, message in structural_issues(gadget)] + assert "circuit: invalid custom source" in messages + + +def test_missing_parser_does_not_claim_invalid_source(rep3_qodec: qc.Qodec) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.circuit.format = "unregistered-structure-review" + with pytest.raises(ValueError, match="No source parser registered"): + gadget.circuit.calls() + assert list(structural_issues(gadget)) == [] + + +def test_unequal_code_lists_round_trip_but_analysis_rejects( + rep3_qodec: qc.Qodec, +) -> None: + from qdk.ec import CodeProfile + + code = rep3_qodec.codes["repetition3"] + code.z = [] + rep3_qodec.validate() + reloaded = qc.Qodec.loads(rep3_qodec.dumps()) + assert reloaded.codes[code.name].z == [] + report = audit(reloaded) + assert any("counts disagree" in item.summary for item in report.errors) + with pytest.raises(ValueError, match="counts disagree"): + CodeProfile(code) + + +@pytest.mark.parametrize( + "format,source", + [ + pytest.param("stim", "NOT_A_GATE 0\n", marks=requires_stim), + ("yaml", "[not valid yaml"), + ("custom", "arbitrary text"), + ], +) +def test_source_text_round_trips_before_audit( + rep3_qodec: qc.Qodec, format: str, source: str +) -> None: + gadget = rep3_qodec.layers[0].gadgets["idle"] + gadget.circuit.format = format + gadget.circuit.source = source + reloaded = qc.Qodec.loads(rep3_qodec.dumps()) + circuit = reloaded.layers[0].gadgets["idle"].circuit + assert circuit.source == source + assert circuit.format == format + with pytest.raises(ValueError): + _ = circuit.calls() + report = audit(reloaded) + if format != "custom": + assert any("circuit:" in item.summary for item in report.errors) + + +def test_short_layer_lists_can_be_saved_but_audit_is_incomplete() -> None: + for layers in ([], [qc.Layer(qc.InstructionSet("draft"))]): + protocol = qc.Qodec(layers) + protocol.validate() + assert len(qc.Qodec.loads(protocol.dumps()).layers) == len(layers) + assert any( + "at least two layers" in item.summary for item in audit(protocol).errors + ) + + +@pytest.mark.parametrize( + "kind,value,expected", + [ + ("number", "true", "expected number"), + ("integer", "1.5", "expected integer"), + ("boolean", "1", "expected boolean"), + ("bit", "2", "expected bit"), + ("bit", "'circuit.readouts[0]'", "preceding readouts"), + ("number", "'circuit.readouts[0]'", "only a bit"), + ("pauli", "'Q_0'", "expected pauli"), + ("string", "[alpha, beta]", ""), + ("integer", "[1, 2]", ""), + ("number", "-2", ""), + ("boolean", "false", ""), + ], +) +def test_argument_types_are_audited_after_round_trip( + kind: str, value: str, expected: str +) -> None: + instruction = qc.Instruction( + "probe", parameters=[qc.instructions.Parameter("value", kind)], flags=["reject"] + ) + physical = qc.InstructionSet("physical", instructions=[instruction]) + logical = qc.InstructionSet("logical", instructions=[qc.Instruction("draft")]) + gadget = qc.Gadget( + logical.instructions["draft"], + qc.gadgets.Circuit(physical, f"- probe: [value: {value}]", format="yaml"), + ) + protocol = qc.Qodec([qc.Layer(logical, gadgets=[gadget]), qc.Layer(physical)]) + saved = qc.Qodec.loads(protocol.dumps()) + issues = list(structural_issues(saved)) + if expected: + assert any(expected in message for _, message in issues), issues + else: + assert issues == [] + + +def test_forwarding_and_select_are_checked() -> None: + physical = qc.InstructionSet( + "physical", + instructions=[ + qc.Instruction( + "probe", + parameters=[qc.instructions.Parameter("value", "number")], + flags=["reject"], + ) + ], + ) + instruction = qc.Instruction( + "draft", parameters=[qc.instructions.Parameter("theta", "string")] + ) + gadget = qc.Gadget( + instruction, + qc.gadgets.Circuit( + physical, + "- probe: {arguments: {value: forwarded}, select: [{'flags[2]': 0}]}", + format="yaml", + ), + parameter_bindings={"theta": "forwarded", "missing": "unused"}, + ) + messages = [message for _, message in structural_issues(gadget)] + assert any("incompatible type" in message for message in messages), messages + assert any("unknown flag" in message for message in messages), messages + assert any("binding 'missing'" in message for message in messages), messages + + +def test_preceding_record_and_valid_select_are_accepted() -> None: + instruction = qc.Instruction( + "probe", + parameters=[qc.instructions.Parameter("value", "bit")], + flags=["reject"], + ) + physical = qc.InstructionSet("physical", instructions=[instruction]) + gadget = qc.Gadget( + qc.Instruction("draft"), + qc.gadgets.Circuit( + physical, + "- probe: []\n- probe: {arguments: {value: 'circuit.readouts[0]'}, select: [{reject: 0}, {'flags[0]': 1}]}", + format="yaml", + ), + ) + assert list(structural_issues(gadget)) == [] + + +def test_partial_readouts_and_code_capacity_are_preserved_for_audit( + rep3_qodec: qc.Qodec, +) -> None: + source = rep3_qodec.layers[0] + source.instruction_set.blocks = [qc.instructions.Block("repetition3", encodes=2)] + gadget = source.gadgets["measure_z"] + gadget.readouts = [[], []] + saved = qc.Qodec.loads(rep3_qodec.dumps()) + messages = [message for _, message in structural_issues(saved)] + assert any("requires 2" in message for message in messages), messages + assert any( + "2 entries supplied; expected 1" in message for message in messages + ), messages + + +@pytest.mark.parametrize( + "actions,expected", + [ + ( + [ + qc.actions.Stabilize( + ["Z_3"], condition=qc.actions.Condition(["enabled"]) + ) + ], + "not been introduced", + ), + ( + [qc.actions.Stabilize(["Z_3"]), qc.actions.Pauli("X_2")], + "not been introduced", + ), + ( + [qc.actions.Stabilize(["I_3"]), qc.actions.Pauli("X_3")], + "not been introduced", + ), + ( + [ + qc.actions.Observe(["Z_0"]), + qc.actions.Pauli( + "X_0", condition=qc.actions.Condition(["outcomes[0]"]) + ), + ], + "", + ), + ], +) +def test_action_order_and_sparse_temporaries( + actions: list[qc.Action], expected: str +) -> None: + instruction = qc.Instruction( + "draft", + inputs=[qc.instructions.BlockOperand("q")], + parameters=[qc.instructions.Parameter("enabled", "bit")], + action=actions, + ) + messages = list(_instruction_issues(instruction, {"q": 1})) + if expected: + assert any(expected in message for message in messages), messages + else: + assert messages == [] + + +def test_attached_instruction_errors_are_not_repeated_on_gadgets( + rep3_qodec: qc.Qodec, +) -> None: + layer = rep3_qodec.layers[0] + gadget = layer.gadgets["idle"] + original = gadget.implements + instruction = qc.Instruction( + "idle", + inputs=original.inputs, + outputs=original.outputs, + action=[qc.actions.Rotate("Z_0", "undeclared_angle")], + ) + gadget.implements = instruction + layer.instruction_set.instructions = { + **layer.instruction_set.instructions, + "idle": instruction, + } + report = audit(rep3_qodec) + errors = [item for item in report.errors if "undeclared_angle" in item.summary] + assert len(errors) == 1 + assert errors[0].where.startswith("layers[0].instruction_set") + + detached_errors = list(structural_issues(gadget)) + assert sum("undeclared_angle" in message for _, message in detached_errors) == 1 + + +def test_uninspectable_action_does_not_hide_independent_reference_errors( + tmp_path: Path, +) -> None: + path = tmp_path / "conditional.isa.yaml" + path.write_text( + json.dumps( + { + "name": "conditional", + "blocks": {}, + "instructions": [ + { + "mnemonic": "measure", + "description": "draft", + "action": [{"observe": "Z_0", "if": ["enabled"]}], + } + ], + } + ) + ) + logical = qc.InstructionSet.load(path) + physical = qc.InstructionSet("physical") + gadget = qc.Gadget( + logical.instructions["measure"], + qc.gadgets.Circuit(physical, "[]", format="yaml"), + readouts=[["readouts[2]"]], + ) + protocol = qc.Qodec([qc.Layer(logical, gadgets=[gadget]), qc.Layer(physical)]) + report = audit(protocol) + assert len(report.errors) == 2 + assert any("action cannot be inspected" in item.summary for item in report.errors) + assert any("readouts[2]" in item.summary for item in report.errors) + + +def test_unmatched_encoding_codes_are_checked_without_crossing_boundaries( + rep3_qodec: qc.Qodec, +) -> None: + gadget = rep3_qodec.layers[0].gadgets["measure_z"] + code = qc.Code("unmatched", stabilizers=[], x=["X_0"], z=[]) + gadget.outputs = [qc.gadgets.Encoding(code, support=["0"])] + issues = list(structural_issues(rep3_qodec)) + assert any( + path == 'codes["unmatched"]' and "counts disagree" in message + for path, message in issues + ) + assert any("out: 1 encodings for 0 operands" in message for _, message in issues) + assert not any("bound to different codes" in message for _, message in issues) + report = audit(rep3_qodec) + assert not any(item.rule == "code/invalid-algebra" for item in report.errors) diff --git a/source/qdk_package/tests/test_check_api_surface.py b/source/qdk_package/tests/test_check_api_surface.py new file mode 100644 index 00000000000..15a2dace9d3 --- /dev/null +++ b/source/qdk_package/tests/test_check_api_surface.py @@ -0,0 +1,117 @@ +"""The API checker must distinguish unavailable exports from a clean scan.""" + +import json +from pathlib import Path +import sys +import types + +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) +import check_api_surface as checker + + +@pytest.mark.parametrize( + "error", [ModuleNotFoundError("missing backend"), AttributeError("missing export")] +) +def test_unavailable_export_reports_incomplete_scan(monkeypatch, capsys, error) -> None: + module = types.ModuleType("qdk.example") + module.__all__ = ["Public"] + + def unavailable(name): + raise error + + module.__getattr__ = unavailable + monkeypatch.setattr( + checker, "_iter_qdk_modules", lambda: [(module.__name__, module)] + ) + monkeypatch.setattr(sys, "argv", ["check_api_surface.py"]) + assert checker.main() == 2 + output = capsys.readouterr() + assert "API scan incomplete" in output.err + assert "qdk.example.Public" in output.err + assert "No private API leakage" not in output.err + + +def test_programming_errors_in_lazy_exports_propagate() -> None: + module = types.ModuleType("qdk.example") + + def broken(name): + raise ValueError("invalid initialization") + + module.__getattr__ = broken + with pytest.raises(ValueError, match="invalid initialization"): + checker._lazy_getattr(module, module.__name__, "Public") + + +def test_missing_declared_export_retains_the_cause() -> None: + with pytest.raises(checker.ScanIncomplete, match="qdk.example.Missing") as caught: + checker._lazy_getattr(types.ModuleType("qdk.example"), "qdk.example", "Missing") + assert isinstance(caught.value.__cause__, AttributeError) + + +def test_class_member_failures_are_not_silently_skipped(monkeypatch) -> None: + class BrokenMeta(type): + def __getattribute__(cls, name): + if name == "method": + raise RuntimeError("broken descriptor") + return super().__getattribute__(name) + + class Public(metaclass=BrokenMeta): + def method(self): + pass + + module = types.ModuleType("qdk.example") + module.__all__ = ["Public"] + module.Public = Public + monkeypatch.setattr( + checker, "_iter_qdk_modules", lambda: [(module.__name__, module)] + ) + with pytest.raises(RuntimeError, match="broken descriptor"): + checker.scan() + + +def test_incomplete_scan_has_machine_readable_output(monkeypatch, capsys) -> None: + def incomplete(): + raise checker.ScanIncomplete("missing module") + + monkeypatch.setattr(checker, "scan", incomplete) + monkeypatch.setattr(sys, "argv", ["check_api_surface.py", "--json"]) + assert checker.main() == 2 + assert json.loads(capsys.readouterr().out) == { + "error": "incomplete scan", + "detail": "missing module", + } + + +def test_complete_empty_scan_succeeds(monkeypatch, capsys) -> None: + module = types.ModuleType("qdk.example") + module.__all__ = ["constant"] + module.constant = None + monkeypatch.setattr( + checker, "_iter_qdk_modules", lambda: [(module.__name__, module)] + ) + monkeypatch.setattr(sys, "argv", ["check_api_surface.py"]) + assert checker.main() == 0 + assert "No private API leakage" in capsys.readouterr().err + + +def test_module_import_failures_warn_and_are_skipped(monkeypatch, capsys) -> None: + root = types.ModuleType("qdk") + root.__path__ = [] + monkeypatch.setattr(checker, "_import_root_package", lambda: root) + monkeypatch.setattr( + checker.pkgutil, + "walk_packages", + lambda *args, **kwargs: [(None, "qdk.example", False)], + ) + + def unavailable(name): + raise ImportError("missing dependency") + + monkeypatch.setattr(checker.importlib, "import_module", unavailable) + assert checker.scan() == [] + assert ( + "WARNING: could not import qdk.example: missing dependency" + in capsys.readouterr().err + )