API Reference

Auto-generated from the library's docstrings.

Core

hdh.hdh.HDH

A Hybrid Dependency Hypergraph: the model-agnostic representation this library is built around.

An HDH represents a quantum workload (from any computational model — circuits, MBQC patterns, quantum walks, QCA) as a directed hypergraph of node states connected by hyperedges that model operations. It's usually not constructed directly; instead, build one of hdh.models.circuit.Circuit, hdh.models.mbqc.MBQC, hdh.models.qw.QW, or hdh.models.qca.QCA and call its build_hdh(), or convert an existing circuit with hdh.converters.qiskit_converter.from_qiskit (or the Cirq/PennyLane/Braket equivalents).

Node IDs are strings of the form q{index}_t{timestep} (quantum) or c{index}_t{timestep} (classical) — the prefix must always match the node's sigma type, and hyperedges connect a set of such nodes to represent one operation's effect on the states it touches.

Attributes:
  • S (Set[NodeID]) –

    All node IDs in the hypergraph.

  • C (Set[frozenset]) –

    All hyperedges, each a frozenset of node IDs.

  • T (Set[TimeStep]) –

    All distinct timesteps that appear in time_map.

  • sigma (Dict[NodeID, NodeType]) –

    Node ID -> "q" (quantum) or "c" (classical).

  • tau (Dict[frozenset, EdgeType]) –

    Hyperedge -> "q" or "c", mirroring sigma for edges.

  • upsilon (Dict[NodeID, NodeReal]) –

    Node ID -> "a" (actualized) or "p" (potential/predicted, e.g. a state that only exists if a classical condition holds).

  • phi (Dict[frozenset, EdgeReal]) –

    Hyperedge -> "a" or "p", mirroring upsilon for edges.

  • time_map (Dict[NodeID, TimeStep]) –

    Node ID -> the timestep it occurs at.

  • gate_name (Dict[frozenset, str]) –

    Hyperedge -> the gate/operation name that produced it (e.g. "h", "cx_stage2", "measure").

  • gate_params (Dict[frozenset, List[float]]) –

    Hyperedge -> rotation angles / gate parameters, for hyperedges from a parametric gate that had params recorded.

  • edge_args (Dict[frozenset, Tuple[List[int], List[int], List[bool]]]) –

    Hyperedge -> (qubits_with_time, bits_with_time, modifies_flags), used by converters to reconstruct a circuit representation from the HDH.

  • edge_role (Dict[frozenset, Literal['teledata', 'telegate']]) –

    Hyperedge -> "teledata" or "telegate", for hyperedges that have been assigned a distribution primitive.

  • edge_metadata (Dict[frozenset, Dict]) –

    Free-form per-hyperedge metadata.

  • motifs –

    Reserved for motif-matching passes; unused by the core API.

Source code in hdh/hdh.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
class HDH:
    """A Hybrid Dependency Hypergraph: the model-agnostic representation this
    library is built around.

    An HDH represents a quantum workload (from any computational model —
    circuits, MBQC patterns, quantum walks, QCA) as a directed hypergraph of
    node *states* connected by hyperedges that model operations. It's usually
    not constructed directly; instead, build one of `hdh.models.circuit.Circuit`,
    `hdh.models.mbqc.MBQC`, `hdh.models.qw.QW`, or `hdh.models.qca.QCA` and call
    its `build_hdh()`, or convert an existing circuit with
    `hdh.converters.qiskit_converter.from_qiskit` (or the Cirq/PennyLane/Braket
    equivalents).

    Node IDs are strings of the form ``q{index}_t{timestep}`` (quantum) or
    ``c{index}_t{timestep}`` (classical) — the prefix must always match the
    node's `sigma` type, and hyperedges connect a set of such nodes to
    represent one operation's effect on the states it touches.

    Attributes:
        S: All node IDs in the hypergraph.
        C: All hyperedges, each a `frozenset` of node IDs.
        T: All distinct timesteps that appear in `time_map`.
        sigma: Node ID -> `"q"` (quantum) or `"c"` (classical).
        tau: Hyperedge -> `"q"` or `"c"`, mirroring `sigma` for edges.
        upsilon: Node ID -> `"a"` (actualized) or `"p"` (potential/predicted,
            e.g. a state that only exists if a classical condition holds).
        phi: Hyperedge -> `"a"` or `"p"`, mirroring `upsilon` for edges.
        time_map: Node ID -> the timestep it occurs at.
        gate_name: Hyperedge -> the gate/operation name that produced it
            (e.g. ``"h"``, ``"cx_stage2"``, ``"measure"``).
        gate_params: Hyperedge -> rotation angles / gate parameters, for
            hyperedges from a parametric gate that had params recorded.
        edge_args: Hyperedge -> `(qubits_with_time, bits_with_time,
            modifies_flags)`, used by converters to reconstruct a circuit
            representation from the HDH.
        edge_role: Hyperedge -> `"teledata"` or `"telegate"`, for hyperedges
            that have been assigned a distribution primitive.
        edge_metadata: Free-form per-hyperedge metadata.
        motifs: Reserved for motif-matching passes; unused by the core API.
    """

    def __init__(self):
        self.S: Set[NodeID] = set()
        self.C: Set[frozenset] = set()
        self.T: Set[TimeStep] = set()
        self.sigma: Dict[NodeID, NodeType] = {}  # node types 
        self.tau: Dict[frozenset, EdgeType] = {}  # hyperedge types
        self.upsilon: Dict[NodeID, NodeReal] = {} # node realization a,p
        self.phi: Dict[frozenset, EdgeReal] = {} # hyperedge realization 
        self.time_map: Dict[NodeID, TimeStep] = {}  # f: S -> T
        self.gate_name: Dict[frozenset, str] = {}  # maps hyperedge → gate name string
        self.gate_params: Dict[frozenset, List[float]] = {}  # maps hyperedge → rotation params, if any
        self.edge_args: Dict[frozenset, Tuple[List[int], List[int], List[bool]]] = {} #mapping for nackwards translations
        self.edge_role: Dict[frozenset, Literal["teledata", "telegate"]] = {}  # tracks nature edges -> for primitive implementation
        self.motifs = {}  
        self.edge_metadata: Dict[frozenset, Dict] = {}

    def add_node(self, node_id: NodeID, node_type: NodeType, time: TimeStep, node_real: NodeReal = "a"):
        """Add a node, or no-op if an identical node already exists.

        Args:
            node_id: Node ID, e.g. ``"q0_t0"`` or ``"c1_t2"``. The leading
                letter must match `node_type` ("q"/"c") — see `sigma`.
            node_type: `"q"` (quantum) or `"c"` (classical).
            time: Timestep this node occurs at.
            node_real: `"a"` (actualized) or `"p"` (potential).

        Raises:
            ValueError: If `node_id` already exists with a *different*
                `node_type`. Re-adding the same ID with the same type is
                fine (e.g. a later gate referencing an already-created
                input node) and simply leaves the existing node untouched.
        """
        existing_type = self.sigma.get(node_id)
        if existing_type is not None and existing_type != node_type:
            raise ValueError(
                f"Node '{node_id}' already exists with type '{existing_type}'; "
                f"cannot redefine it as type '{node_type}'. This usually means two "
                f"different logical values were mapped to the same node ID."
            )
        self.S.add(node_id)
        self.sigma[node_id] = node_type
        self.time_map[node_id] = time
        self.T.add(time)
        self.upsilon[node_id] = node_real

    def add_hyperedge(self, node_ids: Set[NodeID], edge_type: EdgeType, name: Optional[str] = None, node_real: EdgeReal = "a", role: Optional[Literal["teledata", "telegate"]] = None):
        """Add a hyperedge connecting `node_ids`, representing one operation.

        Args:
            node_ids: The nodes this operation touches (its inputs and
                outputs together, since HDH edges are undirected).
            edge_type: `"q"` (quantum) or `"c"` (classical) — see `tau`.
            name: Operation name, e.g. ``"h"``, ``"cx_stage2"``, ``"measure"``.
                Stored lower-cased in `gate_name`; omit for an unnamed edge.
            node_real: `"a"` (actualized) or `"p"` (potential) — see `phi`.
            role: Distribution primitive this edge has been assigned, if any
                — `"teledata"` or `"telegate"`. Usually set later by a
                partitioning pass, not at construction time.

        Returns:
            frozenset: the edge, as added to `C` — use this as the key into
            `tau`/`phi`/`gate_name`/`edge_args`/`gate_params`/etc.
        """
        edge = frozenset(node_ids)
        self.C.add(edge)
        self.tau[edge] = edge_type
        self.phi[edge] = node_real
        if name:
            self.gate_name[edge] = name.lower()
        if role:
            self.edge_role[edge] = role
        return edge

    def get_ancestry(self, node: NodeID) -> Set[NodeID]:
        """Return nodes with paths ending at `node` and earlier time steps."""
        return {
            s for s in self.S
            if self.time_map[s] <= self.time_map[node] and self._path_exists(s, node)
        }

    def get_lineage(self, node: NodeID) -> Set[NodeID]:
        """Return nodes reachable from `node` with later time steps."""
        return {
            s for s in self.S
            if self.time_map[s] >= self.time_map[node] and self._path_exists(node, s)
        }

    def _path_exists(self, start: NodeID, end: NodeID) -> bool:
        """DFS to find a time-respecting path from `start` to `end`."""
        visited = set()
        stack = [start]
        while stack:
            current = stack.pop()
            if current == end:
                return True
            visited.add(current)
            neighbors = {
                neighbor
                for edge in self.C if current in edge
                for neighbor in edge
                if neighbor != current and self.time_map[neighbor] > self.time_map[current]
            }
            stack.extend(neighbors - visited)
        return False

    def get_num_qubits(self) -> int:
        """Return the number of logical qubits, inferred from node IDs.

        Computed as `max(qubit_index) + 1` over every quantum node's index
        (e.g. ``"q4_t2"`` -> qubit 4), not a stored count — so it reflects
        the highest qubit index actually used, not necessarily how many
        distinct qubits are involved (a circuit that only uses qubit 0 and
        qubit 4 still reports 5).

        Returns:
            int: number of qubits, or 0 if there are no quantum nodes.
        """
        qubit_indices = set()
        for node_id in self.S:
            if self.sigma[node_id] == 'q':
                try:
                    base = node_id.split('_')[0]  # e.g. "q4"
                    idx = int(base[1:])  # skip 'q'
                    qubit_indices.add(idx)
                except:
                    continue
        return max(qubit_indices) + 1 if qubit_indices else 0

add_hyperedge(node_ids, edge_type, name=None, node_real='a', role=None)

Add a hyperedge connecting node_ids, representing one operation.

Parameters:
  • node_ids (Set[NodeID]) –

    The nodes this operation touches (its inputs and outputs together, since HDH edges are undirected).

  • edge_type (EdgeType) –

    "q" (quantum) or "c" (classical) — see tau.

  • name (Optional[str], default: None ) –

    Operation name, e.g. "h", "cx_stage2", "measure". Stored lower-cased in gate_name; omit for an unnamed edge.

  • node_real (EdgeReal, default: 'a' ) –

    "a" (actualized) or "p" (potential) — see phi.

  • role (Optional[Literal['teledata', 'telegate']], default: None ) –

    Distribution primitive this edge has been assigned, if any — "teledata" or "telegate". Usually set later by a partitioning pass, not at construction time.

Returns:
  • frozenset –

    the edge, as added to C — use this as the key into

  • –

    tau/phi/gate_name/edge_args/gate_params/etc.

Source code in hdh/hdh.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def add_hyperedge(self, node_ids: Set[NodeID], edge_type: EdgeType, name: Optional[str] = None, node_real: EdgeReal = "a", role: Optional[Literal["teledata", "telegate"]] = None):
    """Add a hyperedge connecting `node_ids`, representing one operation.

    Args:
        node_ids: The nodes this operation touches (its inputs and
            outputs together, since HDH edges are undirected).
        edge_type: `"q"` (quantum) or `"c"` (classical) — see `tau`.
        name: Operation name, e.g. ``"h"``, ``"cx_stage2"``, ``"measure"``.
            Stored lower-cased in `gate_name`; omit for an unnamed edge.
        node_real: `"a"` (actualized) or `"p"` (potential) — see `phi`.
        role: Distribution primitive this edge has been assigned, if any
            — `"teledata"` or `"telegate"`. Usually set later by a
            partitioning pass, not at construction time.

    Returns:
        frozenset: the edge, as added to `C` — use this as the key into
        `tau`/`phi`/`gate_name`/`edge_args`/`gate_params`/etc.
    """
    edge = frozenset(node_ids)
    self.C.add(edge)
    self.tau[edge] = edge_type
    self.phi[edge] = node_real
    if name:
        self.gate_name[edge] = name.lower()
    if role:
        self.edge_role[edge] = role
    return edge

add_node(node_id, node_type, time, node_real='a')

Add a node, or no-op if an identical node already exists.

Parameters:
  • node_id (NodeID) –

    Node ID, e.g. "q0_t0" or "c1_t2". The leading letter must match node_type ("q"/"c") — see sigma.

  • node_type (NodeType) –

    "q" (quantum) or "c" (classical).

  • time (TimeStep) –

    Timestep this node occurs at.

  • node_real (NodeReal, default: 'a' ) –

    "a" (actualized) or "p" (potential).

Raises:
  • ValueError –

    If node_id already exists with a different node_type. Re-adding the same ID with the same type is fine (e.g. a later gate referencing an already-created input node) and simply leaves the existing node untouched.

Source code in hdh/hdh.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def add_node(self, node_id: NodeID, node_type: NodeType, time: TimeStep, node_real: NodeReal = "a"):
    """Add a node, or no-op if an identical node already exists.

    Args:
        node_id: Node ID, e.g. ``"q0_t0"`` or ``"c1_t2"``. The leading
            letter must match `node_type` ("q"/"c") — see `sigma`.
        node_type: `"q"` (quantum) or `"c"` (classical).
        time: Timestep this node occurs at.
        node_real: `"a"` (actualized) or `"p"` (potential).

    Raises:
        ValueError: If `node_id` already exists with a *different*
            `node_type`. Re-adding the same ID with the same type is
            fine (e.g. a later gate referencing an already-created
            input node) and simply leaves the existing node untouched.
    """
    existing_type = self.sigma.get(node_id)
    if existing_type is not None and existing_type != node_type:
        raise ValueError(
            f"Node '{node_id}' already exists with type '{existing_type}'; "
            f"cannot redefine it as type '{node_type}'. This usually means two "
            f"different logical values were mapped to the same node ID."
        )
    self.S.add(node_id)
    self.sigma[node_id] = node_type
    self.time_map[node_id] = time
    self.T.add(time)
    self.upsilon[node_id] = node_real

