Spaces:
Runtime error
Runtime error
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
|