Repository: CQCL/pytket-phir

Issue:
Error simulating circuit with condition based on more than two bits
**Describe the bug**
I am not sure if this is a bug in pytket-phir or pecos. When an operation in a circuit is conditional on the values of more than two bits, the generated PHIR contains a section like this:

```json
        {
            "block": "if",
            "condition":
            {
                "cop": "&",
                "args":
                [
                    {
                        "cop": "==",
                        "args":
                        [
                            [
                                "c",
                                2
                            ],
                            0
                        ]
                    },
                    {
                        "cop": "==",
                        "args":
                        [
                            [
                                "c",
                                1
                            ],
                            0
                        ]
                    },
                    {
                        "cop": "==",
                        "args":
                        [
                            [
                                "c",
                                0
                            ],
                            0
                        ]
                    }
                ]
            },
            "true_branch":
            [
                {
                    "qop": "RZ",
                    "angles":
                    [
                        [
                            0.5
                        ],
                        "pi"
                    ],
                    "args":
                    [
                        [
                            "q",
                            0
                        ]
                    ]
                }
            ]
        }
```

When this PHIR is passed to the simulation engine, we get an error:

```
Traceback (most recent call last):
  File "/home/alec/r/pytket-quantinuum/silas.py", line 19, in <module>
    res = engine.run(phir, shots=10)
  File "/home/alec/r/pytket-quantinuum/env/lib/python3.10/site-packages/pecos/engines/hybrid_engine.py", line 175, in run
    for buffered_ops in self.cinterp.execute(self.cinterp.program.ops):
  File "/home/alec/r/pytket-quantinuum/env/lib/python3.10/site-packages/pecos/classical_interpreters/phir_classical_interpreter.py", line 164, in execute
    for op in self._flatten_blocks(seq):
  File "/home/alec/r/pytket-quantinuum/env/lib/python3.10/site-packages/pecos/classical_interpreters/phir_classical_interpreter.py", line 149, in _flatten_blocks
    if self.eval_expr(op.condition):
  File "/home/alec/r/pytket-quantinuum/env/lib/python3.10/site-packages/pecos/classical_interpreters/phir_classical_interpreter.py", line 216, in eval_expr
    lhs, rhs = args
ValueError: too many values to unpack (expected 2)
```

Somewhere in the stack there seems to be an assumption that the `&` operator must have 2 arguments.

**To Reproduce**

```python
from pytket.circuit import Bit, Circuit, Qubit
from pytket.phir.api import pytket_to_phir
from pecos.engines.hybrid_engine import HybridEngine

n_bits = 3
c = Circuit(1, n_bits)
c.Rz(0.5, 0, condition_bits=list(range(n_bits)), condition_value=0)
phir = pytket_to_phir(c)
engine = HybridEngine(qsim="state-vector")
engine.use_seed(0)
res = engine.run(phir, shots=10)
```

**Expected behavior**
The circuit should be simulated without error.

**Additional context**
Issue originally raised here: https://github.com/CQCL/pytket-pecos/issues/39 .

Discussion:
@qartik: The PHIR generated does look okay to me:

```py
PHIRModel(
    format_='PHIR/JSON',
    version='0.1.0',
    metadata={'source': 'pytket-phir v0.8.1.dev22'},
    ops=[
        QVarDefine(metadata=None, data='qvar_define', data_type='qubits', variable='q', size=1),
        CVarDefine(metadata=None, data='cvar_define', data_type='i64', variable='c', size=3),
        Comment(c='IF ([c[0], c[1], c[2]] == 0) THEN Rz(0.5) q[0];'),
        IfBlock(
            metadata=None,
            block='if',
            condition=COp(
                metadata=None,
                cop='&',
                returns=None,
                args=[
                    COp(metadata=None, cop='==', returns=None, args=[('c', 2), 0]),
                    COp(metadata=None, cop='==', returns=None, args=[('c', 1), 0]),
                    COp(metadata=None, cop='==', returns=None, args=[('c', 0), 0])
                ]
            ),
            true_branch=[SQOp(metadata=None, qop='RZ', angles=([0.5], 'pi'), args=[('q', 0)])],
            false_branch=None
        )
    ]
)
```