get_ancestry(node)

Return nodes with paths ending at node and earlier time steps.

Source code in hdh/hdh.py
126
127
128
129
130
131
def get_ancestry(self, node: NodeID) -> Set[NodeID]:
    """Return nodes with paths ending at `node` and earlier time steps."""
    return {
        s for s in self.S
        if self.time_map[s] <= self.time_map[node] and self._path_exists(s, node)
    }

get_lineage(node)

Return nodes reachable from node with later time steps.

Source code in hdh/hdh.py
133
134
135
136
137
138
def get_lineage(self, node: NodeID) -> Set[NodeID]:
    """Return nodes reachable from `node` with later time steps."""
    return {
        s for s in self.S
        if self.time_map[s] >= self.time_map[node] and self._path_exists(node, s)
    }

get_num_qubits()

Return the number of logical qubits, inferred from node IDs.

Computed as max(qubit_index) + 1 over every quantum node's index (e.g. "q4_t2" -> qubit 4), not a stored count — so it reflects the highest qubit index actually used, not necessarily how many distinct qubits are involved (a circuit that only uses qubit 0 and qubit 4 still reports 5).

Returns:
  • int( int ) –

    number of qubits, or 0 if there are no quantum nodes.

Source code in hdh/hdh.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def get_num_qubits(self) -> int:
    """Return the number of logical qubits, inferred from node IDs.

    Computed as `max(qubit_index) + 1` over every quantum node's index
    (e.g. ``"q4_t2"`` -> qubit 4), not a stored count — so it reflects
    the highest qubit index actually used, not necessarily how many
    distinct qubits are involved (a circuit that only uses qubit 0 and
    qubit 4 still reports 5).

    Returns:
        int: number of qubits, or 0 if there are no quantum nodes.
    """
    qubit_indices = set()
    for node_id in self.S:
        if self.sigma[node_id] == 'q':
            try:
                base = node_id.split('_')[0]  # e.g. "q4"
                idx = int(base[1:])  # skip 'q'
                qubit_indices.add(idx)
            except:
                continue
    return max(qubit_indices) + 1 if qubit_indices else 0

Models

hdh.models.circuit.Circuit

Gate-model quantum circuit builder that compiles down to an HDH.

Instructions are recorded in order via add_instruction (and, for classically-conditioned gates, add_conditional_gate), then translated into an HDH's nodes and hyperedges by build_hdh. This mirrors how you'd build a circuit in Qiskit/Cirq/etc., but stores gates as a flat list rather than executing them immediately.

Attributes:
  • instructions (List[Tuple[str, List[int], List[int], List[bool], Literal['a', 'p'], Optional[List[float]]]]) –

    Recorded gates, one tuple per call to add_instruction: (name, qubits, bits, modifies_flags, cond_flag, params). Built up by add_instruction/add_conditional_gate and consumed by build_hdh — not usually read or written directly.

Source code in hdh/models/circuit.py
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
class Circuit:
    """Gate-model quantum circuit builder that compiles down to an HDH.

    Instructions are recorded in order via ``add_instruction`` (and, for
    classically-conditioned gates, ``add_conditional_gate``), then translated
    into an HDH's nodes and hyperedges by ``build_hdh``. This mirrors how you'd
    build a circuit in Qiskit/Cirq/etc., but stores gates as a flat list rather
    than executing them immediately.

    Attributes:
        instructions: Recorded gates, one tuple per call to ``add_instruction``:
            ``(name, qubits, bits, modifies_flags, cond_flag, params)``. Built
            up by ``add_instruction``/``add_conditional_gate`` and consumed by
            ``build_hdh`` — not usually read or written directly.
    """

    def __init__(self):
        self.instructions: List[
            Tuple[str, List[int], List[int], List[bool], Literal["a", "p"], Optional[List[float]]]
        ] = []  # (name, qubits, bits, modifies_flags, cond_flag, params)

    def add_instruction(
        self,
        name: str,
        qubits: List[int],
        bits: Optional[List[int]] = None,
        modifies_flags: Optional[List[bool]] = None,
        cond_flag: Literal["a", "p"] = "a",
        params: Optional[List[float]] = None,
    ):
        """Append one gate or measurement to the circuit.

        Args:
            name: Gate name (e.g. ``"h"``, ``"cx"``, ``"rx"``, ``"measure"``).
                Case-insensitive; stored lower-cased.
            qubits: Qubit indices the instruction acts on, in order.
            bits: Classical bit indices involved. For ``"measure"``, defaults
                to a 1:1 mapping with `qubits` if omitted; ignored for
                unconditional gates unless explicitly provided.
            modifies_flags: Per-qubit flag marking whether that qubit's state
                is actually changed by this instruction. Defaults to all
                `True`. Rarely needed directly — prefer `add_conditional_gate`
                for classically-controlled gates.
            cond_flag: `"a"` (actualized) for an unconditional instruction, or
                `"p"` (potential) for one whose effect depends on a classical
                condition not yet known at build time.
            params: Rotation angles / gate parameters (e.g. ``[theta]`` for an
                `rx` gate), if the gate is parametric. Stored alongside the
                instruction and later attached to the corresponding HDH
                hyperedge via `HDH.gate_params` so they survive a round trip
                through `build_hdh` and back to a circuit representation.
        """
        name = name.lower()

        if name == "measure":
            modifies_flags = [True] * len(qubits)
        else:
            bits = bits or []
            modifies_flags = modifies_flags or [True] * len(qubits)

        self.instructions.append((name, qubits, bits, modifies_flags, cond_flag, params))

    def add_conditional_gate(
        self,
        classical_bit: int,
        target_qubit: int,
        gate_name: str,
        additional_qubits: Optional[List[int]] = None,
        modifies_flags: Optional[List[bool]] = None,
        params: Optional[List[float]] = None,
    ):
        """Append a gate whose application is conditioned on a classical bit.

        Convenience wrapper around `add_instruction` for the common
        single-classical-control case (e.g. a mid-circuit-measurement
        feed-forward gate): it sets `cond_flag="p"` and puts `classical_bit`
        in the instruction's `bits`, so the resulting HDH marks the gate's
        output as a *potential* (not yet actualized) state until that
        classical value is known. `classical_bit` must already have a value
        by the time this instruction executes — typically produced by an
        earlier `add_instruction("measure", ...)` call.

        Args:
            classical_bit: Index of the classical bit the gate is conditioned on.
            target_qubit: Primary qubit the gate acts on.
            gate_name: Gate name, as in `add_instruction`.
            additional_qubits: Extra qubits for a multi-qubit conditional gate,
                applied after `target_qubit`.
            modifies_flags: As in `add_instruction`; defaults to all `True`.
            params: Rotation angles / gate parameters, as in `add_instruction`.
        """
        gate_name = gate_name.lower()

        # Build the qubit list
        if additional_qubits is None:
            qubits = [target_qubit]
        else:
            qubits = [target_qubit] + additional_qubits

        # Set default modifies_flags
        if modifies_flags is None:
            modifies_flags = [True] * len(qubits)

        # Add the instruction with cond_flag="p" (positive condition)
        # and the classical bit in the bits list
        self.add_instruction(
            name=gate_name,
            qubits=qubits,
            bits=[classical_bit],
            modifies_flags=modifies_flags,
            cond_flag="p",
            params=params,
        )

    def build_hdh(self, hdh_cls=HDH) -> HDH:
        """Translate the recorded instructions into an HDH.

        Each qubit/bit gets one node per timestep it's touched, named
        ``q{idx}_t{time}`` / ``c{idx}_t{time}``. Single-qubit gates add one
        hyperedge connecting a qubit's input and output node at consecutive
        timesteps.

        Multi-qubit gates are deliberately spread across *three* timesteps
        per gate rather than one, via three hyperedges suffixed
        ``_stage1``/``_stage2``/``_stage3``: stage 1 and 3 are per-qubit wire
        continuity (input->intermediate, final->post), and stage 2 is the
        single hyperedge spanning every involved qubit's intermediate and
        final nodes. This is intentional — it's what lets the HDH represent
        pre- and post-gate teleportation as separate cuttable edges rather
        than only a single all-or-nothing gate boundary — so a multi-qubit
        gate's apparent "depth" in `time_map` is 3 ticks even though it's one
        logical operation.

        Args:
            hdh_cls: HDH class to instantiate (override for a subclass).

        Returns:
            HDH: the built hypergraph, with `S`/`C`/`sigma`/`tau`/`time_map`
            and friends populated. `edge_args` and `gate_params` are also
            populated per gate, letting `hdh.converters.qiskit_converter.to_qiskit`
            (and similar) reconstruct a circuit from it.
        """
        hdh = hdh_cls()
        qubit_time: Dict[int, int] = {}
        bit_time: Dict[int, int] = {}
        last_gate_input_time: Dict[int, int] = {}

        for name, qargs, cargs, modifies_flags, cond_flag, params in self.instructions:
            # --- Canonicalize inputs ---
            qargs = list(qargs or [])
            if name == "measure":
                cargs = list(cargs) if cargs is not None else qargs.copy()  # 1:1 map
                if len(cargs) != len(qargs):
                    raise ValueError("measure: len(bits) must equal len(qubits)")
                modifies_flags = [True] * len(qargs)
            else:
                cargs = list(cargs or [])
                if modifies_flags is None:
                    modifies_flags = [True] * len(qargs)
                elif len(modifies_flags) != len(qargs):
                    raise ValueError("len(modifies_flags) must equal len(qubits)")

            # Measurements
            if name == "measure":
                for i, qubit in enumerate(qargs):
                    # Use current qubit time (default 0), do NOT advance it here
                    t_in = qubit_time.get(qubit, 0)
                    q_in = f"q{qubit}_t{t_in}"

                    # Check if node already exists - preserve its potential status
                    if q_in not in hdh.S:
                        hdh.add_node(q_in, "q", t_in, node_real="a")  # Default to actual

                    bit = cargs[i]
                    t_out = t_in + 1              # classical result at next tick
                    c_out = f"c{bit}_t{t_out}"
                    # Classical output is always actual - measurement is unconditional
                    hdh.add_node(c_out, "c", t_out, node_real=cond_flag)

                    # Measurement hyperedge is always actual - the operation itself is unconditional
                    # (even if measuring a potential quantum state)
                    hdh.add_hyperedge({q_in, c_out}, "c", name="measure", node_real=cond_flag)

                    # Next-free convention for this bit stream
                    bit_time[bit] = t_out + 1

                    # Important: do NOT set qubit_time[qubit] = t_in + k
                    # The quantum wire collapses; keep its last quantum tick unchanged.
                continue

            # Conditional gate handling
            if name != "measure" and cond_flag == "p" and cargs:
                # Supports 1 classical control; extend to many if you like
                ctrl = cargs[0]

                # Ensure times exist
                for q in qargs:
                    if q not in qubit_time:
                        qubit_time[q] = 0  # ← Initialize at t=0
                        last_gate_input_time[q] = 0  # ← Initialize at t=0

                # Classical node must already exist (e.g., produced by a prior measure)
                # bit_time points to "next free" slot; the latest existing node is at t = bit_time-1
                c_latest = bit_time.get(ctrl, 1) - 1
                cnode = f"c{ctrl}_t{c_latest}"
                hdh.add_node(cnode, "c", c_latest, node_real="a")  # Classical node is actual

                edges = []
                for tq in qargs:
                    # gate happens at next tick after both inputs are ready
                    t_in_q = qubit_time[tq]
                    t_gate = max(t_in_q, c_latest) + 1
                    qname = f"q{tq}"

                    # Create input quantum node (actual state before conditional)
                    qin = f"{qname}_t{t_in_q}"
                    hdh.add_node(qin, "q", t_in_q, node_real="a")

                    # Create output quantum node (potential state after conditional)
                    qout = f"{qname}_t{t_gate}"
                    hdh.add_node(qout, "q", t_gate, node_real=cond_flag)

                    # Add quantum hyperedge for wire continuity (potential)
                    q_edge = hdh.add_hyperedge({qin, qout}, "q", name=name, node_real=cond_flag)
                    edges.append(q_edge)

                    # Add classical hyperedge for conditional dependency (potential)
                    c_edge = hdh.add_hyperedge({cnode, qout}, "c", name=name, node_real=cond_flag)
                    edges.append(c_edge)

                    # advance time
                    last_gate_input_time[tq] = t_in_q
                    qubit_time[tq] = t_gate

                # store edge_args for reconstruction/debug
                q_with_time = [(q, qubit_time[q]) for q in qargs]
                c_with_time = [(ctrl, c_latest + 1)]  # next-free convention; adjust if you track exact
                for e in edges:
                    hdh.edge_args[e] = (q_with_time, c_with_time, modifies_flags or [True] * len(qargs))
                    if params:
                        hdh.gate_params[e] = params

                continue

            #Actualized gate (non-conditional)
            for q in qargs:
                if q not in qubit_time:
                    qubit_time[q] = 0  # ← Initialize at t=0
                    last_gate_input_time[q] = 0  # ← Initialize at t=0

            active_times = [qubit_time[q] for q in qargs]
            time_step = max(active_times) + 1 if active_times else 0

            in_nodes: List[str] = []
            out_nodes: List[str] = []

            intermediate_nodes: List[str] = []
            final_nodes: List[str] = []
            post_nodes: List[str] = []

            multi_gate = (name != "measure" and len(qargs) > 1)
            common_start = max((qubit_time.get(q, 0) for q in qargs), default=0) if multi_gate else None

            for i, qubit in enumerate(qargs):
                t_in = qubit_time[qubit]
                qname = f"q{qubit}"
                in_id = f"{qname}_t{t_in}"
                hdh.add_node(in_id, "q", t_in, node_real=cond_flag)
                in_nodes.append(in_id)

                # choose timeline
                if multi_gate:
                    t1 = common_start + 1
                    t2 = common_start + 2
                    t3 = common_start + 3

                    # FIX ISSUE #37: Create intermediate nodes INSIDE loop for each qubit
                    mid_id   = f"{qname}_t{t1}"
                    final_id = f"{qname}_t{t2}"
                    post_id  = f"{qname}_t{t3}"

                    hdh.add_node(mid_id,   "q", t1, node_real=cond_flag)
                    hdh.add_node(final_id, "q", t2, node_real=cond_flag)
                    hdh.add_node(post_id,  "q", t3, node_real=cond_flag)

                    intermediate_nodes.append(mid_id)
                    final_nodes.append(final_id)
                    post_nodes.append(post_id)

                    last_gate_input_time[qubit] = t_in
                    qubit_time[qubit] = t3
                else:
                    # Single-qubit gates: don't create nodes here
                    # created by the single-qubit handler below
                    t1 = t_in + 1
                    t2 = t1 + 1
                    t3 = t2 + 1

            edges = []
            if len(qargs) > 1:
                # Multi-qubit gate
                # Stage 1: input → intermediate (1:1)
                for in_node, mid_node in zip(in_nodes, intermediate_nodes):
                    e = hdh.add_hyperedge({in_node, mid_node}, "q", name=f"{name}_stage1", node_real=cond_flag)
                    edges.append(e)

                # Stage 2: full multiqubit edge from intermediate → final
                e2 = hdh.add_hyperedge(set(intermediate_nodes) | set(final_nodes), "q", name=f"{name}_stage2", node_real=cond_flag)
                edges.append(e2)

                # Stage 3: final → post (1:1)
                for f_node, p_node in zip(final_nodes, post_nodes):
                    e = hdh.add_hyperedge({f_node, p_node}, "q", name=f"{name}_stage3", node_real=cond_flag)
                    edges.append(e)

            if name == "measure":
                for i, qubit in enumerate(qargs):
                    t_in = qubit_time.get(qubit, 0)
                    q_in = f"q{qubit}_t{t_in}"
                    hdh.add_node(q_in, "q", t_in, node_real=cond_flag)

                    bit = cargs[i]
                    t_out = t_in + 1
                    c_out = f"c{bit}_t{t_out}"
                    hdh.add_node(c_out, "c", t_out, node_real=cond_flag)

                    hdh.add_hyperedge({q_in, c_out}, "c", name="measure", node_real=cond_flag)
                    bit_time[bit] = t_out + 1
                continue

            if name != "measure":
                for bit in cargs:
                    t = bit_time.get(bit, 0)
                    cname = f"c{bit}"
                    out_id = f"{cname}_t{t + 1}"
                    hdh.add_node(out_id, "c", t + 1, node_real=cond_flag)
                    out_nodes.append(out_id)
                    bit_time[bit] = t + 1

            all_nodes = set(in_nodes) | set(out_nodes)
            if all(n.startswith("c") for n in all_nodes):
                edge_type = "c"
            elif any(n.startswith("c") for n in all_nodes):
                edge_type = "c"
            else:
                edge_type = "q"

            if len(qargs) == 1:
                # Single-qubit gate
                for i, qubit in enumerate(qargs):
                    if modifies_flags[i] and name != "measure":
                        # Use current qubit_time
                        t_in = qubit_time[qubit]  
                        t_out = t_in + 1
                        qname = f"q{qubit}"
                        in_id = f"{qname}_t{t_in}"
                        out_id = f"{qname}_t{t_out}"
                        hdh.add_node(out_id, "q", t_out, node_real=cond_flag)
                        edge = hdh.add_hyperedge({in_id, out_id}, "q", name=name, node_real=cond_flag)
                        edges.append(edge)
                        # Update time for next gate
                        qubit_time[qubit] = t_out
                        last_gate_input_time[qubit] = t_in

            q_with_time = [(q, qubit_time[q]) for q in qargs]
            c_with_time = [(c, bit_time.get(c, 0)) for c in cargs]
            for edge in edges:
                hdh.edge_args[edge] = (q_with_time, c_with_time, modifies_flags)
                if params:
                    hdh.gate_params[edge] = params

        return hdh

