Skip to content

circuit_analyzer

CircuitAnalyzer

Bases: Analyzer

Computes structural metrics describing a quantum circuit.

The metrics are grouped into four categories:

  • Size: number of qubits, gates, two-qubit gates, two-qubit gate percentage, depth.
  • Interaction graph (IG): metrics derived from the qubit interaction graph, where nodes correspond to qubits and edges correspond to two-qubit gates.
  • Gate dependency graph (GDG): metrics derived from the directed acyclic graph of gate-to-gate dependencies on shared qubits.
  • Density: parallelisation-related metrics (density score, idling score).

The metric set follows the structural circuit profiling approach proposed in Bandic et al., "Profiling quantum circuits for their efficient execution on single- and multi-core architectures" (Quantum Sci. Technol. 10, 015060, 2025).

Source code in opensquirrel/passes/analyzer/circuit_analyzer.py
class CircuitAnalyzer(Analyzer):
    """Computes structural metrics describing a quantum circuit.

    The metrics are grouped into four categories:

    * Size: number of qubits, gates, two-qubit gates, two-qubit gate percentage, depth.
    * Interaction graph (IG): metrics derived from the qubit interaction graph,
      where nodes correspond to qubits and edges correspond to two-qubit gates.
    * Gate dependency graph (GDG): metrics derived from the directed acyclic graph
      of gate-to-gate dependencies on shared qubits.
    * Density: parallelisation-related metrics (density score, idling score).

    The metric set follows the structural circuit profiling approach proposed
    in Bandic et al., "Profiling quantum circuits for their efficient execution
    on single- and multi-core architectures" (Quantum Sci. Technol. 10, 015060, 2025).

    """

    def analyze(self, circuit: Circuit) -> dict[str, Any]:
        """Run the analyzer on the given circuit and return all metrics.

        Args:
            circuit (Circuit): The circuit to analyze.

        Returns:
            dict[str, Any]: A flat dictionary mapping metric name to its value.

        """
        self.circuit = circuit
        self.gate_statements = [s for s in circuit.ir.statements if isinstance(s, Gate)]

        metrics: dict[str, Any] = {}
        metrics.update(self._size_metrics())
        metrics.update(self._interaction_graph_metrics())
        metrics.update(self._gate_dependency_graph_metrics())
        metrics.update(self._density_metrics())

        return metrics

    # ------------------------------------------------------------------ #
    # Size metrics                                                       #
    # ------------------------------------------------------------------ #
    def _size_metrics(self) -> dict[str, Any]:
        n_qubits = self.circuit.qubit_register_size
        n_gates = len(self.gate_statements)
        n_two_qubit_gates = sum(1 for s in self.gate_statements if isinstance(s, TwoQubitGate))
        two_qubit_pct = round(n_two_qubit_gates / n_gates, 4) if n_gates > 0 else 0.0
        depth = self._compute_depth()

        return {
            "n_qubits": n_qubits,
            "n_gates": n_gates,
            "n_two_qubit_gates": n_two_qubit_gates,
            "two_qubit_pct": two_qubit_pct,
            "depth": depth,
        }

    def _compute_depth(self) -> int:
        """ASAP-style circuit depth (longest dependency chain)."""
        n_qubits = self.circuit.qubit_register_size
        if n_qubits == 0 or not self.gate_statements:
            return 0

        layer = [0] * n_qubits
        for gate_statement in self.gate_statements:
            qubit_indices = list(gate_statement.qubit_indices)
            new_layer = max(layer[qubit_index] for qubit_index in qubit_indices) + 1
            for qubit_index in qubit_indices:
                layer[qubit_index] = new_layer
        return max(layer)

    # ------------------------------------------------------------------ #
    # Interaction graph metrics                                          #
    # ------------------------------------------------------------------ #
    def _interaction_graph_metrics(self) -> dict[str, Any]:
        weighted_edges = self.circuit.interaction_graph
        if not weighted_edges:
            return {
                "ig_avg_shortest_path": 0.0,
                "ig_std_adjacency": 0.0,
                "ig_diameter": 0,
                "ig_central_dominance": 0.0,
                "ig_avg_degree": 0.0,
                "ig_n_maximal_cliques": 0,
                "ig_clustering_coefficient": 0.0,
            }

        graph = nx.Graph()
        graph.add_nodes_from(range(self.circuit.qubit_register_size))
        for (i, j), weight in weighted_edges.items():
            graph.add_edge(i, j, weight=weight)

        # avg_shortest_path: computed on the largest connected component.
        try:
            largest_cc_nodes = max(nx.connected_components(graph), key=len)
            largest_cc = graph.subgraph(largest_cc_nodes)
            avg_shortest_path = (
                round(nx.average_shortest_path_length(largest_cc), 4) if largest_cc.number_of_nodes() > 1 else 0.0
            )
        except (nx.NetworkXError, ValueError):
            avg_shortest_path = 0.0

        # std of adjacency matrix entries
        adjacency_matrix = nx.to_numpy_array(graph)
        std_adjacency = round(float(adjacency_matrix.std()), 4)

        # diameter: undefined for disconnected graphs so we use 0 in that case.
        try:
            diameter = nx.diameter(graph) if nx.is_connected(graph) else 0
        except nx.NetworkXError:
            diameter = 0

        # central point of dominance: max betweenness across nodes.
        betweenness = nx.betweenness_centrality(graph)
        central_dominance = round(max(betweenness.values()), 4) if betweenness else 0.0

        # average degree
        degrees = [d for _, d in graph.degree()]
        avg_degree = round(sum(degrees) / len(degrees), 4) if degrees else 0.0

        # number of maximal cliques
        try:
            n_maximal_cliques = sum(1 for _ in nx.find_cliques(graph))
        except nx.NetworkXError:
            n_maximal_cliques = 0

        # average clustering coefficient
        clustering_coefficient = round(nx.average_clustering(graph), 4)

        return {
            "ig_avg_shortest_path": avg_shortest_path,
            "ig_std_adjacency": std_adjacency,
            "ig_diameter": diameter,
            "ig_central_dominance": central_dominance,
            "ig_avg_degree": avg_degree,
            "ig_n_maximal_cliques": n_maximal_cliques,
            "ig_clustering_coefficient": clustering_coefficient,
        }

    # ------------------------------------------------------------------ #
    # Gate dependency graph metrics                                      #
    # ------------------------------------------------------------------ #
    def _gate_dependency_graph_metrics(self) -> dict[str, Any]:
        n_gates = len(self.gate_statements)
        if n_gates == 0:
            return {
                "gdg_critical_path_length": 0,
                "gdg_path_length_mean": 0.0,
                "gdg_path_length_std": 0.0,
                "gdg_pct_gates_in_critical_path": 0.0,
            }

        gate_dependency_graph = self._build_gate_dependency_graph()
        critical_path_length = self._safe_critical_path_length(gate_dependency_graph)
        longest_to, longest_from = self._compute_longest_paths(gate_dependency_graph)
        mean_length, std_length = self._path_length_stats(longest_to)
        pct_gates_in_cp = self._critical_path_membership_fraction(
            gate_dependency_graph, longest_to, longest_from, critical_path_length, n_gates
        )

        return {
            "gdg_critical_path_length": critical_path_length,
            "gdg_path_length_mean": round(mean_length, 4),
            "gdg_path_length_std": round(std_length, 4),
            "gdg_pct_gates_in_critical_path": pct_gates_in_cp,
        }

    def _build_gate_dependency_graph(self) -> nx.DiGraph:
        """Build a directed acyclic gate dependency graph where edge (i, j) means gate i must run before gate j."""
        gate_dependency_graph: nx.DiGraph = nx.DiGraph()
        last_gate_on_qubit: dict[int, int] = {}
        for index, gate in enumerate(self.gate_statements):
            gate_dependency_graph.add_node(index)
            for qubit_index in gate.qubit_indices:
                if qubit_index in last_gate_on_qubit:
                    gate_dependency_graph.add_edge(last_gate_on_qubit[qubit_index], index)
                last_gate_on_qubit[qubit_index] = index
        return gate_dependency_graph

    def _safe_critical_path_length(self, gate_dependency_graph: nx.DiGraph) -> int:
        try:
            return nx.dag_longest_path_length(gate_dependency_graph)
        except nx.NetworkXError:
            return 0

    def _compute_longest_paths(self, gate_dependency_graph: nx.DiGraph) -> tuple[dict[int, int], dict[int, int]]:
        """Return (longest_from, longest_to) for every node in the gate dependency graph.

        longest_to[n]   = length of the longest path ending at n
        longest_from[n] = length of the longest path starting at n
        """
        topo_order = list(nx.topological_sort(gate_dependency_graph))
        longest_to: dict[int, int] = dict.fromkeys(gate_dependency_graph.nodes, 0)
        for node in topo_order:
            for successor in gate_dependency_graph.successors(node):
                if longest_to[node] + 1 > longest_to[successor]:
                    longest_to[successor] = longest_to[node] + 1

        longest_from: dict[int, int] = dict.fromkeys(gate_dependency_graph.nodes, 0)
        for node in reversed(topo_order):
            for successor in gate_dependency_graph.successors(node):
                if longest_from[successor] + 1 > longest_from[node]:
                    longest_from[node] = longest_from[successor] + 1

        return longest_from, longest_to

    def _path_length_stats(self, longest_to: dict[int, int]) -> tuple[float, float]:
        path_lengths = list(longest_to.values())
        if not path_lengths:
            return 0.0, 0.0
        mean_length = statistics.mean(path_lengths)
        variance = statistics.pstdev(path_lengths)
        return mean_length, math.sqrt(variance)

    def _critical_path_membership_fraction(
        self,
        gate_dependency_graph: nx.DiGraph,
        longest_to: dict[int, int],
        longest_from: dict[int, int],
        critical_path_length: int,
        n_gates: int,
    ) -> float:
        """Fraction of gates that lie on some critical path.

        A node lies on a critical path iff the longest path through it
        (longest_to[n] + longest_from[n]) equals the overall critical path length.
        """
        n_in_cp = sum(
            1 for node in gate_dependency_graph.nodes if longest_to[node] + longest_from[node] == critical_path_length
        )
        return round(n_in_cp / n_gates, 4)

    # ------------------------------------------------------------------ #
    # Density metrics                                                    #
    # ------------------------------------------------------------------ #

    def _density_metrics(self) -> dict[str, Any]:
        n_qubits = self.circuit.qubit_register_size
        n_gates = len(self.gate_statements)
        n_two_qubit_gates = sum(1 for s in self.gate_statements if isinstance(s, TwoQubitGate))
        n_one_qubit_gates = n_gates - n_two_qubit_gates
        depth = self._compute_depth()

        # Density score: parallelisation level of the circuit (0..1).
        # Formula from Bandic et al. 2025, eq. (1).
        if n_qubits > 1 and depth > 1:
            density_score = (2 * n_two_qubit_gates + n_one_qubit_gates) / ((depth - 1) * (n_qubits - 1))
            density_score = round(min(density_score, 1.0), 4)
        else:
            density_score = 0.0

        # Idling score: average qubit idling fraction (0..1).
        if n_qubits > 0 and depth > 0:
            qubit_active_layers: dict[int, int] = dict.fromkeys(range(n_qubits), 0)
            for gate_statement in self.gate_statements:
                for qubit_index in gate_statement.qubit_indices:
                    qubit_active_layers[qubit_index] = qubit_active_layers.get(qubit_index, 0) + 1
            total_idle = sum(depth - active for active in qubit_active_layers.values())
            idling_score = round(max(0.0, min(total_idle / (n_qubits * depth), 1.0)), 4)
        else:
            idling_score = 0.0

        return {"density_score": density_score, "idling_score": idling_score}

analyze

analyze(circuit: Circuit) -> dict[str, Any]

Run the analyzer on the given circuit and return all metrics.

Parameters:

Name Type Description Default
circuit Circuit

The circuit to analyze.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A flat dictionary mapping metric name to its value.

Source code in opensquirrel/passes/analyzer/circuit_analyzer.py
def analyze(self, circuit: Circuit) -> dict[str, Any]:
    """Run the analyzer on the given circuit and return all metrics.

    Args:
        circuit (Circuit): The circuit to analyze.

    Returns:
        dict[str, Any]: A flat dictionary mapping metric name to its value.

    """
    self.circuit = circuit
    self.gate_statements = [s for s in circuit.ir.statements if isinstance(s, Gate)]

    metrics: dict[str, Any] = {}
    metrics.update(self._size_metrics())
    metrics.update(self._interaction_graph_metrics())
    metrics.update(self._gate_dependency_graph_metrics())
    metrics.update(self._density_metrics())

    return metrics