The error seems like it is in PECOS as it doesn't seem to be accounting for the AND of conditions:

```py
self = <pecos.classical_interpreters.phir_classical_interpreter.PHIRClassicalInterpreter object at 0x130a55280>, expr = <pecos.reps.pypmir.op_types.COp object at 0x130d027e0>

    def eval_expr(self, expr: int | str | list | pt.opt.COp) -> int | None:
        """Evaluates integer expressions."""
        match expr:
            case int():
                return expr

            case str():
                return self.get_cval(expr)
            case list():
                return self.get_bit(*expr)
            case pt.opt.COp():
                sym = expr.name
                args = expr.args

                if sym in {"~"}:  # Unary ops
                    lhs = args[0]
                    rhs = None
                else:
>                   lhs, rhs = args
E                   ValueError: too many values to unpack (expected 2)

.venv/lib/python3.12/site-packages/pecos/classical_interpreters/phir_classical_interpreter.py:216: ValueError
```

@qciaran could you confirm?@cqc-alec: Yes, I thought this might be a pecos issue but wasn't entirely sure whether the intended semantics of the `cop='&'` covered the case of more than two arguments.@qciaran: Yeah, I would think an `&` that contains anything but two arguments would be wrong... I intended `&` to be a pure bitwise binary operations. So `a & b` or `a[0] & b[1]` is valid but `&(a[0], b[1], c[2])` is not. That is interesting to consider. But given the current syntax... If the entire register can't be used, such as `if(c==0)`, then I would imagine you would want `(c[0] == 0) & (c[1] == 0) & (c[2] == 0)` or equivalently `(((c[0] == 0) & (c[1] == 0)) & (c[2] == 0))`.  @qciaran: I have wondered if we should introduce slices of some sort...@qartik: > If the entire register can't be used, such as `if(c==0)`

If this is a common use case (which seems true to me), I am happy to change phirgen to try to generate register-level condition. I did something similar for `MultiBitOp` at https://github.com/CQCL/pytket-phir/blob/main/pytket/phir/phirgen.py#L320-L332

The question of allowing only two arguments with classical ops vs more remains. I am happy either way -- either phirgen can produce nested binary expressions or PECOS can consider supporting multiple arguments.@qciaran: I think for now, if greater than two things do need ANDing then it should be done so in using the nested binary approach.

Yeah, if this is just representing an entire register and is being broken down into bits, it might be nice to generate the register-level condition as you say.

As far as bit ANDing and other ways to express it, I think I need more time to think if there is an alternative way of expressing it that I am comfortable with. And it might require a decent change to the PHIR spec.

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index 0280127..7647bb5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,8 +8,11 @@ name = "pytket-phir"
 description = "A circuit analyzer and translator from pytket to PHIR"
 readme = "README.md"
 requires-python = ">=3.10, <3.13"