add_conditional_gate(classical_bit, target_qubit, gate_name, additional_qubits=None, modifies_flags=None, params=None)

Append a gate whose application is conditioned on a classical bit.

Convenience wrapper around add_instruction for the common single-classical-control case (e.g. a mid-circuit-measurement feed-forward gate): it sets cond_flag="p" and puts classical_bit in the instruction's bits, so the resulting HDH marks the gate's output as a potential (not yet actualized) state until that classical value is known. classical_bit must already have a value by the time this instruction executes — typically produced by an earlier add_instruction("measure", ...) call.

Parameters:
  • classical_bit (int) –

    Index of the classical bit the gate is conditioned on.

  • target_qubit (int) –

    Primary qubit the gate acts on.

  • gate_name (str) –

    Gate name, as in add_instruction.

  • additional_qubits (Optional[List[int]], default: None ) –

    Extra qubits for a multi-qubit conditional gate, applied after target_qubit.

  • modifies_flags (Optional[List[bool]], default: None ) –

    As in add_instruction; defaults to all True.

  • params (Optional[List[float]], default: None ) –

    Rotation angles / gate parameters, as in add_instruction.

Source code in hdh/models/circuit.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def add_conditional_gate(
    self,
    classical_bit: int,
    target_qubit: int,
    gate_name: str,
    additional_qubits: Optional[List[int]] = None,
    modifies_flags: Optional[List[bool]] = None,
    params: Optional[List[float]] = None,
):
    """Append a gate whose application is conditioned on a classical bit.

    Convenience wrapper around `add_instruction` for the common
    single-classical-control case (e.g. a mid-circuit-measurement
    feed-forward gate): it sets `cond_flag="p"` and puts `classical_bit`
    in the instruction's `bits`, so the resulting HDH marks the gate's
    output as a *potential* (not yet actualized) state until that
    classical value is known. `classical_bit` must already have a value
    by the time this instruction executes — typically produced by an
    earlier `add_instruction("measure", ...)` call.

    Args:
        classical_bit: Index of the classical bit the gate is conditioned on.
        target_qubit: Primary qubit the gate acts on.
        gate_name: Gate name, as in `add_instruction`.
        additional_qubits: Extra qubits for a multi-qubit conditional gate,
            applied after `target_qubit`.
        modifies_flags: As in `add_instruction`; defaults to all `True`.
        params: Rotation angles / gate parameters, as in `add_instruction`.
    """
    gate_name = gate_name.lower()

    # Build the qubit list
    if additional_qubits is None:
        qubits = [target_qubit]
    else:
        qubits = [target_qubit] + additional_qubits

    # Set default modifies_flags
    if modifies_flags is None:
        modifies_flags = [True] * len(qubits)

    # Add the instruction with cond_flag="p" (positive condition)
    # and the classical bit in the bits list
    self.add_instruction(
        name=gate_name,
        qubits=qubits,
        bits=[classical_bit],
        modifies_flags=modifies_flags,
        cond_flag="p",
        params=params,
    )

add_instruction(name, qubits, bits=None, modifies_flags=None, cond_flag='a', params=None)

Append one gate or measurement to the circuit.

Parameters:
  • name (str) –

    Gate name (e.g. "h", "cx", "rx", "measure"). Case-insensitive; stored lower-cased.

  • qubits (List[int]) –

    Qubit indices the instruction acts on, in order.

  • bits (Optional[List[int]], default: None ) –

    Classical bit indices involved. For "measure", defaults to a 1:1 mapping with qubits if omitted; ignored for unconditional gates unless explicitly provided.

  • modifies_flags (Optional[List[bool]], default: None ) –

    Per-qubit flag marking whether that qubit's state is actually changed by this instruction. Defaults to all True. Rarely needed directly — prefer add_conditional_gate for classically-controlled gates.

  • cond_flag (Literal['a', 'p'], default: 'a' ) –

    "a" (actualized) for an unconditional instruction, or "p" (potential) for one whose effect depends on a classical condition not yet known at build time.

  • params (Optional[List[float]], default: None ) –

    Rotation angles / gate parameters (e.g. [theta] for an rx gate), if the gate is parametric. Stored alongside the instruction and later attached to the corresponding HDH hyperedge via HDH.gate_params so they survive a round trip through build_hdh and back to a circuit representation.

Source code in hdh/models/circuit.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def add_instruction(
    self,
    name: str,
    qubits: List[int],
    bits: Optional[List[int]] = None,
    modifies_flags: Optional[List[bool]] = None,
    cond_flag: Literal["a", "p"] = "a",
    params: Optional[List[float]] = None,
):
    """Append one gate or measurement to the circuit.

    Args:
        name: Gate name (e.g. ``"h"``, ``"cx"``, ``"rx"``, ``"measure"``).
            Case-insensitive; stored lower-cased.
        qubits: Qubit indices the instruction acts on, in order.
        bits: Classical bit indices involved. For ``"measure"``, defaults
            to a 1:1 mapping with `qubits` if omitted; ignored for
            unconditional gates unless explicitly provided.
        modifies_flags: Per-qubit flag marking whether that qubit's state
            is actually changed by this instruction. Defaults to all
            `True`. Rarely needed directly — prefer `add_conditional_gate`
            for classically-controlled gates.
        cond_flag: `"a"` (actualized) for an unconditional instruction, or
            `"p"` (potential) for one whose effect depends on a classical
            condition not yet known at build time.
        params: Rotation angles / gate parameters (e.g. ``[theta]`` for an
            `rx` gate), if the gate is parametric. Stored alongside the
            instruction and later attached to the corresponding HDH
            hyperedge via `HDH.gate_params` so they survive a round trip
            through `build_hdh` and back to a circuit representation.
    """
    name = name.lower()

    if name == "measure":
        modifies_flags = [True] * len(qubits)
    else:
        bits = bits or []
        modifies_flags = modifies_flags or [True] * len(qubits)

    self.instructions.append((name, qubits, bits, modifies_flags, cond_flag, params))

build_hdh(hdh_cls=HDH)

Translate the recorded instructions into an HDH.

Each qubit/bit gets one node per timestep it's touched, named q{idx}_t{time} / c{idx}_t{time}. Single-qubit gates add one hyperedge connecting a qubit's input and output node at consecutive timesteps.

Multi-qubit gates are deliberately spread across three timesteps per gate rather than one, via three hyperedges suffixed _stage1/_stage2/_stage3: stage 1 and 3 are per-qubit wire continuity (input->intermediate, final->post), and stage 2 is the single hyperedge spanning every involved qubit's intermediate and final nodes. This is intentional — it's what lets the HDH represent pre- and post-gate teleportation as separate cuttable edges rather than only a single all-or-nothing gate boundary — so a multi-qubit gate's apparent "depth" in time_map is 3 ticks even though it's one logical operation.

Parameters:
  • hdh_cls –

    HDH class to instantiate (override for a subclass).

Returns:
  • HDH( HDH ) –

    the built hypergraph, with S/C/sigma/tau/time_map

  • HDH –

    and friends populated. edge_args and gate_params are also

  • HDH –

    populated per gate, letting hdh.converters.qiskit_converter.to_qiskit

  • HDH –

    (and similar) reconstruct a circuit from it.

