File size: 7,345 Bytes
2db2be1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
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
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
"""
spec.py — the LLM ↔ Bouncer contract.

Two representations of one quantum intent:

* **Wire form (DSL string)** — what the (re-fine-tuned) Qwen2.5-3B model emits.
  This is intentionally the *exact* format the existing 7B adapter and training
  data already use::

      [ACTION: RANDOM] [QUBITS: 3]
      [ACTION: VQE]    [DISTANCE: 0.735]
      [ACTION: BELL]   [PAIRS: 2]
      [ACTION: GROVER] [ITEMS: 4]
      [ACTION: QAOA]   [NODES: 4] [DEPTH: 1]

  Keeping the wire format stable means the 3B re-fine-tune (Task 2) produces
  output this pipeline can consume unchanged.

* **Structured form (`QuantumSpec`)** — a typed dataclass the Bouncer and the
  rest of the pipeline work with.

`interpret_query()` is the **heuristic intent parser** used until the 3B LLM
slot is wired in (Task 2). It maps free text → DSL by keyword + number
extraction, so the deterministic pipeline is fully testable without the model.
When the LLM is present, `compile_with_llm()` replaces it and the downstream
code is untouched.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, asdict
from typing import Optional

# ── Supported actions ────────────────────────────────────────────────────────

ACTIONS = ("RANDOM", "VQE", "BELL", "GROVER", "QAOA")

# ── DSL wire format ──────────────────────────────────────────────────────────

# Ordered most-specific-first so "[ACTION: GROVER]" isn't shadowed.
_DSL_PATTERNS = {
    "RANDOM": re.compile(
        r"\[ACTION:\s*RANDOM\]\s*\[QUBITS:\s*(\d+)\s*\]", re.IGNORECASE
    ),
    "VQE": re.compile(
        r"\[ACTION:\s*VQE\]\s*\[DISTANCE:\s*(\d+(?:\.\d+)?)\s*\]", re.IGNORECASE
    ),
    "BELL": re.compile(
        r"\[ACTION:\s*BELL\]\s*\[PAIRS:\s*(\d+)\s*\]", re.IGNORECASE
    ),
    "GROVER": re.compile(
        r"\[ACTION:\s*GROVER\]\s*\[ITEMS:\s*(\d+)\s*\]", re.IGNORECASE
    ),
    "QAOA": re.compile(
        r"\[ACTION:\s*QAOA\]\s*\[NODES:\s*(\d+)\s*\]\s*\[DEPTH:\s*(\d+)\s*\]",
        re.IGNORECASE,
    ),
}


@dataclass
class QuantumSpec:
    """A validated quantum intent, before any physics checks run."""

    action: str
    qubits: Optional[int] = None        # RANDOM, BELL, GROVER
    pairs: Optional[int] = None         # BELL
    items: Optional[int] = None         # GROVER
    nodes: Optional[int] = None         # QAOA
    depth: Optional[int] = None         # QAOA
    distance: Optional[float] = None    # VQE (Å)

    def to_dsl(self) -> str:
        if self.action == "RANDOM":
            return f"[ACTION: RANDOM] [QUBITS: {self.qubits}]"
        if self.action == "VQE":
            return f"[ACTION: VQE] [DISTANCE: {self.distance:g}]"
        if self.action == "BELL":
            return f"[ACTION: BELL] [PAIRS: {self.pairs}]"
        if self.action == "GROVER":
            return f"[ACTION: GROVER] [ITEMS: {self.items}]"
        if self.action == "QAOA":
            return f"[ACTION: QAOA] [NODES: {self.nodes}] [DEPTH: {self.depth}]"
        raise ValueError(f"Unknown action: {self.action!r}")

    def to_dict(self) -> dict:
        return asdict(self)


# ── DSL → spec ───────────────────────────────────────────────────────────────

def parse_dsl(dsl_string: str) -> Optional[QuantumSpec]:
    """
    Parse a DSL wire string into a QuantumSpec, or None if it doesn't match.

    This is a *pure* parse: no physics bounds are checked here — that is the
    Bouncer's job. Returns None (not an error dict) so callers can decide how
    to surface a non-match.
    """
    raw = dsl_string.strip()
    for action, pattern in _DSL_PATTERNS.items():
        m = pattern.match(raw)
        if not m:
            continue
        if action == "RANDOM":
            return QuantumSpec(action="RANDOM", qubits=int(m.group(1)))
        if action == "VQE":
            return QuantumSpec(action="VQE", distance=float(m.group(1)))
        if action == "BELL":
            return QuantumSpec(action="BELL", pairs=int(m.group(1)))
        if action == "GROVER":
            return QuantumSpec(action="GROVER", items=int(m.group(1)))
        if action == "QAOA":
            return QuantumSpec(action="QAOA", nodes=int(m.group(1)),
                               depth=int(m.group(2)))
    return None


# ── Heuristic intent parser (fallback when no LLM is loaded) ─────────────────
#
# Keyword → action mapping, then extract the leading numeric argument. Defaults
# are conservative and intentionally pick the smallest meaningful circuit so
# the demo never silently balloons into a large one.

_KEYWORD = {
    "RANDOM": ("random", "superposition", "coin flip", "random number",
               "random bits", "randomness"),
    "VQE":    ("vqe", "ground state", "ground-state", "hydrogen", "h2",
               "molecular energy", "bond", "eigensolver", "energy of"),
    "BELL":   ("bell", "entangle", "entanglement", "epr", "bell pair"),
    "GROVER": ("grover", "grover's", "search", "database", "amplitude"),
    "QAOA":   ("qaoa", "maxcut", "max-cut", "combinatorial", "optimization",
               "optimisation"),
}


def _first_number(text: str) -> Optional[float]:
    """
    Extract the first standalone number from text.

    Uses negative look-around for letters so chemical-formula subscripts
    (the '2' in 'H2', the '3' in 'NH3', the '2O' in 'H2O') are NOT picked up.
    This matters for VQE queries like 'H2 at 0.735 Angstroms' — without it,
    the '2' in H2 is matched before '0.735' and the distance silently becomes 2.0.
    """
    nums = re.findall(r"(?<![A-Za-z])\d+(?:\.\d+)?(?![A-Za-z])", text)
    return float(nums[0]) if nums else None


def interpret_query(query: str) -> Optional[QuantumSpec]:
    """
    Map free text to a QuantumSpec using keyword + number heuristics.

    Returns None if no action keyword is recognised. This stands in for the
    Qwen2.5-3B LLM until Task 2 wires it in; the rest of the pipeline is
    identical either way because both produce a `QuantumSpec`.
    """
    q = query.lower()

    for action, keywords in _KEYWORD.items():
        if not any(kw in q for kw in keywords):
            continue
        n = _first_number(query)

        if action == "RANDOM":
            # Default 3 qubits if the user didn't specify a size.
            return QuantumSpec(action="RANDOM", qubits=int(n) if n else 3)
        if action == "VQE":
            # H₂ equilibrium bond length is the canonical default.
            return QuantumSpec(action="VQE", distance=float(n) if n else 0.735)
        if action == "BELL":
            return QuantumSpec(action="BELL", pairs=int(n) if n else 1)
        if action == "GROVER":
            # Round to a power of two-ish DB size; default 4 items.
            items = max(2, int(n) if n else 4)
            return QuantumSpec(action="GROVER", items=items)
        if action == "QAOA":
            nodes = int(n) if n else 4
            return QuantumSpec(action="QAOA", nodes=nodes, depth=1)

    return None