Source code for blueqat.circuit_funcs.qasm_parser

# Copyright 2019-2026 The Blueqat Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Parser for a practical subset of OpenQASM 2.0 (the qelib1.inc gate set) into a Circuit.

This is the reverse of `Circuit.to_qasm()`.
"""

import ast
import math
import operator
import re
from typing import Dict, List, Optional, Tuple

from ..circuit import Circuit

# name -> (blueqat gate name, number of numeric args)
_GATES = {
    # no-arg 1-qubit
    'x': ('x', 0), 'y': ('y', 0), 'z': ('z', 0), 'h': ('h', 0),
    's': ('s', 0), 'sdg': ('sdg', 0), 't': ('t', 0), 'tdg': ('tdg', 0),
    'sx': ('sx', 0), 'sxdg': ('sxdg', 0), 'id': ('i', 0), 'reset': ('reset', 0),
    # 1-arg 1-qubit
    'rx': ('rx', 1), 'ry': ('ry', 1), 'rz': ('rz', 1),
    'p': ('phase', 1), 'u1': ('phase', 1),
    # 3-arg 1-qubit
    'u': ('u', 3), 'u3': ('u', 3),
    # no-arg 2-qubit
    'cx': ('cx', 0), 'cz': ('cz', 0), 'cy': ('cy', 0), 'ch': ('ch', 0), 'swap': ('swap', 0),
    # 1-arg 2-qubit
    'cp': ('cphase', 1), 'cu1': ('cphase', 1),
    'crx': ('crx', 1), 'cry': ('cry', 1), 'crz': ('crz', 1),
    'rxx': ('rxx', 1), 'ryy': ('ryy', 1), 'rzz': ('rzz', 1),
    # blueqat's zz gate is the fixed diag(1, i, i, 1) -- it takes no angle
    'zz': ('zz', 0),
    # 4-arg 2-qubit
    'cu': ('cu', 4),
    # no-arg 3-qubit
    'ccx': ('ccx', 0), 'cswap': ('cswap', 0),
}

_CONSTS = {'pi': math.pi}
#: Largest exponent an angle expression may use. `**` is unbounded arithmetic:
#: `9**9**9` has no answer a machine will finish computing, and this parser reads
#: text arriving from MCP clients, so an angle is not a place to allow that.
MAX_EXPONENT = 64


def _bounded_pow(base: float, exponent: float) -> float:
    if abs(exponent) > MAX_EXPONENT:
        raise ValueError(
            f"Exponent {exponent} exceeds the limit of {MAX_EXPONENT} allowed in a "
            f"QASM angle expression.")
    return operator.pow(base, exponent)


_BINOPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
           ast.Div: operator.truediv, ast.Pow: _bounded_pow}
_UNARYOPS = {ast.USub: operator.neg, ast.UAdd: operator.pos}


def _eval_angle_expr(node: ast.AST) -> float:
    """Safely evaluate a numeric angle expression (numbers, +-*/, unary -, and `pi`)
    without falling back to `eval`, since this parses (potentially untrusted) QASM text."""
    if isinstance(node, ast.Expression):
        return _eval_angle_expr(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.Name) and node.id in _CONSTS:
        return _CONSTS[node.id]
    if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
        return _BINOPS[type(node.op)](_eval_angle_expr(node.left), _eval_angle_expr(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARYOPS:
        return _UNARYOPS[type(node.op)](_eval_angle_expr(node.operand))
    raise ValueError(f"Unsupported expression in QASM angle argument: {ast.dump(node)}")


def _parse_args(args_str: str) -> List[float]:
    args = []
    for a in args_str.split(','):
        try:
            tree = ast.parse(a.strip(), mode='eval')
        except SyntaxError as e:
            raise ValueError(f"Could not parse QASM argument {a!r}: {e}") from e
        args.append(_eval_angle_expr(tree))
    return args


def _arity(gate_name: str) -> int:
    """How many qubits one application of this gate takes."""
    from ..gate import OneQubitGate, TwoQubitGate
    from ..gateset import get_op_type
    op_type = get_op_type(gate_name)
    if op_type is None or issubclass(op_type, OneQubitGate):
        return 1
    if issubclass(op_type, TwoQubitGate):
        return 2
    return 3


def _parse_registers(text: str) -> Tuple[Dict[str, Tuple[int, int]], int]:
    """Declared quantum registers as ``name -> (offset, size)``, and the total width.

    Registers are laid out consecutively in blueqat's single qubit space, in
    declaration order.
    """
    registers: Dict[str, Tuple[int, int]] = {}
    offset = 0
    for name, size in re.findall(r'\bqreg\s+(\w+)\s*\[\s*(\d+)\s*\]', text):
        size = int(size)
        registers[name] = (offset, size)
        offset += size
    return registers, offset


def _parse_targets(targets_str: str,
                   registers: Optional[Dict[str, Tuple[int, int]]] = None) -> List[int]:
    """Qubit indices named by a target list.

    ``q[2]`` names one qubit; a bare ``q`` names the whole register, which is
    ordinary OpenQASM and used to parse as an empty target list -- so the gate
    was appended and did nothing, with no error.
    """
    registers = registers or {}
    targets: List[int] = []
    for piece in targets_str.split(','):
        piece = piece.strip()
        if not piece:
            continue
        indexed = re.fullmatch(r'(\w+)\s*\[\s*(\d+)\s*\]', piece)
        if indexed:
            name, index = indexed.group(1), int(indexed.group(2))
            offset = registers.get(name, (0, 0))[0] if registers else 0
            targets.append(offset + index)
            continue
        whole = re.fullmatch(r'\w+', piece)
        if whole and whole.group(0) in registers:
            offset, size = registers[whole.group(0)]
            targets.extend(range(offset, offset + size))
            continue
        raise ValueError(f"Could not parse QASM target {piece!r}.")
    return targets


[docs] def look_alike_characters(text: str) -> Dict[str, str]: """Characters that are not what they look like, and what they should be. Text pasted out of a PDF or a word processor carries characters that render identically to ASCII or to an ordinary ideograph but are different code points -- full-width punctuation and digits, and the 214 Kangxi radicals, where ``子`` (U+5B50) and ``⼦`` (U+2F26) are indistinguishable on screen. A program carrying them fails to parse for a reason nobody can see by looking. Returns ``{character: what it normalizes to}`` for each distinct character NFKC would change. ⚠ That is a wide net, and using it as a "this text is damaged" test gives false positives on perfectly good Japanese. Full-width brackets and colons are correct typography; NFKC also turns ``①`` into ``1``, ``㎡`` into ``m2``, ``Ⅳ`` into ``IV`` and ``…`` into ``...``, none of which is a repair. Use `always_wrong_characters` to ask whether something is broken; use this one to describe what is there. """ import unicodedata out: Dict[str, str] = {} for ch in text: if ord(ch) < 128: continue replacement = unicodedata.normalize('NFKC', ch) if replacement != ch: out[ch] = replacement return out
#: The one range whose members can be called damage on sight. Their Unicode #: names say what they are -- KANGXI RADICAL CHILD, KANGXI RADICAL TALL -- so a #: character from a radical table appearing in running prose is not something #: anyone chose. All 214 normalize onto an ordinary ideograph. #: #: ⚠ Even here, "safe to repair in stored text" depends on where the text came #: from. Extracted from a PDF, one of these is an accident. Typed by a person #: or produced by a model, it is what they entered, and rewriting it is #: rewriting them. The character alone does not settle it. EXTRACTION_DAMAGE_RANGES = ( (0x2F00, 0x2FD5), # Kangxi radicals ) #: Look-alikes that may be exactly what someone meant. Normalize these into a #: *search index*, never in the stored text. #: #: The compatibility ideographs are the trap. Their names say nothing -- #: CJK COMPATIBILITY IDEOGRAPH-FA10 -- and they normalize onto 塚, 晴 and 祥, #: which is to say onto characters that appear in people's names. A document #: carrying one may be spelling somebody's name correctly, and repairing the #: stored text would spell it wrong. #: #: Full-width letters are the registered form of some company names and appear #: in quotations that must not be altered; half-width kana is a presentation #: choice. The three full-width intervals are listed separately on purpose: #: FF10-FF5A as one span would swallow the full-width colon, question mark and #: brackets, which are ordinary Japanese punctuation. PRESENTATION_RANGES = ( (0xF900, 0xFAFF), # CJK compatibility ideographs (0xFF10, 0xFF19), # full-width 0-9 (0xFF21, 0xFF3A), # full-width A-Z (0xFF41, 0xFF5A), # full-width a-z (0xFF61, 0xFF9F), # half-width kana ) #: Both, which is what a *program* cares about: QASM is ASCII by definition, so #: the question of whether a character was intended does not arise inside one. LOOK_ALIKE_RANGES = EXTRACTION_DAMAGE_RANGES + PRESENTATION_RANGES def _in_ranges(text: str, ranges, repairable: bool = True) -> Dict[str, str]: import unicodedata out: Dict[str, str] = {} for ch in text: if not any(low <= ord(ch) <= high for low, high in ranges): continue replacement = unicodedata.normalize('NFKC', ch) if (replacement != ch) == repairable: out[ch] = replacement return out
[docs] def extraction_damage(text: str) -> Dict[str, str]: """Kangxi radicals, which say in their own names that they are misplaced. U+2F26 renders exactly like U+5B50 and is a different character, so a document carrying it cannot be searched for its own words. ⚠ Whether repairing the stored text is right still depends on where the text came from: extracted from a document, one of these is an accident; typed by a person or emitted by a model, it is their input. Normalizing a search index is always safe and achieves the searching either way. """ return _in_ranges(text, EXTRACTION_DAMAGE_RANGES)
[docs] def presentation_variants(text: str) -> Dict[str, str]: """Look-alikes that may be deliberate. Normalize an index, not the text. Compatibility ideographs normalize onto characters that appear in people's names -- U+FA10, U+FA12 and U+FA1A onto 塚, 晴 and 祥 -- so a document carrying one may be spelling a name correctly. Full-width letters are the registered form of some company names and appear inside quotations. Nothing here can tell an intentional one from an accident, so rewriting the stored text changes names and misquotes sources. Normalize both the index and the query instead, and leave the text as it was written. """ return _in_ranges(text, PRESENTATION_RANGES)
[docs] def always_wrong_characters(text: str) -> Dict[str, str]: """Every look-alike, damage or presentation, with its repair. The union of `extraction_damage` and `presentation_variants`. Right for a program -- QASM is ASCII, so anything here is a mistake in one -- and the wrong question for prose, where the two halves want opposite treatment. Only characters NFKC actually changes are returned. Reporting one whose normalized form is itself would be naming a problem and offering the problem as its own solution; see `unfixable_lookalikes`. """ return _in_ranges(text, LOOK_ALIKE_RANGES)
[docs] def unfixable_lookalikes(text: str) -> Dict[str, str]: """Look-alikes that normalizing will *not* resolve. Twelve compatibility ideographs -- U+FA0E, FA0F, FA11, FA13, FA14, FA1F, FA21, FA23, FA24, FA27, FA28 and FA29 -- normalize to themselves, so U+FA11 and U+5D0E stay different after NFKC on both sides. Variant forms of a personal name are the usual way to meet them, and they need a different answer entirely. ⚠ These are not the whole of the problem, only the part that is in range. Ordinary variant ideographs -- U+9AD9 against U+9AD8, say -- are not compatibility characters at all and no normalization touches them. Matching people by name does not close either way; matching them by identifier does. Returned as ``{character: itself}`` so that "found it" and "fixed it" stay distinguishable: calling a normalization pass a resolution here is how the same report comes back a second time. """ return _in_ranges(text, LOOK_ALIKE_RANGES, repairable=False)
def _look_alike_note(text: str) -> str: """A sentence naming the look-alikes in `text`, or nothing.""" offenders = look_alike_characters(text) if not offenders: return "" shown = ', '.join(f"{ch!r} (U+{ord(ch):04X}) for {want!r}" for ch, want in list(offenders.items())[:4]) more = '' if len(offenders) <= 4 else f", and {len(offenders) - 4} more" return (f" The program contains characters that look like ASCII but are " f"not: {shown}{more}. Text pasted from a PDF or a word processor " f"does this. Pass normalize=True to from_qasm, or run the source " f"through unicodedata.normalize('NFKC', ...) first.")
[docs] def from_qasm(qasm: str, normalize: bool = False) -> Circuit: """Parse an OpenQASM 2.0 program (the qelib1.inc gate set) into a Circuit. `normalize` applies NFKC first, folding full-width punctuation and Kangxi radicals onto their ASCII and ideographic equivalents. It is off by default because silently rewriting input is a guess at what was meant; when parsing fails, the error says whether such characters are present, which is something no amount of looking at the text will reveal. """ import unicodedata if normalize: qasm = unicodedata.normalize('NFKC', qasm) try: return _parse_program(qasm) except ValueError as e: note = _look_alike_note(qasm) raise ValueError(f"{e}{note}") from None
def _parse_program(qasm: str) -> Circuit: # strip line (//) and block (/* */) comments text = re.sub(r'/\*.*?\*/', '', qasm, flags=re.DOTALL) text = re.sub(r'//.*', '', text) # The declared width is the circuit's width. Inferring it from the gates # instead loses every qubit that was declared but left idle, and shrinks a # circuit on a to_qasm/from_qasm round trip. registers, width = _parse_registers(text) c = Circuit(width) for raw_stmt in text.split(';'): stmt = raw_stmt.strip() if not stmt: continue if stmt.startswith(('OPENQASM', 'include', 'qreg', 'creg', 'gate ', 'opaque ')): continue barrier_match = re.match(r'barrier\s+(.+)$', stmt) if barrier_match: targets = _parse_targets(barrier_match.group(1), registers) if targets: c.barrier[tuple(targets) if len(targets) > 1 else targets[0]] continue measure_match = re.match(r'measure\s+(.+?)\s*->\s*(.+)$', stmt) if measure_match: targets = _parse_targets(measure_match.group(1), registers) for target in targets: c.m[target] continue gate_match = re.match(r'(\w+)\s*(?:\(([^)]*)\))?\s+(.+)$', stmt) if not gate_match: raise ValueError(f"Could not parse QASM statement: {stmt!r}") name, args_str, targets_str = gate_match.groups() if name not in _GATES: raise ValueError(f"Unsupported QASM gate: {name!r}") gate_name, n_args = _GATES[name] args = _parse_args(args_str) if args_str else [] if len(args) != n_args: raise ValueError(f"Gate {name!r} expects {n_args} argument(s), got {len(args)}") targets = _parse_targets(targets_str, registers) if not targets: raise ValueError(f"Gate {name!r} names no qubits: {stmt!r}") op = getattr(c, gate_name) if args: op = op(*args) if _arity(gate_name) == 1 and len(targets) > 1: # "h q;" over a whole register is one gate per qubit. for target in targets: op[target] else: op[targets[0] if len(targets) == 1 else tuple(targets)] return c