Source code in hdh/models/circuit.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def build_hdh(self, hdh_cls=HDH) -> HDH:
    """Translate the recorded instructions into an HDH.

    Each qubit/bit gets one node per timestep it's touched, named
    ``q{idx}_t{time}`` / ``c{idx}_t{time}``. Single-qubit gates add one
    hyperedge connecting a qubit's input and output node at consecutive
    timesteps.

    Multi-qubit gates are deliberately spread across *three* timesteps
    per gate rather than one, via three hyperedges suffixed
    ``_stage1``/``_stage2``/``_stage3``: stage 1 and 3 are per-qubit wire
    continuity (input->intermediate, final->post), and stage 2 is the
    single hyperedge spanning every involved qubit's intermediate and
    final nodes. This is intentional — it's what lets the HDH represent
    pre- and post-gate teleportation as separate cuttable edges rather
    than only a single all-or-nothing gate boundary — so a multi-qubit
    gate's apparent "depth" in `time_map` is 3 ticks even though it's one
    logical operation.

    Args:
        hdh_cls: HDH class to instantiate (override for a subclass).

    Returns:
        HDH: the built hypergraph, with `S`/`C`/`sigma`/`tau`/`time_map`
        and friends populated. `edge_args` and `gate_params` are also
        populated per gate, letting `hdh.converters.qiskit_converter.to_qiskit`
        (and similar) reconstruct a circuit from it.
    """
    hdh = hdh_cls()
    qubit_time: Dict[int, int] = {}
    bit_time: Dict[int, int] = {}
    last_gate_input_time: Dict[int, int] = {}

    for name, qargs, cargs, modifies_flags, cond_flag, params in self.instructions:
        # --- Canonicalize inputs ---
        qargs = list(qargs or [])
        if name == "measure":
            cargs = list(cargs) if cargs is not None else qargs.copy()  # 1:1 map
            if len(cargs) != len(qargs):
                raise ValueError("measure: len(bits) must equal len(qubits)")
            modifies_flags = [True] * len(qargs)
        else:
            cargs = list(cargs or [])
            if modifies_flags is None:
                modifies_flags = [True] * len(qargs)
            elif len(modifies_flags) != len(qargs):
                raise ValueError("len(modifies_flags) must equal len(qubits)")

        # Measurements
        if name == "measure":
            for i, qubit in enumerate(qargs):
                # Use current qubit time (default 0), do NOT advance it here
                t_in = qubit_time.get(qubit, 0)
                q_in = f"q{qubit}_t{t_in}"

                # Check if node already exists - preserve its potential status
                if q_in not in hdh.S:
                    hdh.add_node(q_in, "q", t_in, node_real="a")  # Default to actual

                bit = cargs[i]
                t_out = t_in + 1              # classical result at next tick
                c_out = f"c{bit}_t{t_out}"
                # Classical output is always actual - measurement is unconditional
                hdh.add_node(c_out, "c", t_out, node_real=cond_flag)

                # Measurement hyperedge is always actual - the operation itself is unconditional
                # (even if measuring a potential quantum state)
                hdh.add_hyperedge({q_in, c_out}, "c", name="measure", node_real=cond_flag)

                # Next-free convention for this bit stream
                bit_time[bit] = t_out + 1

                # Important: do NOT set qubit_time[qubit] = t_in + k
                # The quantum wire collapses; keep its last quantum tick unchanged.
            continue

        # Conditional gate handling
        if name != "measure" and cond_flag == "p" and cargs:
            # Supports 1 classical control; extend to many if you like
            ctrl = cargs[0]

            # Ensure times exist
            for q in qargs:
                if q not in qubit_time:
                    qubit_time[q] = 0  # ← Initialize at t=0
                    last_gate_input_time[q] = 0  # ← Initialize at t=0

            # Classical node must already exist (e.g., produced by a prior measure)
            # bit_time points to "next free" slot; the latest existing node is at t = bit_time-1
            c_latest = bit_time.get(ctrl, 1) - 1
            cnode = f"c{ctrl}_t{c_latest}"
            hdh.add_node(cnode, "c", c_latest, node_real="a")  # Classical node is actual

            edges = []
            for tq in qargs:
                # gate happens at next tick after both inputs are ready
                t_in_q = qubit_time[tq]
                t_gate = max(t_in_q, c_latest) + 1
                qname = f"q{tq}"

                # Create input quantum node (actual state before conditional)
                qin = f"{qname}_t{t_in_q}"
                hdh.add_node(qin, "q", t_in_q, node_real="a")

                # Create output quantum node (potential state after conditional)
                qout = f"{qname}_t{t_gate}"
                hdh.add_node(qout, "q", t_gate, node_real=cond_flag)

                # Add quantum hyperedge for wire continuity (potential)
                q_edge = hdh.add_hyperedge({qin, qout}, "q", name=name, node_real=cond_flag)
                edges.append(q_edge)

                # Add classical hyperedge for conditional dependency (potential)
                c_edge = hdh.add_hyperedge({cnode, qout}, "c", name=name, node_real=cond_flag)
                edges.append(c_edge)

                # advance time
                last_gate_input_time[tq] = t_in_q
                qubit_time[tq] = t_gate

            # store edge_args for reconstruction/debug
            q_with_time = [(q, qubit_time[q]) for q in qargs]
            c_with_time = [(ctrl, c_latest + 1)]  # next-free convention; adjust if you track exact
            for e in edges:
                hdh.edge_args[e] = (q_with_time, c_with_time, modifies_flags or [True] * len(qargs))
                if params:
                    hdh.gate_params[e] = params

            continue

        #Actualized gate (non-conditional)
        for q in qargs:
            if q not in qubit_time:
                qubit_time[q] = 0  # ← Initialize at t=0
                last_gate_input_time[q] = 0  # ← Initialize at t=0

        active_times = [qubit_time[q] for q in qargs]
        time_step = max(active_times) + 1 if active_times else 0

        in_nodes: List[str] = []
        out_nodes: List[str] = []

        intermediate_nodes: List[str] = []
        final_nodes: List[str] = []
        post_nodes: List[str] = []

        multi_gate = (name != "measure" and len(qargs) > 1)
        common_start = max((qubit_time.get(q, 0) for q in qargs), default=0) if multi_gate else None

        for i, qubit in enumerate(qargs):
            t_in = qubit_time[qubit]
            qname = f"q{qubit}"
            in_id = f"{qname}_t{t_in}"
            hdh.add_node(in_id, "q", t_in, node_real=cond_flag)
            in_nodes.append(in_id)

            # choose timeline
            if multi_gate:
                t1 = common_start + 1
                t2 = common_start + 2
                t3 = common_start + 3

                # FIX ISSUE #37: Create intermediate nodes INSIDE loop for each qubit
                mid_id   = f"{qname}_t{t1}"
                final_id = f"{qname}_t{t2}"
                post_id  = f"{qname}_t{t3}"

                hdh.add_node(mid_id,   "q", t1, node_real=cond_flag)
                hdh.add_node(final_id, "q", t2, node_real=cond_flag)
                hdh.add_node(post_id,  "q", t3, node_real=cond_flag)

                intermediate_nodes.append(mid_id)
                final_nodes.append(final_id)
                post_nodes.append(post_id)

                last_gate_input_time[qubit] = t_in
                qubit_time[qubit] = t3
            else:
                # Single-qubit gates: don't create nodes here
                # created by the single-qubit handler below
                t1 = t_in + 1
                t2 = t1 + 1
                t3 = t2 + 1

        edges = []
        if len(qargs) > 1:
            # Multi-qubit gate
            # Stage 1: input → intermediate (1:1)
            for in_node, mid_node in zip(in_nodes, intermediate_nodes):
                e = hdh.add_hyperedge({in_node, mid_node}, "q", name=f"{name}_stage1", node_real=cond_flag)
                edges.append(e)

            # Stage 2: full multiqubit edge from intermediate → final
            e2 = hdh.add_hyperedge(set(intermediate_nodes) | set(final_nodes), "q", name=f"{name}_stage2", node_real=cond_flag)
            edges.append(e2)

            # Stage 3: final → post (1:1)
            for f_node, p_node in zip(final_nodes, post_nodes):
                e = hdh.add_hyperedge({f_node, p_node}, "q", name=f"{name}_stage3", node_real=cond_flag)
                edges.append(e)

        if name == "measure":
            for i, qubit in enumerate(qargs):
                t_in = qubit_time.get(qubit, 0)
                q_in = f"q{qubit}_t{t_in}"
                hdh.add_node(q_in, "q", t_in, node_real=cond_flag)

                bit = cargs[i]
                t_out = t_in + 1
                c_out = f"c{bit}_t{t_out}"
                hdh.add_node(c_out, "c", t_out, node_real=cond_flag)

                hdh.add_hyperedge({q_in, c_out}, "c", name="measure", node_real=cond_flag)
                bit_time[bit] = t_out + 1
            continue

        if name != "measure":
            for bit in cargs:
                t = bit_time.get(bit, 0)
                cname = f"c{bit}"
                out_id = f"{cname}_t{t + 1}"
                hdh.add_node(out_id, "c", t + 1, node_real=cond_flag)
                out_nodes.append(out_id)
                bit_time[bit] = t + 1

        all_nodes = set(in_nodes) | set(out_nodes)
        if all(n.startswith("c") for n in all_nodes):
            edge_type = "c"
        elif any(n.startswith("c") for n in all_nodes):
            edge_type = "c"
        else:
            edge_type = "q"

        if len(qargs) == 1:
            # Single-qubit gate
            for i, qubit in enumerate(qargs):
                if modifies_flags[i] and name != "measure":
                    # Use current qubit_time
                    t_in = qubit_time[qubit]  
                    t_out = t_in + 1
                    qname = f"q{qubit}"
                    in_id = f"{qname}_t{t_in}"
                    out_id = f"{qname}_t{t_out}"
                    hdh.add_node(out_id, "q", t_out, node_real=cond_flag)
                    edge = hdh.add_hyperedge({in_id, out_id}, "q", name=name, node_real=cond_flag)
                    edges.append(edge)
                    # Update time for next gate
                    qubit_time[qubit] = t_out
                    last_gate_input_time[qubit] = t_in

        q_with_time = [(q, qubit_time[q]) for q in qargs]
        c_with_time = [(c, bit_time.get(c, 0)) for c in cargs]
        for edge in edges:
            hdh.edge_args[edge] = (q_with_time, c_with_time, modifies_flags)
            if params:
                hdh.gate_params[edge] = params

    return hdh

hdh.models.mbqc.MBQC

Measurement-based quantum computing (MBQC) pattern builder.

Patterns are sequences of NEMC operations — N (auxiliary state preparation), E (entanglement), M (measurement), C (classical correction) — recorded via add_operation and translated into an HDH by build_hdh.

Unlike hdh.models.circuit.Circuit, node labels here are caller-chosen strings rather than auto-derived from a qubit index: MBQC nodes don't necessarily correspond 1:1 with qubits, so there's no automatic q_/c_ naming. By convention, though, a label's type must stay consistent across every operation that references it (e.g. always use "c0" for a classical output, never reuse it as a quantum input) — HDH node IDs still encode their sigma type via prefix, and HDH.add_node raises if the same label is reused with a different inferred type.

Source code in hdh/models/mbqc.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
class MBQC:
    """Measurement-based quantum computing (MBQC) pattern builder.

    Patterns are sequences of NEMC operations — N (auxiliary state
    preparation), E (entanglement), M (measurement), C (classical correction)
    — recorded via `add_operation` and translated into an HDH by `build_hdh`.

    Unlike `hdh.models.circuit.Circuit`, node labels here are caller-chosen
    strings rather than auto-derived from a qubit index: MBQC nodes don't
    necessarily correspond 1:1 with qubits, so there's no automatic
    ``q_``/``c_`` naming. By convention, though, a label's type must stay
    consistent across every operation that references it (e.g. always use
    ``"c0"`` for a classical output, never reuse it as a quantum input) — HDH
    node IDs still encode their `sigma` type via prefix, and `HDH.add_node`
    raises if the same label is reused with a different inferred type.
    """

    def __init__(self, hdh_cls=HDH):
        self.pattern = []  # (op_type, A, b)
        self.hdh_cls = hdh_cls

    def add_operation(self, op_type: str, A: List[str], b: str):
        """Append one NEMC operation to the pattern.

        Args:
            op_type: One of ``"N"``, ``"E"``, ``"M"``, ``"C"`` (case-insensitive).
            A: Input node label(s) the operation reads. Empty for ``"N"``
                (it has no inputs, only produces `b`).
            b: Output node label the operation produces or measures. For
                ``"E"`` (entanglement), reuse one of the entangled nodes'
                existing labels rather than introducing a new one.
        """
        self.pattern.append((op_type.upper(), A, b))

    def build_hdh(self) -> HDH:
        """Translate the recorded NEMC pattern into an HDH.

        Each operation gets its own timestep, in recording order. Node type
        (quantum vs. classical) is inferred per operation via `_node_type`:
        N produces a quantum output from a classical placeholder input, E is
        purely quantum, M consumes a quantum input and produces a classical
        output, and C is purely classical.

        Returns:
            HDH: the built hypergraph.
        """
        hdh = self.hdh_cls()
        time_map = {}
        current_time = 0

        for op_type, A, b in self.pattern:
            in_nodes = set()
            out_nodes = set()
            all_nodes = A + [b]

            # Assign time steps
            op_time = current_time
            current_time += 1

            for x in A:
                t = time_map.get(x, 0)
                hdh.add_node(f"{x}_t{t}", self._node_type(op_type, input=True), t)
                in_nodes.add(f"{x}_t{t}")

            hdh.add_node(f"{b}_t{op_time}", self._node_type(op_type, input=False), op_time)
            out_nodes.add(f"{b}_t{op_time}")
            time_map[b] = op_time

            edge_nodes = in_nodes | out_nodes
            hdh.add_hyperedge(edge_nodes, self._edge_type(op_type), name=op_type.lower())

        return hdh

    def _node_type(self, op_type, input=False):
        """Map an NEMC op type + input/output position to a `sigma` value."""
        if op_type == "N":
            return "c" if input else "q"
        if op_type == "E":
            return "q"
        if op_type == "M":
            return "q" if input else "c"
        if op_type == "C":
            return "c"

    def _edge_type(self, op_type):
        """Map an NEMC op type to a `tau` value: only E is quantum."""
        return "q" if op_type == "E" else "c"

add_operation(op_type, A, b)

Append one NEMC operation to the pattern.

Parameters:
  • op_type (str) –

    One of "N", "E", "M", "C" (case-insensitive).

  • A (List[str]) –

    Input node label(s) the operation reads. Empty for "N" (it has no inputs, only produces b).

  • b (str) –

    Output node label the operation produces or measures. For "E" (entanglement), reuse one of the entangled nodes' existing labels rather than introducing a new one.

Source code in hdh/models/mbqc.py
31
32
33
34
35
36
37
38
39
40
41
42
def add_operation(self, op_type: str, A: List[str], b: str):
    """Append one NEMC operation to the pattern.

    Args:
        op_type: One of ``"N"``, ``"E"``, ``"M"``, ``"C"`` (case-insensitive).
        A: Input node label(s) the operation reads. Empty for ``"N"``
            (it has no inputs, only produces `b`).
        b: Output node label the operation produces or measures. For
            ``"E"`` (entanglement), reuse one of the entangled nodes'
            existing labels rather than introducing a new one.
    """
    self.pattern.append((op_type.upper(), A, b))

build_hdh()

Translate the recorded NEMC pattern into an HDH.

Each operation gets its own timestep, in recording order. Node type (quantum vs. classical) is inferred per operation via _node_type: N produces a quantum output from a classical placeholder input, E is purely quantum, M consumes a quantum input and produces a classical output, and C is purely classical.

Returns:
  • HDH( HDH ) –

    the built hypergraph.

Source code in hdh/models/mbqc.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def build_hdh(self) -> HDH:
    """Translate the recorded NEMC pattern into an HDH.

    Each operation gets its own timestep, in recording order. Node type
    (quantum vs. classical) is inferred per operation via `_node_type`:
    N produces a quantum output from a classical placeholder input, E is
    purely quantum, M consumes a quantum input and produces a classical
    output, and C is purely classical.

    Returns:
        HDH: the built hypergraph.
    """
    hdh = self.hdh_cls()
    time_map = {}
    current_time = 0

    for op_type, A, b in self.pattern:
        in_nodes = set()
        out_nodes = set()
        all_nodes = A + [b]

        # Assign time steps
        op_time = current_time
        current_time += 1

        for x in A:
            t = time_map.get(x, 0)
            hdh.add_node(f"{x}_t{t}", self._node_type(op_type, input=True), t)
            in_nodes.add(f"{x}_t{t}")

        hdh.add_node(f"{b}_t{op_time}", self._node_type(op_type, input=False), op_time)
        out_nodes.add(f"{b}_t{op_time}")
        time_map[b] = op_time

        edge_nodes = in_nodes | out_nodes
        hdh.add_hyperedge(edge_nodes, self._edge_type(op_type), name=op_type.lower())

    return hdh

hdh.models.qca.QCA

Quantum cellular automaton (QCA) builder: a fixed neighbor topology updated for a set number of steps, then optionally measured.

Unlike the other models, a QCA is fully specified at construction time rather than built up incrementally — there's no add_* method, just build_hdh.

Parameters:
  • topology –

    Adjacency map from each qubit label (e.g. "q0") to the list of neighbor labels its update rule reads from.

  • measurements –

    Qubit labels to measure at the final timestep.

  • steps –

    Number of update steps to simulate.

  • hdh_cls –

    HDH class to instantiate (override for a subclass).