-license = {file = "LICENSE"}
-authors = [{name = "Quantinuum"}]
+license = { file = "LICENSE" }
+authors = [{ name = "Quantinuum" }]
+maintainers = [
+  { name = "Kartik Singhal", email = "Kartik.Singhal@quantinuum.com" },
+]
 
 classifiers = [
   "Environment :: Console",
@@ -50,9 +53,7 @@ where = ["."]
 
 [tool.pytest.ini_options]
 addopts = "-s -vv"
-pythonpath = [
-  "."
-]
+pythonpath = ["."]
 log_cli = true
 log_cli_level = "INFO"
 log_level = "DEBUG"
diff --git a/pytket/phir/phirgen.py b/pytket/phir/phirgen.py
index 5cfb4e0..0db08b3 100644
--- a/pytket/phir/phirgen.py
+++ b/pytket/phir/phirgen.py
@@ -11,6 +11,7 @@
 import json
 import logging
 import sys
+from collections import deque
 from copy import deepcopy
 from importlib.metadata import version
 from typing import TYPE_CHECKING, Any, TypeAlias
@@ -356,15 +357,29 @@ def convert_classicalevalop(op: tk.ClassicalEvalOp, cmd: tk.Command) -> JsonDict
 
 def multi_bit_condition(args: "list[UnitID]", value: int) -> JsonDict:
     """Construct bitwise condition."""
-    return {
-        "cop": "&",
-        "args": [
-            {"cop": "==", "args": [arg_to_bit(arg), bval]}
-            for (arg, bval) in zip(
-                args[::-1], map(int, f"{value:0{len(args)}b}"), strict=True
-            )
-        ],
-    }
+    min_args = 2
+    if len(args) < min_args:
+        msg = f"multi_bit_condition requires at least {min_args} arguments"
+        raise TypeError(msg)
+
+    def nested_cop(cop: str, args: "deque[UnitID]", val_bits: deque[int]) -> JsonDict:
+        if len(args) == min_args:
+            return {
+                "cop": cop,
+                "args": [
+                    {"cop": "==", "args": [arg_to_bit(args.popleft()), val_bits.pop()]},
+                    {"cop": "==", "args": [arg_to_bit(args.popleft()), val_bits.pop()]},
+                ],
+            }
+        return {
+            "cop": cop,
+            "args": [
+                {"cop": "==", "args": [arg_to_bit(args.popleft()), val_bits.pop()]},
+                nested_cop(cop, args, val_bits),
+            ],
+        }
+
+    return nested_cop("&", deque(args), deque(map(int, f"{value:0{len(args)}b}")))
 
 
 def convert_subcmd(op: tk.Op, cmd: tk.Command) -> JsonDict | None:  # noqa: PLR0912
diff --git a/requirements.txt b/requirements.txt
index 0649295..2e682ab 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,7 +4,7 @@ networkx<3
 phir==0.3.3
 pre-commit==3.8.0
 pydata_sphinx_theme==0.15.4
-pytest==8.3.2
+pytest==8.3.3
 pytket==1.32.0
 ruff==0.6.4
 setuptools_scm==8.1.0


Test patch:
diff --git a/tests/test_phirgen.py b/tests/test_phirgen.py
index bbaed38..d4a28b5 100644
--- a/tests/test_phirgen.py
+++ b/tests/test_phirgen.py
@@ -94,8 +94,8 @@ def test_pytket_classical_only() -> None:
         "condition": {
             "cop": "&",
             "args": [
-                {"cop": "==", "args": [["b", 2], 1]},
                 {"cop": "==", "args": [["b", 1], 0]},
+                {"cop": "==", "args": [["b", 2], 1]},
             ],
         },
         "true_branch": [
@@ -177,8 +177,8 @@ def test_conditional_barrier() -> None:
         "condition": {
             "cop": "&",
             "args": [
-                {"cop": "==", "args": [["m", 1], 0]},
                 {"cop": "==", "args": [["m", 0], 0]},
+                {"cop": "==", "args": [["m", 1], 0]},
             ],
         },
         "true_branch": [{"meta": "barrier", "args": [["q", 0], ["q", 1]]}],
@@ -463,3 +463,26 @@ def test_nullary_ops() -> None:
         "cop": "==",
         "args": [["tk_SCRATCH_BIT", 1], 1],  # evals to False
     }
+
+
+def test_condition_multiple_bits() -> None:
+    """From https://github.com/CQCL/pytket-phir/issues/215 ."""
+    n_bits = 3
+    c = Circuit(1, n_bits)
+    c.Rz(0.5, 0, condition_bits=list(range(n_bits)), condition_value=6)
+    phir = json.loads(pytket_to_phir(c))
+
+    assert phir["ops"][2] == {"//": "IF ([c[0], c[1], c[2]] == 6) THEN Rz(0.5) q[0];"}
+    assert phir["ops"][3]["condition"] == {
+        "cop": "&",
+        "args": [
+            {"cop": "==", "args": [["c", 0], 0]},
+            {
+                "cop": "&",
+                "args": [
+                    {"cop": "==", "args": [["c", 1], 1]},
+                    {"cop": "==", "args": [["c", 2], 1]},
+                ],
+            },
+        ],
+    }