Source code in hdh/models/qca.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class QCA:
    """Quantum cellular automaton (QCA) builder: a fixed neighbor topology
    updated for a set number of steps, then optionally measured.

    Unlike the other models, a `QCA` is fully specified at construction time
    rather than built up incrementally — there's no `add_*` method, just
    `build_hdh`.

    Args:
        topology: Adjacency map from each qubit label (e.g. ``"q0"``) to the
            list of neighbor labels its update rule reads from.
        measurements: Qubit labels to measure at the final timestep.
        steps: Number of update steps to simulate.
        hdh_cls: HDH class to instantiate (override for a subclass).
    """

    def __init__(self, topology, measurements, steps, hdh_cls=HDH):
        self.topology = topology
        self.measurements = measurements
        self.steps = steps
        self.hdh_cls = hdh_cls

    def build_hdh(self) -> HDH:
        """Simulate `steps` update rounds, then measure, producing an HDH.

        At each timestep, every qubit gets one hyperedge connecting its own
        and its neighbors' previous-timestep nodes to its new-timestep node
        (i.e. its update rule). After all steps, each qubit named in
        `measurements` gets a measurement hyperedge to a classical output
        node one timestep later.

        Returns:
            HDH: the built hypergraph.
        """
        hdh = self.hdh_cls()
        time_map = {node: 0 for node in self.topology}

        for t in range(1, self.steps + 1):
            for node, neighbors in self.topology.items():
                inputs = [f"{n}_t{time_map[n]}" for n in neighbors + [node]]
                for n in inputs:
                    hdh.add_node(n, "q", int(n.split("_t")[1]))

                out_node = f"{node}_t{t}"
                hdh.add_node(out_node, "q", t)
                hdh.add_hyperedge(frozenset(inputs + [out_node]), "q", name="update")
                time_map[node] = t

        # Add measurement edges
        for node in self.measurements:
            t_meas = self.steps + 1  # important!
            out_node = f"{node}_t{self.steps}"
            cl_index = int(node[1:])  # assumes "q0", "q1", etc.
            c_node = f"c{cl_index}_t{t_meas}"
            hdh.add_node(c_node, "c", t_meas)
            hdh.add_hyperedge(frozenset({out_node, c_node}), "c", name="measure")

        return hdh

build_hdh()

Simulate steps update rounds, then measure, producing an HDH.

At each timestep, every qubit gets one hyperedge connecting its own and its neighbors' previous-timestep nodes to its new-timestep node (i.e. its update rule). After all steps, each qubit named in measurements gets a measurement hyperedge to a classical output node one timestep later.

Returns:
  • HDH( HDH ) –

    the built hypergraph.

Source code in hdh/models/qca.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def build_hdh(self) -> HDH:
    """Simulate `steps` update rounds, then measure, producing an HDH.

    At each timestep, every qubit gets one hyperedge connecting its own
    and its neighbors' previous-timestep nodes to its new-timestep node
    (i.e. its update rule). After all steps, each qubit named in
    `measurements` gets a measurement hyperedge to a classical output
    node one timestep later.

    Returns:
        HDH: the built hypergraph.
    """
    hdh = self.hdh_cls()
    time_map = {node: 0 for node in self.topology}

    for t in range(1, self.steps + 1):
        for node, neighbors in self.topology.items():
            inputs = [f"{n}_t{time_map[n]}" for n in neighbors + [node]]
            for n in inputs:
                hdh.add_node(n, "q", int(n.split("_t")[1]))

            out_node = f"{node}_t{t}"
            hdh.add_node(out_node, "q", t)
            hdh.add_hyperedge(frozenset(inputs + [out_node]), "q", name="update")
            time_map[node] = t

    # Add measurement edges
    for node in self.measurements:
        t_meas = self.steps + 1  # important!
        out_node = f"{node}_t{self.steps}"
        cl_index = int(node[1:])  # assumes "q0", "q1", etc.
        c_node = f"c{cl_index}_t{t_meas}"
        hdh.add_node(c_node, "c", t_meas)
        hdh.add_hyperedge(frozenset({out_node, c_node}), "c", name="measure")

    return hdh

hdh.models.qw.QW

Discrete-time quantum walk (coin + shift + measurement) pattern builder.

Each step is (op_type, input_label, output_label); build_hdh turns the labels into typed node IDs based on what the operation actually produces, so the label strings themselves don't need a "q"/"c" prefix.

Source code in hdh/models/qw.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class QW:
    """Discrete-time quantum walk (coin + shift + measurement) pattern builder.

    Each step is (op_type, input_label, output_label); ``build_hdh`` turns the
    labels into typed node IDs based on what the operation actually produces,
    so the label strings themselves don't need a "q"/"c" prefix.
    """

    def __init__(self, hdh_cls=HDH):
        self.steps = []  # (type, a, b)
        self.hdh_cls = hdh_cls
        self.qubit_counter = 0  # For auto-generating digit-only qubit IDs

    def _new_qubit_id(self):
        """Generate the next auto-incrementing walker-state label."""
        self.qubit_counter += 1
        return f"q{self.qubit_counter}"

    def add_coin(self, a: str):
        """Apply a coin operation to walker state `a`; returns the new state label."""
        a_prime = self._new_qubit_id()
        self.steps.append(("K", a, a_prime))
        return a_prime

    def add_shift(self, a_prime: str):
        """Apply a shift operation to walker state `a_prime`; returns the new state label."""
        b = self._new_qubit_id()
        self.steps.append(("R", a_prime, b))
        return b

    def add_measurement(self, a: str, b: str):
        """Measure walker state `a`, writing the result to classical label `b`.

        `b` must be a label distinct from any quantum state label already in
        use (e.g. ``"c0"``) — it becomes a *classical* node, so reusing an
        existing quantum label here would try to redefine that node's type.
        """
        self.steps.append(("M", a, b))

    def build_hdh(self) -> HDH:
        """Translate the recorded coin/shift/measurement steps into an HDH."""
        hdh = self.hdh_cls()
        time_map: Dict[str, int] = {}

        for step_index, (op_type, a, b) in enumerate(self.steps):
            in_time = time_map.get(a, 0)
            out_time = in_time + 1

            in_id = f"{a}_t{in_time}"
            out_id = f"{b}_t{out_time}"

            in_type = "q"
            out_type = "q" if op_type in {"K", "R"} else "c"
            edge_type = "q" if op_type in {"K", "R"} else "c"

            hdh.add_node(in_id, in_type, in_time)
            hdh.add_node(out_id, out_type, out_time)
            hdh.add_hyperedge({in_id, out_id}, edge_type, name=op_type.lower())

            time_map[b] = out_time  # set output time
        return hdh

add_coin(a)

Apply a coin operation to walker state a; returns the new state label.

Source code in hdh/models/qw.py
25
26
27
28
29
def add_coin(self, a: str):
    """Apply a coin operation to walker state `a`; returns the new state label."""
    a_prime = self._new_qubit_id()
    self.steps.append(("K", a, a_prime))
    return a_prime

add_measurement(a, b)

Measure walker state a, writing the result to classical label b.

b must be a label distinct from any quantum state label already in use (e.g. "c0") — it becomes a classical node, so reusing an existing quantum label here would try to redefine that node's type.

Source code in hdh/models/qw.py
37
38
39
40
41
42
43
44
def add_measurement(self, a: str, b: str):
    """Measure walker state `a`, writing the result to classical label `b`.

    `b` must be a label distinct from any quantum state label already in
    use (e.g. ``"c0"``) — it becomes a *classical* node, so reusing an
    existing quantum label here would try to redefine that node's type.
    """
    self.steps.append(("M", a, b))

add_shift(a_prime)

Apply a shift operation to walker state a_prime; returns the new state label.

Source code in hdh/models/qw.py
31
32
33
34
35
def add_shift(self, a_prime: str):
    """Apply a shift operation to walker state `a_prime`; returns the new state label."""
    b = self._new_qubit_id()
    self.steps.append(("R", a_prime, b))
    return b

build_hdh()

Translate the recorded coin/shift/measurement steps into an HDH.

Source code in hdh/models/qw.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def build_hdh(self) -> HDH:
    """Translate the recorded coin/shift/measurement steps into an HDH."""
    hdh = self.hdh_cls()
    time_map: Dict[str, int] = {}

    for step_index, (op_type, a, b) in enumerate(self.steps):
        in_time = time_map.get(a, 0)
        out_time = in_time + 1

        in_id = f"{a}_t{in_time}"
        out_id = f"{b}_t{out_time}"

        in_type = "q"
        out_type = "q" if op_type in {"K", "R"} else "c"
        edge_type = "q" if op_type in {"K", "R"} else "c"

        hdh.add_node(in_id, in_type, in_time)
        hdh.add_node(out_id, out_type, out_time)
        hdh.add_hyperedge({in_id, out_id}, edge_type, name=op_type.lower())

        time_map[b] = out_time  # set output time
    return hdh

Visualization

hdh.visualize.plot_hdh(hdh, save_path='hdh_plot.svg')

Render an HDH as a time-vs-qubit/bit diagram.

Nodes are laid out with time on one axis and qubit/classical-bit index on the other; hyperedges are drawn connecting the nodes they touch.

Parameters:
  • hdh –

    The HDH to visualize.

  • save_path –

    Where to save the plot (extension determines format, e.g. .svg/.png). Pass None to instead display it interactively via matplotlib.pyplot.show() without saving.

Returns:
  • –

    None.

Source code in hdh/visualize.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def plot_hdh(hdh, save_path="hdh_plot.svg"):
    """Render an HDH as a time-vs-qubit/bit diagram.

    Nodes are laid out with time on one axis and qubit/classical-bit index
    on the other; hyperedges are drawn connecting the nodes they touch.

    Args:
        hdh: The `HDH` to visualize.
        save_path: Where to save the plot (extension determines format, e.g.
            `.svg`/`.png`). Pass `None` to instead display it interactively
            via `matplotlib.pyplot.show()` without saving.

    Returns:
        None.
    """
    nodes = list(hdh.S)
    edges = [tuple(e) for e in hdh.C]

    node_positions = {}
    node_timesteps = {}
    node_qubits = {}
    qubit_labels = set()
    timesteps = set()

    for node in nodes:
        if node.startswith("q") or node.startswith("c"):
            match = re.match(r"[qc](\d+)_t(\d+)", node)
            if match:
                index, timestep = map(int, match.groups())
                node_timesteps[node] = timestep
                node_qubits[node] = index
                qubit_labels.add(index)
                timesteps.add(timestep)
        else:
            print(f"Skipping node due to unrecognized format: {node}")

    if not qubit_labels:
        print("No valid nodes found with q{{index}}_t{{step}} or c{{index}}_t{{step}} format.")
        return

    max_index = max(qubit_labels)

    for node in nodes:
        if node in node_timesteps and node in node_qubits:
            timestep = node_timesteps[node]
            flipped_index = max_index - node_qubits[node]
            node_positions[node] = (timestep, flipped_index)

    qubit_ticks = sorted(qubit_labels)
    flipped_ticks = [max_index - i for i in qubit_ticks]
    timestep_ticks = sorted(timesteps)

    fig, ax = plt.subplots(figsize=(10, 4))
    ax.set_xlabel("Timestep",fontsize=16)
    ax.set_ylabel("Qubit/Clbit Index",fontsize=16)
    ax.set_xticks(timestep_ticks)
    ax.set_yticks(flipped_ticks)
    ax.set_yticklabels(qubit_ticks)
    ax.set_ylim(min(flipped_ticks) - 1, max(flipped_ticks) + 1)

    involved_nodes = set()
    for edge in edges:
        involved_nodes.update(edge)

    for node in involved_nodes:
        if node in node_positions:
            x, y = node_positions[node]
            node_type = hdh.sigma.get(node, "q")
            is_predicted = hdh.upsilon.get(node, "a") == "p"
            color = {
                "q": "black",
                "ctrl": "black",
                "c": "orange"
            }.get(node_type, "black")

            face_color = "white" if is_predicted else color
            ax.plot(x, y, 'o', markersize=10,
                    markerfacecolor=face_color,
                    markeredgecolor=color,
                    markeredgewidth=2,
                    linestyle='--' if is_predicted else '-')
            ax.text(x, y + 0.15, node, ha='center')

    seen_pairs = set()
    for edge in edges:
        edge_nodes = [n for n in edge if n in node_positions]

        edge_type = hdh.tau.get(frozenset(edge))
        if edge_type is None:
            node_types = [hdh.sigma.get(n, "q") for n in edge]
            edge_type = "c" if all(t == "c" for t in node_types) else "q"

        color = "orange" if edge_type == "c" else "black"
        is_predicted = hdh.phi.get(frozenset(edge), "a") == "p"
        line_style = '--' if is_predicted else '-'

        for i in range(len(edge_nodes)):
            for j in range(i + 1, len(edge_nodes)):
                n1, n2 = edge_nodes[i], edge_nodes[j]
                t1, t2 = node_timesteps[n1], node_timesteps[n2]

                if t1 == t2:
                    continue

                type1 = hdh.sigma.get(n1, "q")
                type2 = hdh.sigma.get(n2, "q")
                if type1 == "ctrl" and type2 == "ctrl":
                    continue

                if t1 > t2:
                    n1, n2 = n2, n1
                    t1, t2 = t2, t1

                pair = (n1, n2)
                if pair in seen_pairs:
                    continue
                seen_pairs.add(pair)

                x0, y0 = node_positions[n1]
                x1, y1 = node_positions[n2]

                if edge_type == "c":
                    dx = x1 - x0
                    dy = y1 - y0
                    dist = np.hypot(dx, dy)
                    if dist == 0:
                        continue
                    # Calculate perpendicular unit vector
                    nx_vec = -dy / dist
                    ny_vec = dx / dist
                    # Offset distance for double line
                    offset = 0.05
                    # Draw two parallel lines
                    ax.plot([x0 + offset * nx_vec, x1 + offset * nx_vec], 
                            [y0 + offset * ny_vec, y1 + offset * ny_vec], 
                            color=color, linewidth=1, linestyle=line_style)
                    ax.plot([x0 - offset * nx_vec, x1 - offset * nx_vec], 
                            [y0 - offset * ny_vec, y1 - offset * ny_vec], 
                            color=color, linewidth=1, linestyle=line_style)
                else:
                    ax.plot([x0, x1], [y0, y1], color=color, linewidth=1.5, linestyle=line_style)

    if save_path:
        ext = os.path.splitext(save_path)[1].lower()
        if ext in [".png", ".jpg"]:
            plt.savefig(save_path, dpi=600, bbox_inches='tight')
        else:
            plt.savefig(save_path, bbox_inches='tight')
        print(f"Plot saved to: {save_path}")
    else:
        plt.show()

Converters

hdh.converters.qiskit_converter.from_qiskit(qc)

Convert a Qiskit QuantumCircuit to HDH format.

Supports: - Standard gates (h, rx, cx, measure, etc.) - IfElseOp with single-bit conditions == 1 - Both Clbit and single-bit ClassicalRegister conditions

Parameters:
  • qc (QuantumCircuit) –

    Qiskit QuantumCircuit

Returns:
  • HDH( HDH ) –

    HDH representation of the circuit

Raises:
  • NotImplementedError –

    For unsupported operations or condition types

Source code in hdh/converters/qiskit_converter.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def from_qiskit(qc: QuantumCircuit) -> HDH:
    """
    Convert a Qiskit QuantumCircuit to HDH format.

    Supports:
    - Standard gates (h, rx, cx, measure, etc.)
    - IfElseOp with single-bit conditions == 1
    - Both Clbit and single-bit ClassicalRegister conditions

    Args:
        qc: Qiskit QuantumCircuit

    Returns:
        HDH: HDH representation of the circuit

    Raises:
        NotImplementedError: For unsupported operations or condition types
    """
    circuit = Circuit()

    for item in qc.data:
        instr, qargs, cargs = item.operation, item.qubits, item.clbits
        # Skip metadata instructions
        if instr.name in {"barrier", "snapshot", "delay", "label"}:
            continue

        # Get indices
        q_indices = [qc.qubits.index(q) for q in qargs]
        c_indices = [qc.clbits.index(c) for c in cargs]

        # Handle IfElseOp
        if isinstance(instr, IfElseOp):
            _process_if_else_op(qc, instr, circuit)
            continue

        # Handle standard instructions
        if instr.name == "measure":
            circuit.add_instruction("measure", q_indices, None)
        else:
            modifies_flags = [True] * len(q_indices)
            raw_params = getattr(instr, "params", None) or []
            try:
                params = [float(p) for p in raw_params] or None
            except (TypeError, ValueError):
                # Unbound symbolic Parameter objects can't be stored as floats.
                params = None
            circuit.add_instruction(instr.name, q_indices, c_indices, modifies_flags, params=params)

    return circuit.build_hdh()

hdh.converters.qiskit_converter.to_qiskit(hdh)

Convert an HDH back into a Qiskit QuantumCircuit.

Multi-qubit gates are stored in the HDH as three hyperedges (<name>_stage1, _stage2, _stage3); only the _stage2 edge carries the full qubit set, so the stage 1 and 3 wire-continuity edges are skipped during reconstruction.

Parameters:
  • hdh (HDH) –

    HDH object

Returns:
  • QuantumCircuit( QuantumCircuit ) –

    Qiskit representation

Note

Gate parameters (rotation angles) are preserved via hdh.gate_params when the HDH was built from a source that recorded them (e.g. from_qiskit); otherwise they default to 0.

Source code in hdh/converters/qiskit_converter.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def to_qiskit(hdh: HDH) -> QuantumCircuit:
    """
    Convert an HDH back into a Qiskit QuantumCircuit.

    Multi-qubit gates are stored in the HDH as three hyperedges (``<name>_stage1``,
    ``_stage2``, ``_stage3``); only the ``_stage2`` edge carries the full qubit set,
    so the stage 1 and 3 wire-continuity edges are skipped during reconstruction.

    Args:
        hdh: HDH object

    Returns:
        QuantumCircuit: Qiskit representation

    Note:
        Gate parameters (rotation angles) are preserved via `hdh.gate_params`
        when the HDH was built from a source that recorded them (e.g.
        `from_qiskit`); otherwise they default to 0.
    """
    qubit_indices: Set[int] = set()
    bit_indices:   Set[int] = set()

    for node_id in hdh.S:
        q = _parse_qubit(node_id)
        if q is not None:
            qubit_indices.add(q)
            continue
        c = _parse_cbit(node_id)
        if c is not None:
            bit_indices.add(c)

    # Map global qubit/bit indices to a dense local range so a partition holding
    # e.g. qubits {2, 3} produces a 2-qubit circuit, not a 4-qubit one.
    qubit_map = {q: i for i, q in enumerate(sorted(qubit_indices))}
    bit_map   = {c: i for i, c in enumerate(sorted(bit_indices))}
    n_qubits  = len(qubit_map)
    n_bits    = len(bit_map)

    if n_qubits == 0:
        return QuantumCircuit()

    qc = QuantumCircuit(n_qubits, n_bits) if n_bits > 0 else QuantumCircuit(n_qubits)

    records: List[tuple] = []

    for edge in hdh.C:
        raw_name  = hdh.gate_name.get(edge, "")
        edge_type = hdh.tau.get(edge, "q")

        if raw_name == "measure":
            q_nodes = sorted(
                (n for n in edge if hdh.sigma.get(n) == "q"),
                key=lambda n: hdh.time_map.get(n, 0),
            )
            c_nodes = sorted(
                (n for n in edge if hdh.sigma.get(n) == "c"),
                key=lambda n: hdh.time_map.get(n, 0),
            )
            if not q_nodes or not c_nodes:
                continue
            q_idx = _parse_qubit(q_nodes[0])
            c_idx = _parse_cbit(c_nodes[0])
            if q_idx is None or c_idx is None or q_idx not in qubit_map or c_idx not in bit_map:
                continue
            sort_time = hdh.time_map.get(c_nodes[0], 0)
            records.append((sort_time, "measure", [qubit_map[q_idx]], [bit_map[c_idx]], False, None))
            continue

        # Stages 1 and 3 only carry wire continuity; stage 2 holds the real gate.
        if raw_name.endswith("_stage1") or raw_name.endswith("_stage3"):
            continue

        # Classical edges of a conditional gate duplicate its quantum edge.
        if edge_type == "c":
            continue

        args = hdh.edge_args.get(edge)
        if args is None:
            continue

        q_with_time, c_with_time, _ = args
        actual_name = raw_name[:-7] if raw_name.endswith("_stage2") else raw_name
        q_indices   = [qubit_map[q] for q, _ in q_with_time if q in qubit_map]
        c_indices   = [bit_map[c] for c, _ in c_with_time if c in bit_map]
        sort_time   = min((t for _, t in q_with_time), default=0)
        is_cond     = hdh.phi.get(edge) == "p"

        records.append((sort_time, actual_name, q_indices, c_indices, is_cond, hdh.gate_params.get(edge)))

    records.sort(key=lambda r: r[0])

    # A multi-qubit gate contributes one record per qubit wire; keep one.
    seen: Set[tuple] = set()
    unique_records: List[tuple] = []
    for rec in records:
        sort_time, name, q_indices, c_indices, is_cond, params = rec
        dedup_key = (sort_time, name, tuple(q_indices))
        if dedup_key not in seen:
            seen.add(dedup_key)
            unique_records.append(rec)

    for sort_time, name, q_indices, c_indices, is_cond, params in unique_records:
        if name == "measure":
            for qi, ci in zip(q_indices, c_indices):
                qc.measure(qi, ci)
            continue

        gate = _make_gate(name, params)
        if gate is None:
            print(f"[WARNING] Unrecognised gate '{name}' at t={sort_time} on qubits {q_indices} - skipped.")
            continue

        if any(qi >= n_qubits or qi < 0 for qi in q_indices):
            print(f"[WARNING] Gate '{name}' references out-of-range qubits {q_indices} - skipped.")
            continue

        if gate.num_qubits != len(q_indices):
            print(f"[WARNING] Gate '{name}' expects {gate.num_qubits} qubits but got {len(q_indices)} - skipped.")
            continue

        if is_cond and c_indices:
            ctrl_bit = c_indices[0]
            if ctrl_bit >= n_bits:
                print(f"[WARNING] Conditional gate '{name}' references out-of-range classical bit {ctrl_bit} - applied unconditionally.")
                qc.append(gate, q_indices)
            else:
                with qc.if_test((qc.clbits[ctrl_bit], 1)):
                    qc.append(gate, q_indices)
        else:
            qc.append(gate, q_indices)

    return qc

hdh.converters.qiskit_converter.partitions_to_qiskit(hdh, partitions)

Recover one Qiskit QuantumCircuit per partition of a cut HDH.

Parameters:
  • hdh (HDH) –

    The HDH that was partitioned

  • partitions (List[Set[str]]) –

    Node sets as returned by hdh.passes.cut.compute_cut

Returns:
  • List[QuantumCircuit] –

    One QuantumCircuit per partition, containing only the gates whose

  • List[QuantumCircuit] –

    qubits are entirely local to that partition.

Source code in hdh/converters/qiskit_converter.py
451
452
453
454
455
456
457
458
459
460
461
462
463
def partitions_to_qiskit(hdh: HDH, partitions: List[Set[str]]) -> List[QuantumCircuit]:
    """
    Recover one Qiskit QuantumCircuit per partition of a cut HDH.

    Args:
        hdh: The HDH that was partitioned
        partitions: Node sets as returned by ``hdh.passes.cut.compute_cut``

    Returns:
        One QuantumCircuit per partition, containing only the gates whose
        qubits are entirely local to that partition.
    """
    return [to_qiskit(_project_hdh(hdh, node_set)) for node_set in partitions]

hdh.converters.qasm_converter.from_qasm(input_type, qasm)

Convert an OpenQASM 2.0 circuit to an HDH.

Loads the circuit via Qiskit's QASM parser, then converts it exactly as hdh.converters.qiskit_converter.from_qiskit would.

Parameters:
  • input_type (str) –

    "file" to load qasm as a path to a .qasm file, or "string" to parse qasm directly as QASM source.

  • qasm (str) –

    File path or QASM source, per input_type.

Returns:
  • HDH –

    the converted circuit.

Raises:
  • ValueError –

    If input_type isn't "file" or "string".

Source code in hdh/converters/qasm_converter.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def from_qasm(input_type: str, qasm: str):
    """Convert an OpenQASM 2.0 circuit to an HDH.

    Loads the circuit via Qiskit's QASM parser, then converts it exactly as
    `hdh.converters.qiskit_converter.from_qiskit` would.

    Args:
        input_type: `"file"` to load `qasm` as a path to a `.qasm` file, or
            `"string"` to parse `qasm` directly as QASM source.
        qasm: File path or QASM source, per `input_type`.

    Returns:
        HDH: the converted circuit.

    Raises:
        ValueError: If `input_type` isn't `"file"` or `"string"`.
    """
    if input_type == 'file':
        circuit = QuantumCircuit.from_qasm_file(qasm)
    elif input_type == 'string':
        circuit = QuantumCircuit.from_qasm_str(qasm)
    else:
        raise ValueError("Unsupported type. Use 'file' or 'string'.")

    return from_qiskit(circuit)

hdh.converters.cirq_converter.from_cirq(c)

Convert a Cirq circuit to an HDH via hdh.models.circuit.Circuit.

Supports standard gates and measurement, moment by moment. Classically conditioned gates (Cirq's equivalent of Qiskit's IfElseOp) aren't supported.

Parameters:
  • c (Circuit) –

    The Cirq circuit to convert.

Returns:
  • HDH( HDH ) –

    the converted circuit.

Source code in hdh/converters/cirq_converter.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def from_cirq(c: cirq.Circuit) -> HDH:
    """Convert a Cirq circuit to an HDH via `hdh.models.circuit.Circuit`.

    Supports standard gates and measurement, moment by moment. Classically
    conditioned gates (Cirq's equivalent of Qiskit's `IfElseOp`) aren't
    supported.

    Args:
        c: The Cirq circuit to convert.

    Returns:
        HDH: the converted circuit.
    """
    circuit = Circuit()
    qmap = _qubit_index_map(c)

    for moment in c:
        for op in moment.operations:
            if _is_measure(op):
                q_indices = [qmap[q] for q in op.qubits]
                circuit.add_instruction("measure", q_indices, None)
                continue

            name = _gate_name(op.gate)
            q_indices = [qmap[q] for q in op.qubits]
            modifies_flags = [True] * len(q_indices)
            circuit.add_instruction(name, q_indices, None, modifies_flags)

    return circuit.build_hdh()

hdh.converters.pennylane_converter.from_pennylane(circ_like)

Convert a PennyLane QuantumScript/OperationRecorder to an HDH.

Supports standard gates, mid-circuit measurements (MidMeasureMP and the ProbabilityMP/ExpectationMP/SampleMP terminal measurements, each treated as a "measure" instruction), and single-condition qml.cond blocks (via PennyLane's Conditional operator).

Note: since PennyLane wires can be arbitrary labels (not necessarily small contiguous integers), they're remapped via _wire_index_map before being handed to Circuit. The resulting HDH's hyperedges are correct, but for multi-qubit gates the resulting node layout may not visually resemble the equivalent circuit built directly from small integer wire indices (e.g. via from_qiskit).

Parameters:
  • circ_like (Union[QuantumScript, OperationRecorder]) –

    The PennyLane script/recorder to convert.

Returns:
  • HDH( HDH ) –

    the converted circuit.

Raises:
  • NotImplementedError –

    If a qml.cond condition isn't MeasurementValue-based.

Source code in hdh/converters/pennylane_converter.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def from_pennylane(circ_like: Union[QuantumScript, OperationRecorder]) -> HDH:
    """Convert a PennyLane `QuantumScript`/`OperationRecorder` to an HDH.

    Supports standard gates, mid-circuit measurements (`MidMeasureMP` and
    the `ProbabilityMP`/`ExpectationMP`/`SampleMP` terminal measurements,
    each treated as a "measure" instruction), and single-condition `qml.cond`
    blocks (via PennyLane's `Conditional` operator).

    Note: since PennyLane wires can be arbitrary labels (not necessarily
    small contiguous integers), they're remapped via `_wire_index_map`
    before being handed to `Circuit`. The resulting HDH's hyperedges are
    correct, but for multi-qubit gates the resulting node layout may not
    visually resemble the equivalent circuit built directly from small
    integer wire indices (e.g. via `from_qiskit`).

    Args:
        circ_like: The PennyLane script/recorder to convert.

    Returns:
        HDH: the converted circuit.

    Raises:
        NotImplementedError: If a `qml.cond` condition isn't
            `MeasurementValue`-based.
    """
    qs = circ_like
    wire2idx = _wire_index_map(qs)

    circuit = Circuit()

    # Track which mid-measure maps to which classical bit index
    meas_mp_to_cbit: Dict[MidMeasureMP, int] = {}
    next_cbit = 0

    for op in qs.operations:
        # mid-circuit measure -> explicit "measure" instruction
        if isinstance(op, (MidMeasureMP,ProbabilityMP, ExpectationMP, SampleMP)):
            w = op.wires[0]
            cbit = meas_mp_to_cbit.setdefault(op, next_cbit)
            if cbit == next_cbit:
                next_cbit += 1
            circuit.add_instruction("measure", [wire2idx[w]], [cbit])
            continue

        # conditional op (qml.cond -> Conditional container)
        if isinstance(op, Conditional):
            then = op.base
            mval = op.meas_val
            mps = getattr(mval, "measurements", None)
            if not mps:
                raise NotImplementedError("Only MeasurementValue-based conditions are supported.")
            mp = mps[0]  # single-bit condition
            cbit = meas_mp_to_cbit.setdefault(mp, next_cbit)
            if cbit == next_cbit:
                next_cbit += 1

            qidxs = [wire2idx[w] for w in then.wires]
            then_params = [float(p) for p in then.parameters] if then.parameters else None
            circuit.add_instruction(
                then.name.lower(),
                qidxs,
                bits=[cbit],
                modifies_flags=[True] * len(qidxs),
                cond_flag="p",
                params=then_params,
            )
            continue

        # plain operation
        name = op.name.lower()
        qidxs = [wire2idx[w] for w in op.wires]
        params = [float(p) for p in op.parameters] if op.parameters else None
        circuit.add_instruction(name, qidxs, bits=[], params=params)

    return circuit.build_hdh()

hdh.converters.braket_converter.from_braket(bk)

Convert an AWS Braket Circuit to HDH via hdh.models.circuit.Circuit.

Supported: - Standard gates (all mapped by name) - Measurements (Measure) - Noise ops are treated as gates by name

Not supported (raises NotImplementedError if encountered): - Classical conditionals / control flow blocks

Source code in hdh/converters/braket_converter.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def from_braket(bk: BraketCircuit) -> HDH:
    """
    Convert an AWS Braket Circuit to HDH via hdh.models.circuit.Circuit.

    Supported:
    - Standard gates (all mapped by name)
    - Measurements (Measure)
    - Noise ops are treated as gates by name

    Not supported (raises NotImplementedError if encountered):
    - Classical conditionals / control flow blocks
    """
    qmap = _qindex_map(bk)
    circuit = Circuit()

    for instr in bk.instructions:
        if _is_ignorable(instr):
            continue

        name = _name_of(instr)
        q_indices = [qmap[q] for q in instr.target]

        # Basic check for classical control (Braket may wrap ops with conditions in newer APIs)
        if hasattr(instr, "condition") and instr.condition is not None:
            # Only implement if you have a bit-index mapping. Braket conditions are not 1-bit Clbits.
            raise NotImplementedError("Classical conditionals in Braket are not supported yet.")

        if _is_measure(instr):
            # Measurement is per qubit target
            circuit.add_instruction("measure", q_indices, None)
            continue

        # Treat any other op (gate or noise) as a modifying quantum op
        modifies_flags = [True] * len(q_indices)
        circuit.add_instruction(name, q_indices, [], modifies_flags)

    return circuit.build_hdh()

Partitioning

hdh.passes.cut.compute_cut(hdh_graph, k, cap, *, beam_k=3, backtrack_window=0, polish_1swap_budget=0, restarts=1, reserve_frac=0.08, predictive_reject=True, seed=0)

Capacity-aware temporal greedy partitioner for HDH graphs.

Implements the algorithm from the paper with cost-aware improvements: - Cost-aware greedy frontier selection using delta cost evaluation - Best-fit residual round-robin placement

Works directly on the HDH hypergraph structure: - Partitions at the NODE level (nodes like "q0_t1", "q1_t2", etc.) - Uses temporal hyperedge connectivity from HDH.C - Respects capacity by counting unique QUBITS per partition - Allows teledata cuts (same qubit in different partitions) - Priority queue selects earliest-time unassigned neighbors - Delta cost guides selection among top-k frontier candidates

Parameters:
  • hdh_graph –

    HDH object with .S (nodes), .C (hyperedges), .time_map

  • k (int) –

    Number of partitions (QPUs)

  • cap (int) –

    Capacity per partition (max unique qubits, not nodes)

  • beam_k (int, default: 3 ) –

    Beam width for frontier selection (default 3)

  • backtrack_window (int, default: 0 ) –

    Accepted for compatibility; currently unused.

  • polish_1swap_budget (int, default: 0 ) –

    Accepted for compatibility; currently unused.

  • restarts (int, default: 1 ) –

    Accepted for compatibility; currently unused.

  • reserve_frac (float, default: 0.08 ) –

    Accepted for compatibility; currently unused.

  • predictive_reject (bool, default: True ) –

    Accepted for compatibility; currently unused.

  • seed (int, default: 0 ) –

    Accepted for compatibility; currently unused.

Returns:
  • partitions( List[Set[str]] ) –

    List of k sets, each containing node IDs assigned to that partition

  • cost( int ) –

    Total communication cost (number of cut hyperedges)

Raises:
  • RuntimeError –

    If the greedy heuristic can't place every node within the given k/cap capacity constraints. This means no feasible full assignment was found — try a larger cap and/or k rather than relying on a partial, silently-wrong result.

Source code in hdh/passes/cut.py
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
def compute_cut(hdh_graph, k: int, cap: int, *,
                beam_k: int = 3,
                backtrack_window: int = 0,
                polish_1swap_budget: int = 0,
                restarts: int = 1,
                reserve_frac: float = 0.08,
                predictive_reject: bool = True,
                seed: int = 0) -> Tuple[List[Set[str]], int]:
    """
    Capacity-aware temporal greedy partitioner for HDH graphs.

    Implements the algorithm from the paper with cost-aware improvements:
    - Cost-aware greedy frontier selection using delta cost evaluation
    - Best-fit residual round-robin placement

    Works directly on the HDH hypergraph structure:
    - Partitions at the NODE level (nodes like "q0_t1", "q1_t2", etc.)
    - Uses temporal hyperedge connectivity from HDH.C
    - Respects capacity by counting unique QUBITS per partition
    - Allows teledata cuts (same qubit in different partitions)
    - Priority queue selects earliest-time unassigned neighbors
    - Delta cost guides selection among top-k frontier candidates

    Args:
        hdh_graph: HDH object with .S (nodes), .C (hyperedges), .time_map
        k: Number of partitions (QPUs)
        cap: Capacity per partition (max unique qubits, not nodes)
        beam_k: Beam width for frontier selection (default 3)
        backtrack_window: Accepted for compatibility; currently unused.
        polish_1swap_budget: Accepted for compatibility; currently unused.
        restarts: Accepted for compatibility; currently unused.
        reserve_frac: Accepted for compatibility; currently unused.
        predictive_reject: Accepted for compatibility; currently unused.
        seed: Accepted for compatibility; currently unused.

    Returns:
        partitions: List of k sets, each containing node IDs assigned to that partition
        cost: Total communication cost (number of cut hyperedges)

    Raises:
        RuntimeError: If the greedy heuristic can't place every node within
            the given `k`/`cap` capacity constraints. This means no feasible
            full assignment was found — try a larger `cap` and/or `k` rather
            than relying on a partial, silently-wrong result.
    """
    if not hdh_graph.S or not hdh_graph.C:
        return [set() for _ in range(k)], 0

    # Build temporal incidence structure
    inc, pins = _build_temporal_incidence(hdh_graph)

    # Initialize partitions and tracking structures
    partitions = [set() for _ in range(k)]
    unassigned = set(hdh_graph.S)
    partition_qubits = [set() for _ in range(k)]  # Track unique qubits per partition
    used = [0] * k  # Track number of unique qubits used per partition

    # QPU order (for now, just sequential; could be topology-aware)
    qpu_order = list(range(k))

    # Phase 1 & 2: Greedy bin filling with cost-aware frontier selection
    for i in range(k):
        bin_idx = qpu_order[i]

        if not unassigned:
            break

        # Select seed: lowest-index unassigned node
        seed_node = min(unassigned, key=lambda n: (hdh_graph.time_map.get(n, 0), n))

        # Initialize bin with seed - update partitions immediately
        partitions[bin_idx].add(seed_node)
        unassigned.remove(seed_node)

        # Track qubits in this bin
        seed_qubit = _extract_qubit_id(seed_node)
        if seed_qubit is not None:
            partition_qubits[bin_idx].add(seed_qubit)
            used[bin_idx] = len(partition_qubits[bin_idx])

        # Initialize frontier with seed's neighbors
        frontier = []  # Min-heap of (time, counter, node)
        counter = [0]  # Counter for tie-breaking
        rejected = set()  # Nodes rejected due to capacity in this bin
        _push_next_valid_neighbors(hdh_graph, seed_node, frontier, unassigned, inc, pins, counter)

        # Greedy temporal expansion with cost-aware selection
        while used[bin_idx] < cap:
            # Select best node from frontier using delta cost (excluding rejected)
            next_node = _select_best_from_frontier_with_rejected(frontier, unassigned, rejected,
                                                                  bin_idx, partitions, inc, pins, beam_k,
                                                                  partition_qubits, hdh_graph)

            if next_node is None:
                break  # No more valid neighbors

            # Check if adding this node would exceed capacity
            next_qubit = _extract_qubit_id(next_node)
            if next_qubit is not None:
                # Would this introduce a new qubit?
                if next_qubit not in partition_qubits[bin_idx]:
                    if used[bin_idx] + 1 > cap:
                        # Would exceed capacity, reject and try next candidate
                        rejected.add(next_node)
                        continue

            # Add node to partition immediately (not just to local variable)
            partitions[bin_idx].add(next_node)
            unassigned.remove(next_node)

            # Update qubit tracking
            if next_qubit is not None and next_qubit not in partition_qubits[bin_idx]:
                partition_qubits[bin_idx].add(next_qubit)
                used[bin_idx] = len(partition_qubits[bin_idx])

            # Push this node's neighbors to frontier
            _push_next_valid_neighbors(hdh_graph, next_node, frontier, unassigned, inc, pins, counter)

    # Phase 3: Residual best-fit placement with delta cost
    unplaceable_nodes = set()

    while unassigned:
        # Find the next unassigned node
        remaining = unassigned - unplaceable_nodes
        if not remaining:
            break  # All remaining nodes are unplaceable

        node = min(remaining, key=lambda n: (hdh_graph.time_map.get(n, 0), n))
        node_qubit = _extract_qubit_id(node)

        # Compute delta cost for each bin and find best fit
        best_bin = None
        best_delta = float('inf')

        for bin_idx in range(k):
            # Check capacity constraint
            can_add = True
            if node_qubit is not None:
                if node_qubit not in partition_qubits[bin_idx]:
                    if used[bin_idx] >= cap:
                        can_add = False

            if can_add:
                delta = _compute_delta_cost_simple(node, bin_idx, partitions, inc, pins)
                if delta < best_delta:
                    best_delta = delta
                    best_bin = bin_idx

        # Place node in best bin
        if best_bin is not None:
            partitions[best_bin].add(node)
            unassigned.remove(node)

            # Update qubit tracking only if this is a new qubit for this bin
            if node_qubit is not None and node_qubit not in partition_qubits[best_bin]:
                partition_qubits[best_bin].add(node_qubit)
                used[best_bin] = len(partition_qubits[best_bin])
        else:
            # Try teledata fallback: find a bin where this qubit already exists
            placed = False
            if node_qubit is not None:
                for b in range(k):
                    if node_qubit in partition_qubits[b]:
                        partitions[b].add(node)
                        unassigned.remove(node)
                        placed = True
                        break

            if not placed:
                # Node is truly unplaceable, mark it and try next node
                unplaceable_nodes.add(node)

    if unplaceable_nodes:
        raise RuntimeError(
            f"compute_cut: the greedy heuristic could not place {len(unplaceable_nodes)} "
            f"node(s) within the given capacity constraints (k={k}, cap={cap}). Returning "
            f"a partial partition would silently under-report the cut cost, so this is "
            f"raised instead. Try a larger `cap` and/or `k`."
        )

    # Compute cost (count cut hyperedges)
    node_assignment = {}
    for partition_idx, partition_nodes in enumerate(partitions):
        for node in partition_nodes:
            node_assignment[node] = partition_idx

    cost = _compute_cut_cost(hdh_graph, node_assignment)

    return partitions, cost

hdh.passes.cut.kahypar_cutter(hdh, k, cap, *, seed=0, config_path=None, suppress_output=True)

Partition an HDH using the kahypar Python package.

Parameters:
  • hdh –

    HDH object with .S (nodes) and .C (hyperedges)

  • k (int) –

    number of partitions

  • cap (int) –

    max unique qubits per partition

  • seed (int, default: 0 ) –

    RNG seed passed to KaHyPar (if supported)

  • config_path (Optional[str], default: None ) –

    path to KaHyPar INI config; required by KaHyPar.

  • suppress_output (bool, default: True ) –

    whether to silence KaHyPar stdout.

Returns:
  • partitions –

    list[set[str]] length k; each set is HDH node-ids assigned to that partition

  • cut_cost –

    number of cut hyperedges in the original HDH (same definition as compute_cut)

Source code in hdh/passes/cut.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def kahypar_cutter(
    hdh,
    k: int,
    cap: int,
    *,
    seed: int = 0,
    config_path: Optional[str] = None,
    suppress_output: bool = True,
):
    """Partition an HDH using the kahypar Python package.

    Args:
        hdh: HDH object with .S (nodes) and .C (hyperedges)
        k: number of partitions
        cap: max unique qubits per partition
        seed: RNG seed passed to KaHyPar (if supported)
        config_path: path to KaHyPar INI config; required by KaHyPar.
        suppress_output: whether to silence KaHyPar stdout.

    Returns:
        partitions: list[set[str]] length k; each set is HDH node-ids assigned to that partition
        cut_cost: number of cut hyperedges in the original HDH (same definition as compute_cut)
    """
    try:
        import kahypar  # type: ignore
    except Exception as e:
        raise ImportError(
            "`kahypar` is not installed. Install it (e.g. `pip install kahypar==1.3.6`) "
            "and ensure you have the build requirements from the PyPI page."
        ) from e

    if k <= 0:
        raise ValueError("k must be >= 1")

    # --- Build qubit vertex set ---
    qubits: Set[int] = set()
    qubit_nodes = defaultdict(list)  # q -> [node ids]
    classical_nodes_by_idx = defaultdict(list)  # c -> [node ids]

    for nid in getattr(hdh, "S", set()):
        qm = _Q_RE.match(nid)
        if qm:
            q = int(qm.group(1))
            qubits.add(q)
            qubit_nodes[q].append(nid)
            continue
        cm = _C_RE.match(nid)
        if cm:
            c = int(cm.group(1))
            classical_nodes_by_idx[c].append(nid)

    qubit_list = sorted(qubits)
    n = len(qubit_list)
    if n == 0:
        # No qubits: nothing meaningful to partition.
        return [set() for _ in range(k)], 0

    if k > n:
        raise ValueError(f"k={k} cannot exceed number of qubits n={n} for kahypar_cutter")

    if cap <= 0:
        raise ValueError("cap must be >= 1 (capacity = max unique qubits per partition)")

    # If cap is too small to ever fit an even split, fail early.
    min_required = (n + k - 1) // k
    if cap < min_required:
        raise ValueError(
            f"cap={cap} is too small for n={n}, k={k}. "
            f"Need at least ceil(n/k)={min_required} to be feasible."
        )

    q_to_vid = {q: i for i, q in enumerate(qubit_list)}

    # --- Build undirected hyperedges on qubit-vertices ---
    # KaHyPar expects hyperedges as pins (vertex IDs). We collapse each HDH hyperedge
    # to the set of qubits it touches.
    hedge_pins: List[List[int]] = []
    hedge_weights: List[int] = []

    for e in getattr(hdh, "C", set()):
        qs = set()
        for nid in e:
            qm = _Q_RE.match(nid)
            if qm:
                qs.add(int(qm.group(1)))
        if len(qs) >= 2:
            hedge_pins.append([q_to_vid[q] for q in sorted(qs)])
            hedge_weights.append(1)

    # Degenerate case: no multi-qubit couplings
    if not hedge_pins:
        # Purely disconnected qubits: just pack sequentially.
        partitions = [set() for _ in range(k)]
        for i, q in enumerate(qubit_list):
            b = i % k
            partitions[b].update(qubit_nodes[q])
            # Attach same-index classical nodes if present
            partitions[b].update(classical_nodes_by_idx.get(q, []))
        return partitions, _compute_cut_cost(hdh, {nid: b for b, part in enumerate(partitions) for nid in part})

    # Convert pins list to CSR-style arrays required by KaHyPar.
    # hyperedge_indices: prefix-sum offsets into `hyperedges` array.
    hyperedge_indices = [0]
    hyperedges: List[int] = []
    for pins in hedge_pins:
        hyperedges.extend(pins)
        hyperedge_indices.append(len(hyperedges))

    num_hyperedges = len(hedge_pins)
    vertex_weights = [1] * n  # each qubit counts as 1 capacity unit

    # --- KaHyPar context/config ---
    if config_path is None:
        config_path = _DEFAULT_KAHYPAR_CONFIG
        if not os.path.exists(config_path):
            raise FileNotFoundError(
                "KaHyPar needs an INI configuration file, and the bundled default "
                f"({_DEFAULT_KAHYPAR_CONFIG}) is missing. "
                "Pass `config_path=...` (e.g. a KaHyPar .ini file)."
            )

    # Balance: ensure max block size <= cap.
    target = n / float(k)
    epsilon = max(0.0, (cap / target) - 1.0)

    context = kahypar.Context()
    context.loadINIconfiguration(str(config_path))
    context.setK(k)
    context.setEpsilon(epsilon)

    # Some builds support setting a seed.
    if hasattr(context, "setSeed"):
        try:
            context.setSeed(int(seed))
        except Exception:
            pass

    if suppress_output and hasattr(context, "suppressOutput"):
        try:
            context.suppressOutput(True)
        except Exception:
            pass

    # --- Build and partition hypergraph ---
    # KaHyPar signature differs slightly across versions; try the common one.
    try:
        hg = kahypar.Hypergraph(
            n,
            num_hyperedges,
            hyperedge_indices,
            hyperedges,
            hedge_weights,
            vertex_weights,
        )
    except TypeError:
        # Older signature: Hypergraph(num_vertices, num_hyperedges, hyperedge_indices, hyperedges, k, ...)
        hg = kahypar.Hypergraph(
            n,
            num_hyperedges,
            hyperedge_indices,
            hyperedges,
            k,
            hedge_weights,
            vertex_weights,
        )

    kahypar.partition(hg, context)

    # --- Read partitioning and lift back to HDH nodes ---
    qubit_block = {qubit_list[v]: int(hg.blockID(v)) for v in range(n)}

    partitions: List[Set[str]] = [set() for _ in range(k)]
    for q, nodes in qubit_nodes.items():
        b = qubit_block[q]
        partitions[b].update(nodes)
        # Attach same-index classical nodes if present
        partitions[b].update(classical_nodes_by_idx.get(q, []))

    # Any remaining classical nodes with indices not matching qubits go to block 0
    for c_idx, nodes in classical_nodes_by_idx.items():
        if c_idx not in qubit_nodes:
            partitions[0].update(nodes)

    node_assignment = {nid: b for b, part in enumerate(partitions) for nid in part}
    cut_cost = _compute_cut_cost(hdh, node_assignment)
    return partitions, cut_cost

hdh.passes.cut.kahypar_cutter_nodebalanced(hdh, k, *, seed=0, config_path=None, epsilon=0.03, suppress_output=True)

Partition an HDH using KaHyPar with node-balanced constraints.

This is intentionally the "capacity-oblivious" baseline: - vertices = HDH nodes (q_t and c_t) - vertex_weights = 1 for all vertices - balance enforced by epsilon around equal-size blocks (in nodes, not logical qubits)

This is useful for measuring how often such a baseline violates a logical-qubit capacity constraint when you evaluate it post-hoc.

Returns:
  • partitions –

    list[set[str]] of HDH node IDs per block

  • cut_cost –

    cut hyperedge count in the original HDH

Source code in hdh/passes/cut.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def kahypar_cutter_nodebalanced(
    hdh,
    k: int,
    *,
    seed: int = 0,
    config_path: Optional[str] = None,
    epsilon: float = 0.03,
    suppress_output: bool = True,
):
    """Partition an HDH using KaHyPar with **node-balanced** constraints.

    This is intentionally the "capacity-oblivious" baseline:
    - vertices = HDH nodes (q*_t* and c*_t*)
    - vertex_weights = 1 for all vertices
    - balance enforced by epsilon around equal-size blocks (in *nodes*, not logical qubits)

    This is useful for measuring how often such a baseline violates a
    *logical-qubit* capacity constraint when you evaluate it post-hoc.

    Returns:
        partitions: list[set[str]] of HDH node IDs per block
        cut_cost: cut hyperedge count in the original HDH
    """
    try:
        import kahypar  # type: ignore
    except Exception as e:
        raise ImportError(
            "`kahypar` is not installed. Install it (e.g. `pip install kahypar==1.3.6`)."
        ) from e

    if k <= 0:
        raise ValueError("k must be >= 1")

    nodes = sorted(list(getattr(hdh, "S", set())))
    n = len(nodes)
    if n == 0:
        return [set() for _ in range(k)], 0
    if k > n:
        raise ValueError(f"k={k} cannot exceed number of HDH nodes n={n} for node-balanced KaHyPar")

    # Build node id -> vertex id
    nid_to_vid = {nid: i for i, nid in enumerate(nodes)}

    # Build undirected hyperedges as pins (vertex IDs)
    hedge_pins: List[List[int]] = []
    hedge_weights: List[int] = []
    for e in getattr(hdh, "C", set()):
        pins = [nid_to_vid[nid] for nid in e if nid in nid_to_vid]
        pins = sorted(set(pins))
        if len(pins) >= 2:
            hedge_pins.append(pins)
            hedge_weights.append(1)

    # Degenerate case: no usable hyperedges
    if not hedge_pins:
        parts = [set() for _ in range(k)]
        for i, nid in enumerate(nodes):
            parts[i % k].add(nid)
        node_assignment = {nid: b for b, part in enumerate(parts) for nid in part}
        return parts, _compute_cut_cost(hdh, node_assignment)

    hyperedge_indices = [0]
    hyperedges: List[int] = []
    for pins in hedge_pins:
        hyperedges.extend(pins)
        hyperedge_indices.append(len(hyperedges))

    num_hyperedges = len(hedge_pins)
    vertex_weights = [1] * n

    if config_path is None:
        config_path = _DEFAULT_KAHYPAR_CONFIG
        if not os.path.exists(config_path):
            raise FileNotFoundError(
                "KaHyPar needs an INI configuration file, and the bundled default "
                f"({_DEFAULT_KAHYPAR_CONFIG}) is missing. "
                "Pass `config_path=...` (e.g. a KaHyPar .ini file)."
            )

    context = kahypar.Context()
    context.loadINIconfiguration(str(config_path))
    context.setK(k)
    context.setEpsilon(float(epsilon))

    if hasattr(context, "setSeed"):
        try:
            context.setSeed(int(seed))
        except Exception:
            pass
    if suppress_output and hasattr(context, "suppressOutput"):
        try:
            context.suppressOutput(True)
        except Exception:
            pass

    try:
        hg = kahypar.Hypergraph(
            n,
            num_hyperedges,
            hyperedge_indices,
            hyperedges,
            hedge_weights,
            vertex_weights,
        )
    except TypeError:
        hg = kahypar.Hypergraph(
            n,
            num_hyperedges,
            hyperedge_indices,
            hyperedges,
            k,
            hedge_weights,
            vertex_weights,
        )

    kahypar.partition(hg, context)

    partitions: List[Set[str]] = [set() for _ in range(k)]
    for vid, nid in enumerate(nodes):
        b = int(hg.blockID(vid))
        partitions[b].add(nid)

    node_assignment = {nid: b for b, part in enumerate(partitions) for nid in part}
    cut_cost = _compute_cut_cost(hdh, node_assignment)
    return partitions, cut_cost

hdh.passes.cut.metis_telegate(hdh, partitions, capacities)

Partition the telegate (qubit) graph via METIS (or KL fallback), with capacity on #qubits/bin. Returns: (bins_qubits, cut_cost, respects_capacity, method['metis'|'kl'])

Source code in hdh/passes/cut.py
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
def metis_telegate(hdh: "HDH", partitions: int, capacities: int) -> Tuple[List[Set[str]], int, bool, str]:
    """
    Partition the telegate (qubit) graph via METIS (or KL fallback), with capacity on #qubits/bin.
    Returns: (bins_qubits, cut_cost, respects_capacity, method['metis'|'kl'])
    """
    G: nx.Graph = telegate_hdh(hdh)

    used_metis = False
    try:
        import metis  # type: ignore
        node_order = list(G.nodes())
        _, part_ids = metis.part_graph(G, nparts=partitions)
        qubit_parts = [set() for _ in range(partitions)]
        for node, p in zip(node_order, part_ids):
            qubit_parts[p].add(node)
        used_metis = True
    except Exception:
        qubit_parts = _kl_fallback_partition(G, partitions)

    bins = _bins_from_parts(qubit_parts)
    bins = _repair_overflow(G, bins, capacities)

    cut_cost = _cut_edges_unweighted(G, bins)
    sizes = _sizes(bins)
    respects_capacity = all(s <= capacities for s in sizes)
    method = "metis" if used_metis else "kl"
    return bins, cut_cost, respects_capacity, method

hdh.passes.cut.cost(hdh_graph, partitions)

Calculate the cost of a given partitioning of the HDH graph.

Parameters:
  • hdh_graph –

    HDH graph object

  • partitions –

    List of sets, where each set contains node IDs in that partition

Returns:
  • Tuple[float, float] –

    Tuple[float, float]: (cost_q, cost_c) - quantum and classical cut costs cost_q: number of quantum hyperedges that span multiple partitions cost_c: number of classical hyperedges that span multiple partitions

Source code in hdh/passes/cut.py
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
def cost(hdh_graph, partitions) -> Tuple[float, float]:
    """
    Calculate the cost of a given partitioning of the HDH graph.

    Args:
        hdh_graph: HDH graph object
        partitions: List of sets, where each set contains node IDs in that partition

    Returns:
        Tuple[float, float]: (cost_q, cost_c) - quantum and classical cut costs
            cost_q: number of quantum hyperedges that span multiple partitions
            cost_c: number of classical hyperedges that span multiple partitions
    """
    if not partitions or not hasattr(hdh_graph, 'C'):
        return 0.0, 0.0

    # Create mapping from node to partition index
    node_to_partition = {}
    for i, partition in enumerate(partitions):
        for node in partition:
            node_to_partition[node] = i

    # Count hyperedges that cross partitions (separated by type)
    cost_q = 0  # Quantum cost
    cost_c = 0  # Classical cost

    for edge in hdh_graph.C:
        # Get partitions of all nodes in this hyperedge
        edge_partitions = set()
        for node in edge:
            if node in node_to_partition:
                edge_partitions.add(node_to_partition[node])

        # If hyperedge spans multiple partitions, it contributes to cost
        if len(edge_partitions) > 1:
            # Get edge weight if available
            edge_weight = 1
            if hasattr(hdh_graph, 'edge_weight'):
                edge_weight = hdh_graph.edge_weight.get(edge, 1)

            # Determine if edge is quantum or classical
            edge_type = 'q'  # Default to quantum
            if hasattr(hdh_graph, 'tau'):
                edge_type = hdh_graph.tau.get(edge, 'q')

            if edge_type == 'q':
                cost_q += edge_weight
            else:
                cost_c += edge_weight

    return float(cost_q), float(cost_c)