qubasic 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. qbasic.py +113 -0
  2. qbasic_core/__init__.py +80 -0
  3. qbasic_core/__main__.py +6 -0
  4. qbasic_core/analysis.py +179 -0
  5. qbasic_core/backend.py +76 -0
  6. qbasic_core/classic.py +419 -0
  7. qbasic_core/control_flow.py +576 -0
  8. qbasic_core/debug.py +327 -0
  9. qbasic_core/demos.py +356 -0
  10. qbasic_core/display.py +274 -0
  11. qbasic_core/engine.py +126 -0
  12. qbasic_core/engine_state.py +109 -0
  13. qbasic_core/errors.py +37 -0
  14. qbasic_core/exec_context.py +24 -0
  15. qbasic_core/executor.py +861 -0
  16. qbasic_core/expression.py +228 -0
  17. qbasic_core/file_io.py +457 -0
  18. qbasic_core/gates.py +284 -0
  19. qbasic_core/help_text.py +167 -0
  20. qbasic_core/io_protocol.py +33 -0
  21. qbasic_core/locc.py +10 -0
  22. qbasic_core/locc_commands.py +221 -0
  23. qbasic_core/locc_display.py +61 -0
  24. qbasic_core/locc_engine.py +195 -0
  25. qbasic_core/locc_execution.py +389 -0
  26. qbasic_core/memory.py +369 -0
  27. qbasic_core/mock_backend.py +66 -0
  28. qbasic_core/noise_mixin.py +96 -0
  29. qbasic_core/parser.py +564 -0
  30. qbasic_core/patterns.py +186 -0
  31. qbasic_core/profiler.py +156 -0
  32. qbasic_core/program_mgmt.py +369 -0
  33. qbasic_core/protocol.py +77 -0
  34. qbasic_core/py.typed +0 -0
  35. qbasic_core/scope.py +74 -0
  36. qbasic_core/screen.py +115 -0
  37. qbasic_core/state_display.py +60 -0
  38. qbasic_core/statements.py +387 -0
  39. qbasic_core/strings.py +107 -0
  40. qbasic_core/subs.py +261 -0
  41. qbasic_core/sweep.py +82 -0
  42. qbasic_core/terminal.py +1697 -0
  43. qubasic-0.1.0.dist-info/METADATA +736 -0
  44. qubasic-0.1.0.dist-info/RECORD +48 -0
  45. qubasic-0.1.0.dist-info/WHEEL +5 -0
  46. qubasic-0.1.0.dist-info/entry_points.txt +2 -0
  47. qubasic-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. qubasic-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1697 @@
1
+ """QBASIC terminal — REPL, commands, circuit building, LOCC execution."""
2
+
3
+ import sys
4
+ import os
5
+ import re
6
+ import time
7
+ from collections import OrderedDict
8
+
9
+ from qiskit import QuantumCircuit, transpile
10
+ from qiskit_aer import AerSimulator
11
+ try:
12
+ from qiskit_aer import AerError
13
+ except ImportError:
14
+ AerError = None
15
+ import numpy as np
16
+
17
+ from qbasic_core.engine import (
18
+ GATE_TABLE, GATE_ALIASES,
19
+ ExecResult,
20
+ _np_gate_matrix, _get_ram_gb, _estimate_gb,
21
+ MAX_QUBITS, DEFAULT_QUBITS, DEFAULT_SHOTS, MAX_UNDO_STACK,
22
+ MAX_LOOP_ITERATIONS,
23
+ OVERHEAD_FACTOR, RAM_BUDGET_FRACTION,
24
+ RE_LINE_NUM, RE_DEF_SINGLE, RE_DEF_BEGIN, RE_REG_INDEX,
25
+ RE_AT_REG, RE_AT_REG_LINE,
26
+ RE_MEAS, RE_RESET, RE_UNITARY, RE_DIM, RE_INPUT,
27
+ RE_CTRL, RE_INV,
28
+ RE_GOTO_GOSUB_TARGET, RE_MEASURE_BASIS, RE_SYNDROME,
29
+ RE_POKE, RE_SYS, RE_OPEN, RE_CLOSE, RE_PRINT_FILE, RE_INPUT_FILE,
30
+ RE_LINE_INPUT, RE_LET_STR, RE_DIM_MULTI,
31
+ RE_DATA, RE_READ, RE_ON_GOTO, RE_ON_GOSUB,
32
+ RE_SELECT_CASE, RE_CASE, RE_DO, RE_LOOP_STMT, RE_EXIT,
33
+ RE_SUB, RE_END_SUB, RE_FUNCTION, RE_END_FUNCTION,
34
+ RE_CALL, RE_LOCAL, RE_STATIC_DECL, RE_SHARED,
35
+ RE_ON_ERROR, RE_RESUME, RE_ERROR_STMT, RE_ASSERT,
36
+ RE_SWAP, RE_DEF_FN, RE_OPTION_BASE,
37
+ RE_PRINT_USING, RE_COLOR, RE_LOCATE, RE_SCREEN, RE_LPRINT,
38
+ RE_ON_MEASURE, RE_ON_TIMER, RE_CHAIN, RE_MERGE,
39
+ RE_REDIM, RE_ERASE, RE_GET,
40
+ )
41
+ from qbasic_core.executor import ExecutorMixin
42
+ from qbasic_core.expression import ExpressionMixin
43
+ from qbasic_core.display import DisplayMixin
44
+ from qbasic_core.demos import DemoMixin
45
+ from qbasic_core.locc import LOCCMixin
46
+ from qbasic_core.control_flow import ControlFlowMixin
47
+ from qbasic_core.file_io import FileIOMixin
48
+ from qbasic_core.analysis import AnalysisMixin
49
+ from qbasic_core.sweep import SweepMixin
50
+ from qbasic_core.memory import MemoryMixin
51
+ from qbasic_core.strings import StringMixin
52
+ from qbasic_core.screen import ScreenMixin
53
+ from qbasic_core.classic import ClassicMixin
54
+ from qbasic_core.subs import SubroutineMixin
55
+ from qbasic_core.debug import DebugMixin
56
+ from qbasic_core.program_mgmt import ProgramMgmtMixin
57
+ from qbasic_core.profiler import ProfilerMixin
58
+ from qbasic_core.noise_mixin import NoiseMixin
59
+ from qbasic_core.state_display import StateDisplayMixin
60
+ from qbasic_core.errors import QBasicError, QBasicBuildError, QBasicRangeError
61
+ from qbasic_core.io_protocol import StdIOPort
62
+ from qbasic_core.parser import parse_stmt
63
+ from qbasic_core.engine_state import Engine
64
+ from qbasic_core.help_text import HELP_TEXT, BANNER_ART
65
+
66
+
67
+ # ═══════════════════════════════════════════════════════════════════════
68
+ # Named quantum states for SET_STATE
69
+ # ═══════════════════════════════════════════════════════════════════════
70
+
71
+ def _resolve_named_state(name: str, n_qubits: int) -> np.ndarray:
72
+ dim = 2 ** n_qubits
73
+ sv = np.zeros(dim, dtype=complex)
74
+ if name == '|0>':
75
+ sv[0] = 1.0
76
+ elif name == '|1>':
77
+ sv[min(1, dim - 1)] = 1.0
78
+ elif name == '|+>':
79
+ sv[0] = 1.0 / np.sqrt(2)
80
+ sv[min(1, dim - 1)] = 1.0 / np.sqrt(2)
81
+ elif name == '|->':
82
+ sv[0] = 1.0 / np.sqrt(2)
83
+ sv[min(1, dim - 1)] = -1.0 / np.sqrt(2)
84
+ elif name == '|BELL>':
85
+ sv[0] = 1.0 / np.sqrt(2)
86
+ sv[dim - 1] = 1.0 / np.sqrt(2)
87
+ elif name == '|GHZ>':
88
+ sv[0] = 1.0 / np.sqrt(2)
89
+ sv[dim - 1] = 1.0 / np.sqrt(2)
90
+ elif name == '|GHZ3>':
91
+ if dim >= 8:
92
+ sv[0] = 1.0 / np.sqrt(2)
93
+ sv[7] = 1.0 / np.sqrt(2)
94
+ else:
95
+ sv[0] = 1.0 # fallback to |0>
96
+ elif name == '|GHZ4>':
97
+ if dim >= 16:
98
+ sv[0] = 1.0 / np.sqrt(2)
99
+ sv[15] = 1.0 / np.sqrt(2)
100
+ else:
101
+ sv[0] = 1.0 # fallback to |0>
102
+ elif name in ('|W>', '|W3>'):
103
+ if dim >= 8:
104
+ sv[1] = 1.0 / np.sqrt(3)
105
+ sv[2] = 1.0 / np.sqrt(3)
106
+ sv[4] = 1.0 / np.sqrt(3)
107
+ else:
108
+ # fallback: equal superposition of available states
109
+ sv[:] = 1.0 / np.sqrt(dim)
110
+ else:
111
+ sv[0] = 1.0
112
+ return sv
113
+
114
+
115
+ # ═══════════════════════════════════════════════════════════════════════
116
+ # The Terminal
117
+ # ═══════════════════════════════════════════════════════════════════════
118
+
119
+ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoMixin,
120
+ LOCCMixin, ControlFlowMixin, FileIOMixin, AnalysisMixin,
121
+ SweepMixin, MemoryMixin, StringMixin, ScreenMixin, ClassicMixin,
122
+ SubroutineMixin, DebugMixin, ProgramMgmtMixin, ProfilerMixin,
123
+ NoiseMixin, StateDisplayMixin):
124
+ # Method organization uses the mixin pattern to reduce apparent class
125
+ # size while keeping everything on QBasicTerminal for import compat.
126
+ #
127
+ # Mixins (defined above):
128
+ # ExpressionMixin — AST-based safe eval, no eval()
129
+ # DemoMixin — built-in demo circuits
130
+ #
131
+ # Concerns grouped by comment headers below:
132
+ # REPL, Commands, Run, Circuit Building,
133
+ # Display, File I/O, Analysis,
134
+ # LOCC Commands, LOCC Execution,
135
+ # Control Flow, Help.
136
+ #
137
+ # The Qiskit circuit-build path and the numpy LOCC path share
138
+ # control-flow logic via _exec_control_flow() but diverge at
139
+ # gate application: _apply_gate_str (Qiskit) vs _locc_apply_gate
140
+ # (numpy). This dualism is necessary because Qiskit does not
141
+ # natively support mid-circuit measurement with classical
142
+ # feedforward in the way LOCC protocols require.
143
+ # _get_parsed is inherited from Engine
144
+
145
+ def _gate_info(self, name: str) -> tuple[int, int] | None:
146
+ """Look up (n_params, n_qubits) for a gate name.
147
+
148
+ Checks the global GATE_TABLE first, then instance-local custom gates.
149
+ Returns None if the gate is unknown.
150
+ """
151
+ if name in GATE_TABLE:
152
+ return GATE_TABLE[name]
153
+ if name in self._custom_gates:
154
+ matrix = self._custom_gates[name]
155
+ n_qubits = int(np.log2(matrix.shape[0]))
156
+ return (0, n_qubits)
157
+ return None
158
+
159
+ @staticmethod
160
+ def _sanitize_path(path: str) -> str:
161
+ """Path sanitization: reject null bytes, control characters, and traversal.
162
+
163
+ Blocks directory-traversal sequences (..) and absolute paths that
164
+ escape the working directory. For a local REPL the user already has
165
+ shell access, but this prevents accidental overwrites of system files
166
+ and hardens INCLUDE chains against path confusion.
167
+ """
168
+ if not path or not path.strip():
169
+ raise ValueError("Empty path")
170
+ if '\x00' in path:
171
+ raise ValueError("Path contains null bytes")
172
+ if any(ord(c) < 32 for c in path):
173
+ raise ValueError("Path contains control characters")
174
+ path = path.strip()
175
+ # Reject traversal sequences — the primary injection vector
176
+ normalized = os.path.normpath(path)
177
+ if '..' in normalized.split(os.sep):
178
+ raise ValueError("Path traversal (..) is not allowed")
179
+ # Reject absolute paths (Unix / or Windows drive letters)
180
+ if os.path.isabs(path):
181
+ raise ValueError("Absolute paths are not allowed")
182
+ # Reject UNC paths (\\server\share) and extended-length paths (\\?\)
183
+ if path.startswith('\\\\'):
184
+ raise ValueError("UNC/extended paths are not allowed")
185
+ return path
186
+
187
+ def __init__(self) -> None:
188
+ """Initialize the QBASIC terminal with default configuration."""
189
+ Engine.__init__(self)
190
+ # Subsystem initialization (mixin state)
191
+ self._init_memory()
192
+ self._init_screen()
193
+ self._init_classic()
194
+ self._init_subs()
195
+ self._init_debug()
196
+ self._init_program_mgmt()
197
+ self._init_profiler()
198
+ self._init_file_handles()
199
+
200
+ # ── REPL ──────────────────────────────────────────────────────────
201
+
202
+ def _setup_readline(self) -> None:
203
+ """Set up command history and tab completion."""
204
+ try:
205
+ import readline
206
+ commands = list(GATE_TABLE.keys()) + [
207
+ 'RUN', 'LIST', 'NEW', 'SAVE', 'LOAD', 'QUBITS', 'SHOTS',
208
+ 'METHOD', 'DEF', 'REG', 'LET', 'STEP', 'STATE', 'HIST',
209
+ 'BLOCH', 'PROBS', 'DEMO', 'DELETE', 'RENUM', 'DEFS', 'REGS',
210
+ 'VARS', 'HELP', 'CIRCUIT', 'LOCC', 'SEND', 'SHARE',
211
+ 'SWEEP', 'INCLUDE', 'EXPORT', 'DECOMPOSE', 'NOISE', 'EXPECT',
212
+ 'DENSITY', 'ENTROPY', 'CSV', 'FOR', 'NEXT', 'IF', 'THEN',
213
+ 'ELSE', 'WHILE', 'WEND', 'GOTO', 'GOSUB', 'RETURN', 'END',
214
+ 'MEASURE', 'BARRIER', 'REM', 'PRINT', 'INPUT', 'DIM', 'UNITARY',
215
+ 'LOCCINFO', 'RAM', 'BYE', 'QUIT', 'EXIT',
216
+ ]
217
+ def completer(text, state):
218
+ t = text.upper()
219
+ matches = [c for c in commands if c.startswith(t)]
220
+ matches += [s for s in self.subroutines if s.startswith(t)]
221
+ matches += [v for v in self.variables if v.upper().startswith(t)]
222
+ matches += [r for r in self.registers if r.upper().startswith(t)]
223
+ return matches[state] + ' ' if state < len(matches) else None
224
+ readline.set_completer(completer)
225
+ readline.parse_and_bind('tab: complete')
226
+ readline.set_completer_delims(' \t\n')
227
+ except ImportError:
228
+ # On Windows, readline is not bundled; try pyreadline3 as fallback
229
+ if sys.platform == 'win32':
230
+ try:
231
+ import pyreadline3 # noqa: F401 — registers as readline
232
+ import readline
233
+ readline.parse_and_bind('tab: complete')
234
+ except ImportError:
235
+ pass # no readline available — tab completion disabled
236
+
237
+ def repl(self) -> None:
238
+ # Enable VT100 escape sequences on Windows console
239
+ if sys.platform == 'win32':
240
+ try:
241
+ import ctypes
242
+ kernel32 = ctypes.windll.kernel32
243
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
244
+ except Exception:
245
+ pass
246
+ self.print_banner()
247
+ self._setup_readline()
248
+ while True:
249
+ try:
250
+ line = self.io.read_line(self._prompt).strip()
251
+ if line:
252
+ self.process(line)
253
+ except KeyboardInterrupt:
254
+ self.io.writeln('')
255
+ continue
256
+ except EOFError:
257
+ self.io.writeln("\nBYE")
258
+ break
259
+
260
+ def process(self, line: str, *, track_undo: bool = True) -> None:
261
+ """Process a line of input (numbered line or immediate command)."""
262
+ # Accumulate TYPE fields when a pending TYPE definition is active
263
+ if self._pending_type is not None:
264
+ self._accumulate_type_field(line)
265
+ return
266
+ # Line number -> store in program
267
+ m = RE_LINE_NUM.match(line)
268
+ if m:
269
+ num = int(m.group(1))
270
+ content = m.group(2).strip()
271
+ # Save undo state (skip during script/file loading)
272
+ if track_undo:
273
+ self._undo_stack.append(dict(self.program))
274
+ if len(self._undo_stack) > MAX_UNDO_STACK:
275
+ self._undo_stack.pop(0)
276
+ if content:
277
+ self.program[num] = content
278
+ self._parsed[num] = parse_stmt(content)
279
+ else:
280
+ if num in self.program:
281
+ del self.program[num]
282
+ self._parsed.pop(num, None)
283
+ self.io.writeln(f"DELETED {num}")
284
+ return
285
+ # Immediate command
286
+ self.dispatch(line)
287
+
288
+ _CMD_NO_ARG = {
289
+ 'RUN': 'cmd_run', 'NEW': 'cmd_new', 'STEP': 'cmd_step',
290
+ 'HIST': 'cmd_hist', 'PROBS': 'cmd_probs', 'DEFS': 'cmd_defs',
291
+ 'REGS': 'cmd_regs', 'VARS': 'cmd_vars', 'HELP': 'cmd_help',
292
+ 'CIRCUIT': 'cmd_circuit', 'DECOMPOSE': 'cmd_decompose',
293
+ 'DENSITY': 'cmd_density', 'LOCCINFO': 'cmd_loccinfo',
294
+ 'UNDO': 'cmd_undo', 'BENCH': 'cmd_bench', 'RAM': 'cmd_ram',
295
+ 'BYE': '_quit', 'QUIT': '_quit', 'EXIT': '_quit',
296
+ # Memory
297
+ 'MAP': 'cmd_map', 'CATALOG': 'cmd_catalog', 'MONITOR': 'cmd_monitor',
298
+ # Screen
299
+ 'CLS': 'cmd_cls', 'PLAY': 'cmd_play',
300
+ # Debug
301
+ 'TRON': 'cmd_tron', 'TROFF': 'cmd_troff', 'CONT': 'cmd_cont',
302
+ 'HISTORY': 'cmd_history',
303
+ # Program management
304
+ 'CHECKSUM': 'cmd_checksum',
305
+ # Classic
306
+ 'RESTORE': 'cmd_restore',
307
+ }
308
+ _CMD_WITH_ARG = {
309
+ 'LIST': 'cmd_list', 'QUBITS': 'cmd_qubits', 'SHOTS': 'cmd_shots',
310
+ 'METHOD': 'cmd_method', 'DEF': 'cmd_def', 'REG': 'cmd_reg',
311
+ 'LET': 'cmd_let', 'STATE': 'cmd_state', 'BLOCH': 'cmd_bloch',
312
+ 'DEMO': 'cmd_demo', 'DELETE': 'cmd_delete', 'RENUM': 'cmd_renum',
313
+ 'SAVE': 'cmd_save', 'LOAD': 'cmd_load', 'SWEEP': 'cmd_sweep',
314
+ 'INCLUDE': 'cmd_include', 'EXPORT': 'cmd_export',
315
+ 'NOISE': 'cmd_noise', 'EXPECT': 'cmd_expect',
316
+ 'ENTROPY': 'cmd_entropy', 'CSV': 'cmd_csv', 'LOCC': 'cmd_locc',
317
+ 'SEND': 'cmd_send', 'SHARE': 'cmd_share', 'DIR': 'cmd_dir',
318
+ 'CLEAR': 'cmd_clear',
319
+ # Memory
320
+ 'PEEK': 'cmd_peek', 'POKE': 'cmd_poke', 'SYS': 'cmd_sys', 'DUMP': 'cmd_dump', 'WAIT': 'cmd_wait',
321
+ # Screen
322
+ 'SCREEN': 'cmd_screen', 'COLOR': 'cmd_color', 'LOCATE': 'cmd_locate',
323
+ 'PROMPT': 'cmd_prompt',
324
+ # Debug
325
+ 'BREAK': 'cmd_breakpoint', 'WATCH': 'cmd_watch', 'PROFILE': 'cmd_profile',
326
+ 'REWIND': 'cmd_rewind', 'FORWARD': 'cmd_forward',
327
+ 'STATS': 'cmd_stats',
328
+ # Program management
329
+ 'AUTO': 'cmd_auto', 'EDIT': 'cmd_edit', 'COPY': 'cmd_copy',
330
+ 'MOVE': 'cmd_move', 'FIND': 'cmd_find', 'REPLACE': 'cmd_replace',
331
+ 'BANK': 'cmd_bank', 'CHAIN': 'cmd_chain', 'MERGE': 'cmd_merge',
332
+ # File handles
333
+ 'OPEN': 'cmd_open', 'CLOSE': 'cmd_close',
334
+ # Module, types, network, primitives
335
+ 'IMPORT': 'cmd_import', 'TYPE': 'cmd_type',
336
+ 'CONNECT': 'cmd_connect', 'DISCONNECT': 'cmd_disconnect',
337
+ 'SAMPLE': 'cmd_sample', 'ESTIMATE': 'cmd_estimate',
338
+ # Circuit macros
339
+ 'CIRCUIT_DEF': 'cmd_circuit_def', 'APPLY_CIRCUIT': 'cmd_apply_circuit',
340
+ }
341
+
342
+ def dispatch(self, line: str) -> None:
343
+ """Parse and execute an immediate command or gate."""
344
+ parts = line.split(None, 1)
345
+ if not parts:
346
+ return
347
+ cmd = parts[0].upper()
348
+ rest = parts[1].strip() if len(parts) > 1 else ''
349
+
350
+ method_name = self._CMD_WITH_ARG.get(cmd) or self._CMD_NO_ARG.get(cmd)
351
+ if method_name:
352
+ try:
353
+ method = getattr(self, method_name)
354
+ if cmd in self._CMD_WITH_ARG:
355
+ method(rest)
356
+ else:
357
+ method()
358
+ except EOFError:
359
+ raise
360
+ except QBasicError as e:
361
+ self.io.writeln(f"?{e.message}")
362
+ except Exception as e:
363
+ self.io.writeln(f"?ERROR: {e}")
364
+ else:
365
+ # Try as immediate gate / subroutine
366
+ try:
367
+ self.run_immediate(line)
368
+ except Exception as e:
369
+ self.io.writeln(f"?SYNTAX ERROR: {e}")
370
+
371
+ def _quit(self) -> None:
372
+ raise EOFError
373
+
374
+ # ── Commands ──────────────────────────────────────────────────────
375
+
376
+ def cmd_qubits(self, rest: str) -> None:
377
+ """Set or display the number of qubits. Range: 1 to MAX_QUBITS."""
378
+ if not rest:
379
+ self.io.writeln(f"QUBITS = {self.num_qubits}")
380
+ return
381
+ n = int(rest)
382
+ if n < 1 or n > MAX_QUBITS:
383
+ from qbasic_core.errors import QBasicRangeError
384
+ raise QBasicRangeError(f"RANGE: 1-{MAX_QUBITS}")
385
+
386
+ self.num_qubits = n
387
+ self.registers.clear()
388
+ est = _estimate_gb(n)
389
+ self.io.writeln(f"{n} QUBITS ALLOCATED (~{est:.1f} GB per instance)")
390
+ ram = _get_ram_gb()
391
+ if ram:
392
+ total, avail = ram
393
+ budget = total * RAM_BUDGET_FRACTION
394
+ if est > avail:
395
+ self.io.writeln(f" WARNING: exceeds available RAM ({avail:.1f} GB free of {total:.0f} GB)")
396
+ elif est > budget * 0.5:
397
+ self.io.writeln(f" NOTE: uses {est/budget*100:.0f}% of 80% RAM budget ({budget:.1f} GB)")
398
+ if n >= 16 and est > 0:
399
+ max_par = int(budget / est)
400
+ if max_par > 0:
401
+ self.io.writeln(f" Max parallel instances in 80% budget: ~{max_par}")
402
+
403
+ def cmd_shots(self, rest: str) -> None:
404
+ """Set or display the number of measurement shots."""
405
+ if not rest:
406
+ self.io.writeln(f"SHOTS = {self.shots}")
407
+ return
408
+ self.shots = max(1, int(rest))
409
+ self.io.writeln(f"SHOTS = {self.shots}")
410
+
411
+ def cmd_method(self, rest: str) -> None:
412
+ """Set simulation method (statevector, stabilizer, MPS, ...) or device (CPU/GPU)."""
413
+ if not rest:
414
+ self.io.writeln(f"METHOD = {self.sim_method} DEVICE = {self.sim_device}")
415
+ methods = ['automatic', 'statevector', 'density_matrix',
416
+ 'stabilizer', 'matrix_product_state', 'extended_stabilizer',
417
+ 'unitary', 'superop']
418
+ self.io.writeln(f" methods: {', '.join(methods)}")
419
+ self.io.writeln(f" devices: CPU, GPU")
420
+ return
421
+ val = rest.strip().upper()
422
+ if val in ('GPU', 'CPU'):
423
+ self.sim_device = val
424
+ self.io.writeln(f"DEVICE = {self.sim_device}")
425
+ else:
426
+ self.sim_method = rest.strip().lower()
427
+ self.io.writeln(f"METHOD = {self.sim_method}")
428
+
429
+ def cmd_list(self, rest: str = '') -> None:
430
+ """LIST — display program lines. LIST SUBS|VARS|ARRAYS for filtered views."""
431
+ arg = rest.strip().upper()
432
+ if arg == 'SUBS':
433
+ return self.cmd_list_subs()
434
+ if arg == 'VARS':
435
+ return self.cmd_list_vars()
436
+ if arg == 'ARRAYS':
437
+ return self.cmd_list_arrays()
438
+ if not self.program:
439
+ self.io.writeln("EMPTY PROGRAM")
440
+ return
441
+ lines = sorted(self.program.keys())
442
+ for num in lines:
443
+ self.io.writeln(f" {num:5d} {self.program[num]}")
444
+
445
+ def cmd_new(self, *, silent: bool = False) -> None:
446
+ """NEW — clear program, subroutines, registers, and variables."""
447
+ self.clear()
448
+ if not silent:
449
+ self.io.writeln("READY")
450
+
451
+ def cmd_delete(self, rest: str) -> None:
452
+ """DELETE <line> or DELETE <start>-<end> — remove program lines."""
453
+ if not rest:
454
+ self.io.writeln("?USAGE: DELETE <line> or DELETE <start>-<end>")
455
+ return
456
+ if '-' in rest:
457
+ a, b = rest.split('-')
458
+ a, b = int(a.strip()), int(b.strip())
459
+ for k in list(self.program.keys()):
460
+ if a <= k <= b:
461
+ del self.program[k]
462
+ self._parsed.pop(k, None)
463
+ self.io.writeln(f"DELETED {a}-{b}")
464
+ else:
465
+ n = int(rest)
466
+ if n in self.program:
467
+ del self.program[n]
468
+ self._parsed.pop(n, None)
469
+ self.io.writeln(f"DELETED {n}")
470
+ else:
471
+ self.io.writeln(f"?LINE {n} NOT FOUND")
472
+
473
+ def cmd_renum(self, rest: str = '') -> None:
474
+ """RENUM [start] [step] — renumber lines, update GOTO/GOSUB targets."""
475
+ if not self.program:
476
+ return
477
+ parts = rest.split() if rest else []
478
+ start = int(parts[0]) if len(parts) > 0 else 10
479
+ step = int(parts[1]) if len(parts) > 1 else 10
480
+ old_lines = sorted(self.program.keys())
481
+ # Build mapping: old line number -> new line number
482
+ line_map = {}
483
+ for i, old in enumerate(old_lines):
484
+ line_map[old] = start + i * step
485
+ # Renumber and update GOTO/GOSUB references
486
+ new_prog = {}
487
+ for i, old in enumerate(old_lines):
488
+ stmt = self.program[old]
489
+ # Update GOTO/GOSUB targets
490
+ def replace_target(m):
491
+ target = int(m.group(2))
492
+ new_target = line_map.get(target, target)
493
+ return f"{m.group(1)} {new_target}"
494
+ stmt = RE_GOTO_GOSUB_TARGET.sub(replace_target, stmt)
495
+
496
+ # Update ON ... GOTO/GOSUB comma-separated target lists.
497
+ # RE_GOTO_GOSUB_TARGET remaps the first number after GOTO/GOSUB,
498
+ # but ON x GOTO 100, 200, 300 leaves the ", 200, 300" tail
499
+ # untouched. Walk any trailing comma-separated numbers and
500
+ # remap them too.
501
+ def _remap_on_target_list(m):
502
+ keyword = m.group(1) # GOTO or GOSUB
503
+ nums_str = m.group(2) # "10, 20, 30"
504
+ parts = nums_str.split(',')
505
+ remapped = []
506
+ for p in parts:
507
+ stripped = p.strip()
508
+ if stripped.isdigit():
509
+ n = int(stripped)
510
+ remapped.append(str(line_map.get(n, n)))
511
+ else:
512
+ remapped.append(p)
513
+ return f"{keyword} {', '.join(remapped)}"
514
+ stmt = re.sub(
515
+ r'(GOTO|GOSUB)\s+(\d+(?:\s*,\s*\d+)+)',
516
+ _remap_on_target_list, stmt, flags=re.IGNORECASE)
517
+
518
+ # Update ON ERROR GOTO <line-number> targets.
519
+ def _remap_on_error(m):
520
+ target = int(m.group(1))
521
+ return f"ON ERROR GOTO {line_map.get(target, target)}"
522
+ stmt = re.sub(
523
+ r'\bON\s+ERROR\s+GOTO\s+(\d+)', _remap_on_error, stmt,
524
+ flags=re.IGNORECASE)
525
+
526
+ # Update RESUME <line-number> targets.
527
+ def _remap_resume(m):
528
+ target = int(m.group(1))
529
+ return f"RESUME {line_map.get(target, target)}"
530
+ stmt = re.sub(
531
+ r'\bRESUME\s+(\d+)', _remap_resume, stmt,
532
+ flags=re.IGNORECASE)
533
+
534
+ new_prog[line_map[old]] = stmt
535
+ self.program = new_prog
536
+ self._parsed = {num: parse_stmt(s) for num, s in new_prog.items()}
537
+ self.io.writeln(f"RENUMBERED {len(new_prog)} LINES (start={start}, step={step})")
538
+
539
+ # cmd_save, cmd_load provided by FileIOMixin.
540
+
541
+ def cmd_def(self, rest: str) -> None:
542
+ """Define a named gate sequence (subroutine), optionally parameterized."""
543
+ # Single-line: DEF BELL = H 0 : CX 0,1
544
+ # Parameterized: DEF ROTATE(angle, q) = RX angle, q : RZ angle, q
545
+ # Multi-line: DEF BEGIN NAME[(params)] ... DEF END
546
+ upper = rest.upper().strip()
547
+ if upper.startswith('BEGIN'):
548
+ return self._def_multiline(rest[5:].strip())
549
+
550
+ m = RE_DEF_SINGLE.match(rest)
551
+ if not m:
552
+ self.io.writeln("?USAGE: DEF NAME[(params)] = GATE : GATE : ...")
553
+ self.io.writeln(" DEF BEGIN NAME[(params)] (multi-line, end with DEF END)")
554
+ return
555
+ name = m.group(1).upper()
556
+ params = [p.strip() for p in m.group(2).split(',')] if m.group(2) else []
557
+ body = [s.strip() for s in m.group(3).split(':') if s.strip()]
558
+ if name in GATE_TABLE:
559
+ from qbasic_core.errors import QBasicSyntaxError
560
+ raise QBasicSyntaxError(f"CANNOT REDEFINE BUILT-IN GATE {name}")
561
+ self.subroutines[name] = {'body': body, 'params': params}
562
+ if params:
563
+ self.io.writeln(f"DEF {name}({', '.join(params)}) ({len(body)} gates)")
564
+ else:
565
+ self.io.writeln(f"DEF {name} ({len(body)} gates)")
566
+
567
+ def _def_multiline(self, header: str) -> None:
568
+ """Read multi-line DEF block from REPL."""
569
+ m = re.match(r'(\w+)(?:\(([^)]*)\))?', header)
570
+ if not m:
571
+ self.io.writeln("?USAGE: DEF BEGIN NAME[(params)]")
572
+ return
573
+ name = m.group(1).upper()
574
+ params = [p.strip() for p in m.group(2).split(',')] if m.group(2) else []
575
+ if name in GATE_TABLE:
576
+ from qbasic_core.errors import QBasicSyntaxError
577
+ raise QBasicSyntaxError(f"CANNOT REDEFINE BUILT-IN GATE {name}")
578
+
579
+ body = []
580
+ self.io.writeln(f" DEF {name} (type gates, DEF END to finish)")
581
+ while True:
582
+ try:
583
+ line = self.io.read_line(' . ').strip()
584
+ except (KeyboardInterrupt, EOFError):
585
+ self.io.writeln("\n CANCELLED")
586
+ return
587
+ if line.upper() == 'DEF END' or line.upper() == 'END':
588
+ break
589
+ if line:
590
+ body.append(line)
591
+ self.subroutines[name] = {'body': body, 'params': params}
592
+ self.io.writeln(f"DEF {name} ({len(body)} gates)")
593
+
594
+ def cmd_type(self, rest: str) -> None:
595
+ """TYPE name — define a named record type.
596
+
597
+ Usage: TYPE Point
598
+ x AS FLOAT
599
+ y AS FLOAT
600
+ END TYPE
601
+ Then: DIM p AS Point
602
+ LET p.x = 3.14
603
+
604
+ Works both interactively (REPL) and non-interactively (LOAD/INCLUDE).
605
+ In non-interactive mode, subsequent lines are routed through process()
606
+ which delegates to _accumulate_type_field until END TYPE is seen.
607
+ """
608
+ from qbasic_core.engine import RE_TYPE_BEGIN
609
+ m = RE_TYPE_BEGIN.match(f"TYPE {rest}")
610
+ if not m:
611
+ self.io.writeln("?USAGE: TYPE <name>")
612
+ return
613
+ type_name = m.group(1).upper()
614
+ # Always use the accumulation path — works for both REPL and scripts.
615
+ # In REPL, repl() calls process() per line, which feeds _accumulate_type_field.
616
+ # In scripts, cmd_load/cmd_include call process() per line likewise.
617
+ self._pending_type = {'name': type_name, 'fields': []}
618
+ self.io.writeln(f" TYPE {type_name} (enter fields, END TYPE to finish)")
619
+
620
+ def _accumulate_type_field(self, line: str) -> None:
621
+ """Accumulate a field for a pending TYPE definition, or finalize on END TYPE."""
622
+ from qbasic_core.engine import RE_TYPE_FIELD, RE_END_TYPE
623
+ stripped = line.strip()
624
+ if RE_END_TYPE.match(stripped):
625
+ # Finalize the type definition
626
+ type_name = self._pending_type['name']
627
+ fields = self._pending_type['fields']
628
+ self._pending_type = None
629
+ self._user_types[type_name] = fields
630
+ self.io.writeln(f"TYPE {type_name} ({len(fields)} fields)")
631
+ return
632
+ fm = RE_TYPE_FIELD.match(stripped)
633
+ if fm:
634
+ self._pending_type['fields'].append((fm.group(1).lower(), fm.group(2).upper()))
635
+ elif stripped:
636
+ self.io.writeln(f" ?EXPECTED: <name> AS <type>")
637
+
638
+ def cmd_circuit_def(self, rest: str) -> None:
639
+ """CIRCUIT_DEF name start-end — define a circuit macro from line range."""
640
+ m = re.match(r'(\w+)\s+(\d+)\s*-\s*(\d+)', rest)
641
+ if not m:
642
+ self.io.writeln("?USAGE: CIRCUIT_DEF <name> <start>-<end>")
643
+ return
644
+ name = m.group(1).upper()
645
+ start, end = int(m.group(2)), int(m.group(3))
646
+ body = []
647
+ for ln in sorted(self.program.keys()):
648
+ if start <= ln <= end:
649
+ body.append(self.program[ln])
650
+ if not body:
651
+ self.io.writeln(f"?NO LINES IN RANGE {start}-{end}")
652
+ return
653
+ self.subroutines[name] = {'body': body, 'params': []}
654
+ self.io.writeln(f"CIRCUIT {name} = lines {start}-{end} ({len(body)} gates)")
655
+
656
+ def cmd_apply_circuit(self, rest: str) -> None:
657
+ """APPLY_CIRCUIT name [@offset] — apply a circuit macro."""
658
+ m = re.match(r'(\w+)(?:\s+@(\d+))?', rest)
659
+ if not m:
660
+ self.io.writeln("?USAGE: APPLY_CIRCUIT <name> [@offset]")
661
+ return
662
+ name = m.group(1).upper()
663
+ offset = int(m.group(2)) if m.group(2) else 0
664
+ if name not in self.subroutines:
665
+ self.io.writeln(f"?UNDEFINED CIRCUIT: {name}")
666
+ return
667
+ call_str = f"{name} @{offset}" if offset else name
668
+ self.run_immediate(call_str)
669
+
670
+ def cmd_reg(self, rest: str) -> None:
671
+ """REG <name> <size> — allocate a named qubit register."""
672
+ # REG data 3
673
+ parts = rest.split()
674
+ if len(parts) != 2:
675
+ self.io.writeln("?USAGE: REG <name> <size>")
676
+ return
677
+ name = parts[0].lower()
678
+ size = int(parts[1])
679
+ # Allocate starting after existing registers
680
+ start = sum(s for _, s in self.registers.values())
681
+ if start + size > self.num_qubits:
682
+ from qbasic_core.errors import QBasicRangeError
683
+ raise QBasicRangeError(f"NOT ENOUGH QUBITS (need {start+size}, have {self.num_qubits})")
684
+
685
+ self.registers[name] = (start, size)
686
+ self.io.writeln(f"REG {name} = qubits {start}-{start+size-1}")
687
+
688
+ def cmd_let(self, rest: str) -> None:
689
+ """LET <var> = <expr> — assign a computed value to a variable."""
690
+ # LET angle = PI/4
691
+ m = re.match(r'(\w+)\s*=\s*(.*)', rest)
692
+ if not m:
693
+ self.io.writeln("?USAGE: LET <var> = <expr>")
694
+ return
695
+ name = m.group(1)
696
+ val = self.eval_expr(m.group(2))
697
+ self.variables[name] = val
698
+ self.io.writeln(f"{name} = {val}")
699
+
700
+ # cmd_defs, cmd_regs, cmd_vars provided by ProgramMgmtMixin.
701
+
702
+ # ── Run ───────────────────────────────────────────────────────────
703
+
704
+ def cmd_run(self) -> None:
705
+ """Execute the stored program."""
706
+ if self.locc_mode:
707
+ return self._locc_run()
708
+ if not self.program:
709
+ self.io.writeln("NOTHING TO RUN")
710
+ return
711
+
712
+ t0 = time.time()
713
+ self._gosub_stack = []
714
+ self._collect_data()
715
+ sorted_lines = sorted(self.program.keys())
716
+ self._scan_subs(sorted_lines)
717
+ self._validate_program(sorted_lines)
718
+
719
+ # Build circuit
720
+ try:
721
+ qc, has_measure = self.build_circuit()
722
+ except KeyboardInterrupt:
723
+ self.io.writeln("\n?INTERRUPTED")
724
+ return
725
+ except Exception as e:
726
+ if hasattr(self, '_error_target') and self._error_target is not None:
727
+ self._err_code = 1
728
+ self._err_line = 0
729
+ self._in_error_handler = True
730
+ msg = str(e)
731
+ if msg.startswith('ERROR '):
732
+ try:
733
+ self._err_code = int(msg.split()[1])
734
+ except (IndexError, ValueError):
735
+ pass
736
+ self.variables['ERR'] = self._err_code
737
+ self.variables['ERL'] = self._err_line
738
+ self.io.writeln(f"?BUILD ERROR (trapped): {e}")
739
+ else:
740
+ self.io.writeln(f"?BUILD ERROR: {e}")
741
+ return
742
+
743
+ # Copy before adding measurements (for statevector extraction)
744
+ qc_sv = qc.copy()
745
+
746
+ # Unitary/superop methods need save instructions, not measurements
747
+ if self.sim_method in ('unitary', 'superop'):
748
+ if self.sim_method == 'unitary':
749
+ qc.save_unitary(label='unitary')
750
+ else:
751
+ qc.save_superop(label='superop')
752
+ elif has_measure:
753
+ qc.measure_all()
754
+
755
+ self.last_circuit = qc
756
+
757
+ # Run with shots (cache transpiled circuit if program unchanged)
758
+ try:
759
+ method = self.sim_method
760
+ if method == 'automatic':
761
+ if self.num_qubits > 28:
762
+ method = 'matrix_product_state'
763
+ elif not self._noise_model and self._is_clifford(qc):
764
+ method = 'stabilizer'
765
+ backend_opts = {'method': method}
766
+ if self.sim_device == 'GPU':
767
+ backend_opts['device'] = 'GPU'
768
+ if self._noise_model:
769
+ backend_opts['noise_model'] = self._noise_model
770
+ # Performance tuning from memory-mapped config
771
+ if hasattr(self, '_fusion_enable'):
772
+ backend_opts['fusion_enable'] = self._fusion_enable
773
+ if hasattr(self, '_mps_truncation'):
774
+ backend_opts['matrix_product_state_truncation_threshold'] = self._mps_truncation
775
+ if hasattr(self, '_sv_parallel_threshold'):
776
+ backend_opts['statevector_parallel_threshold'] = self._sv_parallel_threshold
777
+ if hasattr(self, '_es_approx_error'):
778
+ backend_opts['extended_stabilizer_approximation_error'] = self._es_approx_error
779
+ cache_key = (
780
+ tuple(sorted(self.program.items())),
781
+ self.num_qubits, method, self.sim_device,
782
+ id(self._noise_model),
783
+ getattr(self, '_fusion_enable', None),
784
+ getattr(self, '_mps_truncation', None),
785
+ getattr(self, '_sv_parallel_threshold', None),
786
+ getattr(self, '_es_approx_error', None),
787
+ )
788
+ if self._circuit_cache_key == cache_key and self._circuit_cache is not None:
789
+ qc_t, backend = self._circuit_cache
790
+ else:
791
+ backend = AerSimulator(**backend_opts)
792
+ qc_t = transpile(qc, backend)
793
+ self._circuit_cache_key = cache_key
794
+ self._circuit_cache = (qc_t, backend)
795
+ try:
796
+ result = backend.run(qc_t, shots=self.shots).result()
797
+ except KeyboardInterrupt:
798
+ self.io.writeln("\n?INTERRUPTED")
799
+ return
800
+ except Exception as _sim_err:
801
+ _err_msg = str(_sim_err).lower()
802
+ if (AerError is not None and isinstance(_sim_err, AerError) and 'stabilizer' in _err_msg) or \
803
+ ('stabilizer' in _err_msg and 'invalid parameters' in _err_msg):
804
+ self._circuit_cache_key = None
805
+ self._circuit_cache = None
806
+ sv_opts = {k: v for k, v in backend_opts.items()
807
+ if k != 'method'}
808
+ sv_opts['method'] = 'statevector'
809
+ backend = AerSimulator(**sv_opts)
810
+ qc_t = transpile(qc, backend)
811
+ result = backend.run(qc_t, shots=self.shots).result()
812
+ else:
813
+ raise
814
+ # Extract results based on method
815
+ if method in ('unitary', 'superop'):
816
+ self.last_counts = None
817
+ data = result.data()
818
+ label = 'unitary' if method == 'unitary' else 'superop'
819
+ mat = data.get(label)
820
+ if mat is not None:
821
+ mat_np = np.asarray(mat)
822
+ self.variables[label] = mat_np
823
+ dim = mat_np.shape[0]
824
+ self.io.writeln(f"\n {label.upper()} ({dim}x{dim}):")
825
+ if dim <= 16:
826
+ for i in range(dim):
827
+ row = ' '.join(f"{v.real:+.3f}{v.imag:+.3f}j" for v in mat_np[i])
828
+ self.io.writeln(f" {row}")
829
+ else:
830
+ self.io.writeln(f" (too large to display — stored in variable '{label}')")
831
+ else:
832
+ self.last_counts = dict(result.get_counts())
833
+ # Extract save instruction results into BASIC variables
834
+ data = result.data()
835
+ for key, val in data.items():
836
+ if key.startswith('exp_'):
837
+ var = key[4:]
838
+ self.variables[var] = float(np.real(val))
839
+ elif key.startswith('prob_'):
840
+ var = key[5:]
841
+ self.variables[var] = val
842
+ if isinstance(val, np.ndarray):
843
+ self.arrays[var] = val.tolist()
844
+ elif key.startswith('amp_'):
845
+ var = key[4:]
846
+ if isinstance(val, np.ndarray):
847
+ self.arrays[var] = [complex(v) for v in val]
848
+ self.variables[var] = val
849
+ except Exception as e:
850
+ self.io.writeln(f"?RUNTIME ERROR: {e}")
851
+ return
852
+
853
+ # Get statevector from the measurement-free copy
854
+ if method not in ('unitary', 'superop'):
855
+ try:
856
+ qc_sv.save_statevector()
857
+ sv_backend = AerSimulator(method='statevector')
858
+ sv_result = sv_backend.run(transpile(qc_sv, sv_backend)).result()
859
+ self.last_sv = np.array(sv_result.get_statevector())
860
+ except Exception:
861
+ self.last_sv = None
862
+ else:
863
+ self.last_sv = None
864
+
865
+ dt = time.time() - t0
866
+
867
+ # Update status registers
868
+ depth = qc.depth()
869
+ n_gates = qc.size()
870
+ self._update_status(gate_count=n_gates, circuit_depth=depth,
871
+ run_time_ms=dt * 1000)
872
+
873
+ # Display results
874
+ self.io.writeln(f"\nRAN {len(self.program)} lines, {self.num_qubits} qubits, "
875
+ f"{self.shots} shots in {dt:.2f}s [depth={depth}, gates={n_gates}]")
876
+ if method in ('unitary', 'superop'):
877
+ pass # matrix already displayed above
878
+ elif has_measure and self.last_counts:
879
+ self.print_histogram(self.last_counts)
880
+ self._auto_display()
881
+ else:
882
+ self.io.writeln("(no MEASURE in program — use STATE or PROBS to inspect)")
883
+
884
+ def cmd_sample(self, rest: str = '') -> None:
885
+ """SAMPLE [shots] — sample the current circuit using SamplerV2 primitive."""
886
+ if not self.program:
887
+ self.io.writeln("NOTHING TO SAMPLE")
888
+ return
889
+ shots = int(rest.strip()) if rest.strip() else self.shots
890
+ try:
891
+ qc, has_measure = self.build_circuit()
892
+ if not has_measure:
893
+ qc.measure_all()
894
+ from qiskit_aer.primitives import SamplerV2
895
+ sampler = SamplerV2()
896
+ result = sampler.run([qc], shots=shots).result()
897
+ # Extract counts — SamplerV2 result format varies by Qiskit version.
898
+ # Try the V2 get_counts() on named data attributes first, then fall
899
+ # back to iterating data attributes for older layouts.
900
+ pub_result = result[0]
901
+ counts = {}
902
+ # V2 preferred path: named classical registers expose get_counts()
903
+ try:
904
+ for attr_name in dir(pub_result.data):
905
+ if attr_name.startswith('_'):
906
+ continue
907
+ obj = getattr(pub_result.data, attr_name, None)
908
+ if obj is not None and hasattr(obj, 'get_counts'):
909
+ counts = dict(obj.get_counts())
910
+ break
911
+ except Exception:
912
+ pass
913
+ # Fallback: try legacy get_counts directly on data
914
+ if not counts:
915
+ try:
916
+ counts = dict(pub_result.data.get_counts())
917
+ except Exception:
918
+ pass
919
+ self.last_counts = counts
920
+ self.io.writeln(f"SAMPLED {shots} shots ({len(counts)} unique outcomes)")
921
+ if counts:
922
+ self.print_histogram(counts)
923
+ except Exception as e:
924
+ self.io.writeln(f"?SAMPLE ERROR: {e}")
925
+
926
+ def cmd_estimate(self, rest: str) -> None:
927
+ """ESTIMATE <pauli> [qubits] — estimate observable expectation via EstimatorV2."""
928
+ parts = rest.split()
929
+ if not parts:
930
+ self.io.writeln("?USAGE: ESTIMATE <Z|ZZ|XY|...> [qubits]")
931
+ return
932
+ pauli_str = parts[0].upper()
933
+ qubits = [int(q) for q in parts[1:]] if len(parts) > 1 else list(range(len(pauli_str)))
934
+ try:
935
+ from qiskit.quantum_info import SparsePauliOp
936
+ from qiskit_aer.primitives import EstimatorV2
937
+ from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
938
+ qc, _ = self.build_circuit()
939
+ full_pauli = ['I'] * self.num_qubits
940
+ for i, p in enumerate(pauli_str):
941
+ if i < len(qubits):
942
+ full_pauli[self.num_qubits - 1 - qubits[i]] = p
943
+ op = SparsePauliOp(''.join(full_pauli))
944
+ estimator = EstimatorV2()
945
+ pm = generate_preset_pass_manager(optimization_level=0)
946
+ qc_t = pm.run(qc)
947
+ result = estimator.run([(qc_t, op)]).result()
948
+ val = result[0].data.evs
949
+ self.io.writeln(f" <{pauli_str}> on qubits {qubits} = {float(val):.6f}")
950
+ except Exception as e:
951
+ self.io.writeln(f"?ESTIMATE ERROR: {e}")
952
+
953
+ def cmd_step(self) -> None:
954
+ """Step through program, showing state after each line."""
955
+ if not self.program:
956
+ self.io.writeln("NOTHING TO STEP")
957
+ return
958
+
959
+ from qbasic_core.exec_context import ExecContext
960
+ from qbasic_core.scope import Scope
961
+
962
+ sorted_lines = sorted(self.program.keys())
963
+ qc = QuantumCircuit(self.num_qubits)
964
+ ctx = ExecContext(
965
+ sorted_lines=sorted_lines, ip=0,
966
+ run_vars=Scope(self.variables),
967
+ max_iterations=self._max_iterations, qc=qc,
968
+ )
969
+
970
+ self.io.writeln(f"STEP MODE — {len(sorted_lines)} lines, {self.num_qubits} qubits")
971
+ self.io.writeln("Press ENTER to advance, Q to quit\n")
972
+
973
+ while ctx.ip < len(sorted_lines):
974
+ ctx.iteration_count += 1
975
+ if ctx.iteration_count > ctx.max_iterations:
976
+ raise RuntimeError(f"LOOP LIMIT ({ctx.max_iterations}) — possible infinite loop")
977
+ line_num = sorted_lines[ctx.ip]
978
+ stmt = self.program[line_num]
979
+ parsed = self._get_parsed(line_num)
980
+
981
+ # Display current line
982
+ self.io.writeln(f">> {line_num:5d} {stmt}")
983
+
984
+ # Execute it
985
+ result = self._exec_line(stmt, parsed=parsed, ctx=ctx)
986
+
987
+ # Show state
988
+ try:
989
+ qc_tmp = qc.copy()
990
+ qc_tmp.save_statevector()
991
+ sv_b = AerSimulator(method='statevector')
992
+ sv_r = sv_b.run(transpile(qc_tmp, sv_b)).result()
993
+ sv = np.array(sv_r.get_statevector())
994
+ self._print_sv_compact(sv)
995
+ except Exception:
996
+ self.io.writeln(" (state unavailable)")
997
+
998
+ # Wait for input
999
+ try:
1000
+ user = self.io.read_line(" [ENTER/Q] ").strip().upper()
1001
+ if user == 'Q':
1002
+ self.io.writeln("STOPPED")
1003
+ return
1004
+ except (KeyboardInterrupt, EOFError):
1005
+ self.io.writeln("\nSTOPPED")
1006
+ return
1007
+
1008
+ if isinstance(result, int):
1009
+ ctx.ip = result
1010
+ else:
1011
+ ctx.ip += 1
1012
+
1013
+ self.io.writeln("DONE")
1014
+
1015
+ # run_immediate provided by ExecutorMixin.
1016
+
1017
+ def _validate_program(self, sorted_lines: list[int]) -> None:
1018
+ """Pre-execution validation. Catches structural errors before running."""
1019
+ from qbasic_core.statements import (
1020
+ GotoStmt, GosubStmt, ForStmt, NextStmt, WhileStmt, WendStmt,
1021
+ DoStmt, LoopStmt, SubStmt, EndSubStmt, FunctionStmt, EndFunctionStmt,
1022
+ IfThenStmt,
1023
+ )
1024
+ line_set = set(sorted_lines)
1025
+ for_depth = 0
1026
+ while_depth = 0
1027
+ do_depth = 0
1028
+ for_var_stack: list[str] = []
1029
+ for ln in sorted_lines:
1030
+ parsed = self._get_parsed(ln)
1031
+ if isinstance(parsed, GotoStmt) and parsed.target not in line_set:
1032
+ raise RuntimeError(f"LINE {ln}: GOTO {parsed.target} — target line not found")
1033
+ if isinstance(parsed, GosubStmt) and parsed.target not in line_set:
1034
+ raise RuntimeError(f"LINE {ln}: GOSUB {parsed.target} — target line not found")
1035
+ # Validate GOTO/GOSUB targets embedded in IF THEN/ELSE clauses
1036
+ if isinstance(parsed, IfThenStmt):
1037
+ for clause in (parsed.then_clause, parsed.else_clause):
1038
+ if clause:
1039
+ for m in re.finditer(r'\b(?:GOTO|GOSUB)\s+(\d+)', clause, re.IGNORECASE):
1040
+ target = int(m.group(1))
1041
+ if target not in line_set:
1042
+ raise RuntimeError(
1043
+ f"LINE {ln}: {m.group(0)} (in IF THEN) — target line not found")
1044
+ if isinstance(parsed, ForStmt):
1045
+ for_depth += 1
1046
+ for_var_stack.append(parsed.var.upper())
1047
+ elif isinstance(parsed, NextStmt):
1048
+ for_depth -= 1
1049
+ # Check FOR/NEXT variable name matching
1050
+ if parsed.var and for_var_stack:
1051
+ expected = for_var_stack[-1]
1052
+ if parsed.var.upper() != expected:
1053
+ raise RuntimeError(
1054
+ f"LINE {ln}: NEXT {parsed.var} does not match FOR {expected}")
1055
+ if for_var_stack:
1056
+ for_var_stack.pop()
1057
+ elif isinstance(parsed, WhileStmt):
1058
+ while_depth += 1
1059
+ elif isinstance(parsed, WendStmt):
1060
+ while_depth -= 1
1061
+ elif isinstance(parsed, DoStmt):
1062
+ do_depth += 1
1063
+ elif isinstance(parsed, LoopStmt):
1064
+ do_depth -= 1
1065
+ if for_depth > 0:
1066
+ raise RuntimeError(f"Unmatched FOR: {for_depth} FOR without NEXT")
1067
+ if while_depth > 0:
1068
+ raise RuntimeError(f"Unmatched WHILE: {while_depth} WHILE without WEND")
1069
+ if do_depth > 0:
1070
+ raise RuntimeError(f"Unmatched DO: {do_depth} DO without LOOP")
1071
+
1072
+ # ── Circuit Building (provided by ExecutorMixin) ────────────────
1073
+
1074
+ # Mid-circuit measurement returns this value in Qiskit circuit-build mode.
1075
+ # Qiskit defers measurement to simulation time, so the variable cannot hold
1076
+ # the actual outcome during circuit construction. For classical feedforward,
1077
+ # use LOCC mode with SEND instead.
1078
+ MEAS_CIRCUIT_MODE_VALUE = 0
1079
+
1080
+ def _try_exec_meas(self, stmt: str, qc, run_vars: dict, *, backend=None) -> bool:
1081
+ """Handle MEAS qubit -> var (mid-circuit measurement)."""
1082
+ m = RE_MEAS.match(stmt)
1083
+ if not m:
1084
+ return False
1085
+ qubit = int(self._eval_with_vars(m.group(1), run_vars))
1086
+ var = m.group(2)
1087
+ if 0 <= qubit < self.num_qubits:
1088
+ if not self.locc_mode:
1089
+ raise QBasicBuildError(
1090
+ "MEAS requires LOCC mode for classical feedforward. "
1091
+ "Use LOCC SEND instead, or use MEASURE for end-of-circuit measurement.")
1092
+ b = backend or qc
1093
+ if hasattr(b, 'add_classical_register'):
1094
+ cr = b.add_classical_register(f'meas_{var}')
1095
+ b.measure(qubit, cr[0])
1096
+ else:
1097
+ from qiskit.circuit import ClassicalRegister
1098
+ cr = ClassicalRegister(1, f'meas_{var}')
1099
+ qc.add_register(cr)
1100
+ qc.measure(qubit, cr[0])
1101
+ run_vars[var] = self.MEAS_CIRCUIT_MODE_VALUE
1102
+ self.variables[var] = self.MEAS_CIRCUIT_MODE_VALUE
1103
+ return True
1104
+
1105
+ def _try_exec_reset(self, stmt: str, qc, run_vars: dict, *, backend=None) -> bool:
1106
+ """Handle RESET qubit."""
1107
+ m = RE_RESET.match(stmt)
1108
+ if not m:
1109
+ return False
1110
+ qubit = int(self._eval_with_vars(m.group(1), run_vars))
1111
+ if 0 <= qubit < self.num_qubits:
1112
+ b = backend or qc
1113
+ b.reset(qubit)
1114
+ return True
1115
+
1116
+ def _try_exec_unitary(self, stmt: str) -> bool:
1117
+ """Handle UNITARY name = [[...]] gate definition."""
1118
+ m = RE_UNITARY.match(stmt)
1119
+ if not m:
1120
+ return False
1121
+ name = m.group(1).upper()
1122
+ try:
1123
+ matrix = np.array(self._parse_matrix(m.group(2)), dtype=complex)
1124
+ n_qubits = int(np.log2(matrix.shape[0]))
1125
+ if matrix.shape != (2**n_qubits, 2**n_qubits):
1126
+ raise ValueError("Matrix must be 2^n x 2^n")
1127
+ product = matrix @ matrix.conj().T
1128
+ if not np.allclose(product, np.eye(matrix.shape[0]), atol=1e-6):
1129
+ raise ValueError("Matrix is not unitary (U @ U† ≠ I, atol=1e-6). Use expressions like 1/sqrt(2) instead of truncated decimals.")
1130
+ self._custom_gates[name] = matrix
1131
+ self.io.writeln(f"UNITARY {name}: {n_qubits}-qubit gate defined")
1132
+ except Exception as e:
1133
+ self.io.writeln(f"?UNITARY ERROR: {e}")
1134
+ return True
1135
+
1136
+ def _try_exec_dim(self, stmt: str) -> bool:
1137
+ """Handle DIM name(size) or DIM name(d1, d2, ...) array declaration."""
1138
+ m = RE_DIM_MULTI.match(stmt)
1139
+ if not m:
1140
+ m = RE_DIM.match(stmt)
1141
+ if not m:
1142
+ return False
1143
+ name = m.group(1)
1144
+ dims = [int(d.strip()) for d in m.group(2).split(',')]
1145
+ total = 1
1146
+ for d in dims:
1147
+ total *= d
1148
+ self.arrays[name] = [0.0] * total
1149
+ if len(dims) > 1:
1150
+ self._array_dims[name] = dims
1151
+ return True
1152
+
1153
+ def _try_exec_redim(self, stmt: str) -> bool:
1154
+ """Handle REDIM name(size) — resize an existing array."""
1155
+ m = RE_REDIM.match(stmt)
1156
+ if not m:
1157
+ return False
1158
+ name = m.group(1)
1159
+ new_size = int(m.group(2))
1160
+ old = self.arrays.get(name, [])
1161
+ if isinstance(old, list):
1162
+ if new_size > len(old):
1163
+ self.arrays[name] = old + [0.0] * (new_size - len(old))
1164
+ else:
1165
+ self.arrays[name] = old[:new_size]
1166
+ else:
1167
+ self.arrays[name] = [0.0] * new_size
1168
+ return True
1169
+
1170
+ def _try_exec_erase(self, stmt: str) -> bool:
1171
+ """Handle ERASE name — delete a specific array."""
1172
+ m = RE_ERASE.match(stmt)
1173
+ if not m:
1174
+ return False
1175
+ name = m.group(1)
1176
+ if name in self.arrays:
1177
+ del self.arrays[name]
1178
+ return True
1179
+
1180
+ def _try_exec_get(self, stmt: str, run_vars: dict) -> bool:
1181
+ """Handle GET var — single keypress input without enter.
1182
+
1183
+ Falls back to reading one character from self.io.read_line
1184
+ when not running in a terminal (batch mode, tests, etc.).
1185
+ """
1186
+ m = RE_GET.match(stmt)
1187
+ if not m:
1188
+ return False
1189
+ var = m.group(1)
1190
+ ch = ''
1191
+ try:
1192
+ import sys as _sys
1193
+ if _sys.stdin.isatty():
1194
+ if _sys.platform == 'win32':
1195
+ import msvcrt
1196
+ ch = msvcrt.getwch()
1197
+ else:
1198
+ import tty, termios
1199
+ fd = _sys.stdin.fileno()
1200
+ old_settings = termios.tcgetattr(fd)
1201
+ try:
1202
+ tty.setraw(fd)
1203
+ ch = _sys.stdin.read(1)
1204
+ finally:
1205
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1206
+ else:
1207
+ line = self.io.read_line('')
1208
+ ch = line[0] if line else ''
1209
+ except (EOFError, KeyboardInterrupt, OSError):
1210
+ ch = ''
1211
+ if var.endswith('$'):
1212
+ run_vars[var] = ch
1213
+ self.variables[var] = ch
1214
+ else:
1215
+ run_vars[var] = float(ord(ch)) if ch else 0.0
1216
+ self.variables[var] = run_vars[var]
1217
+ return True
1218
+
1219
+ def _try_exec_input(self, stmt: str, run_vars: dict) -> bool:
1220
+ """Handle INPUT "prompt", var user input with retry on bad input."""
1221
+ m = RE_INPUT.match(stmt)
1222
+ if not m:
1223
+ return False
1224
+ prompt = m.group(1) or m.group(2)
1225
+ var = m.group(2)
1226
+ for _attempt in range(3):
1227
+ try:
1228
+ val = self.io.read_line(f"{prompt}? ")
1229
+ if var.endswith('$'):
1230
+ run_vars[var] = val
1231
+ self.variables[var] = val
1232
+ else:
1233
+ run_vars[var] = float(val) if '.' in val else int(val)
1234
+ self.variables[var] = run_vars[var]
1235
+ return True
1236
+ except (EOFError, KeyboardInterrupt):
1237
+ run_vars[var] = 0
1238
+ self.variables[var] = 0
1239
+ return True
1240
+ except ValueError:
1241
+ self.io.writeln("?REDO FROM START")
1242
+ run_vars[var] = 0
1243
+ self.variables[var] = 0
1244
+ return True
1245
+
1246
+ def _try_exec_measure_basis(self, stmt: str, qc, run_vars: dict,
1247
+ *, backend=None) -> bool:
1248
+ """Handle MEASURE_X/Y/Z qubit — measurement in a non-computational basis."""
1249
+ m = RE_MEASURE_BASIS.match(stmt)
1250
+ if not m:
1251
+ return False
1252
+ basis = m.group(1).upper()
1253
+ qubit = int(self._eval_with_vars(m.group(2), run_vars))
1254
+ if qubit < 0 or qubit >= self.num_qubits:
1255
+ raise ValueError(f"QUBIT {qubit} OUT OF RANGE (0-{self.num_qubits-1})")
1256
+ b = backend or qc
1257
+ if basis == 'X':
1258
+ b.apply_gate('H', (), [qubit]) if hasattr(b, 'apply_gate') else qc.h(qubit)
1259
+ elif basis == 'Y':
1260
+ if hasattr(b, 'apply_gate'):
1261
+ b.apply_gate('SDG', (), [qubit])
1262
+ b.apply_gate('H', (), [qubit])
1263
+ else:
1264
+ qc.sdg(qubit)
1265
+ qc.h(qubit)
1266
+ var = f"m{basis.lower()}_{qubit}"
1267
+ if hasattr(b, 'add_classical_register'):
1268
+ cr = b.add_classical_register(f'meas_{var}')
1269
+ b.measure(qubit, cr[0])
1270
+ else:
1271
+ from qiskit.circuit import ClassicalRegister
1272
+ cr = ClassicalRegister(1, f'meas_{var}')
1273
+ qc.add_register(cr)
1274
+ qc.measure(qubit, cr[0])
1275
+ run_vars[var] = 0
1276
+ self.variables[var] = 0
1277
+ return True
1278
+
1279
+ # _parse_syndrome provided by ExecutorMixin.
1280
+
1281
+ _PAULI_TO_CONTROLLED = {'X': 'CX', 'Y': 'CY', 'Z': 'CZ'}
1282
+
1283
+ def _try_exec_syndrome(self, stmt: str, qc, run_vars: dict,
1284
+ *, backend=None) -> bool:
1285
+ """Handle SYNDROME — non-destructive stabilizer measurement via ancilla."""
1286
+ parsed = self._parse_syndrome(stmt, run_vars)
1287
+ if parsed is None:
1288
+ return False
1289
+ pauli_str, qubits, var = parsed
1290
+ anc = self.num_qubits - 1
1291
+ if anc in qubits:
1292
+ raise ValueError(
1293
+ f"Qubit {anc} needed as ancilla but appears in stabilizer. "
1294
+ f"Increase QUBITS by 1.")
1295
+ b = backend or qc
1296
+ if hasattr(b, 'apply_gate'):
1297
+ b.apply_gate('H', (), [anc])
1298
+ for p, q in zip(pauli_str, qubits):
1299
+ if p != 'I':
1300
+ b.apply_gate(self._PAULI_TO_CONTROLLED[p], (), [anc, q])
1301
+ b.apply_gate('H', (), [anc])
1302
+ cr = b.add_classical_register(f'synd_{var}')
1303
+ b.measure(anc, cr[0])
1304
+ b.reset(anc)
1305
+ else:
1306
+ qc.h(anc)
1307
+ for p, q in zip(pauli_str, qubits):
1308
+ if p != 'I':
1309
+ gate_method = getattr(qc, self._PAULI_TO_CONTROLLED[p].lower())
1310
+ gate_method(anc, q)
1311
+ qc.h(anc)
1312
+ from qiskit.circuit import ClassicalRegister
1313
+ cr = ClassicalRegister(1, f'synd_{var}')
1314
+ qc.add_register(cr)
1315
+ qc.measure(anc, cr[0])
1316
+ qc.reset(anc)
1317
+ run_vars[var] = 0
1318
+ self.variables[var] = 0
1319
+ return True
1320
+
1321
+ # _exec_line, _RESERVED_KEYWORDS, _RESERVED_NAMES provided by ExecutorMixin.
1322
+
1323
+ def _try_stmt_handlers(self, stmt: str, qc, run_vars: dict, *, backend=None) -> bool:
1324
+ """Try statement-type handlers in order. Returns True if handled."""
1325
+ return (
1326
+ self._try_exec_meas(stmt, qc, run_vars, backend=backend)
1327
+ or self._try_exec_reset(stmt, qc, run_vars, backend=backend)
1328
+ or self._try_exec_measure_basis(stmt, qc, run_vars, backend=backend)
1329
+ or self._try_exec_syndrome(stmt, qc, run_vars, backend=backend)
1330
+ or self._try_exec_unitary(stmt)
1331
+ or self._try_exec_dim(stmt)
1332
+ or self._try_exec_redim(stmt)
1333
+ or self._try_exec_erase(stmt)
1334
+ or self._try_exec_get(stmt, run_vars)
1335
+ or self._try_exec_input(stmt, run_vars)
1336
+ or self._try_exec_poke(stmt, run_vars)
1337
+ or self._try_exec_sys(stmt)
1338
+ or self._exec_print_file(stmt, run_vars)
1339
+ or self._exec_input_file(stmt, run_vars)
1340
+ or self._exec_lprint(stmt, run_vars)
1341
+ or self._try_exec_line_input(stmt, run_vars)
1342
+ or self._try_exec_let_str(stmt, run_vars)
1343
+ or self._try_exec_print_using(stmt, run_vars)
1344
+ or self._try_exec_open_close(stmt)
1345
+ or self._try_exec_save_expect(stmt, qc, run_vars)
1346
+ or self._try_exec_save_probs(stmt, qc, run_vars)
1347
+ or self._try_exec_save_amps(stmt, qc, run_vars)
1348
+ or self._try_exec_set_state(stmt, qc)
1349
+ )
1350
+
1351
+ def _try_exec_poke(self, stmt: str, run_vars: dict) -> bool:
1352
+ m = RE_POKE.match(stmt)
1353
+ if not m:
1354
+ return False
1355
+ addr = self._eval_with_vars(m.group(1), run_vars)
1356
+ val = self._eval_with_vars(m.group(2), run_vars)
1357
+ self._poke(addr, val)
1358
+ return True
1359
+
1360
+ def _try_exec_sys(self, stmt: str) -> bool:
1361
+ m = re.match(r'SYS\s+(.+)', stmt, re.IGNORECASE)
1362
+ if not m:
1363
+ return False
1364
+ self.cmd_sys(m.group(1))
1365
+ return True
1366
+
1367
+ def _try_exec_line_input(self, stmt: str, run_vars: dict) -> bool:
1368
+ m = RE_LINE_INPUT.match(stmt)
1369
+ if not m:
1370
+ return False
1371
+ prompt = m.group(1) or m.group(2)
1372
+ var = m.group(2)
1373
+ try:
1374
+ val = self.io.read_line(f"{prompt}? ")
1375
+ run_vars[var] = val
1376
+ self.variables[var] = val
1377
+ except (EOFError, KeyboardInterrupt):
1378
+ run_vars[var] = ''
1379
+ self.variables[var] = ''
1380
+ return True
1381
+
1382
+ def _try_exec_let_str(self, stmt: str, run_vars: dict) -> bool:
1383
+ m = RE_LET_STR.match(stmt)
1384
+ if not m:
1385
+ return False
1386
+ name = m.group(1)
1387
+ val = self._eval_string_expr(m.group(2))
1388
+ run_vars[name] = val
1389
+ self.variables[name] = val
1390
+ return True
1391
+
1392
+ def _try_exec_print_using(self, stmt: str, run_vars: dict) -> bool:
1393
+ m = RE_PRINT_USING.match(stmt)
1394
+ if not m:
1395
+ return False
1396
+ fmt = m.group(1)
1397
+ vals = [self._eval_with_vars(v.strip(), run_vars)
1398
+ for v in m.group(2).split(',') if v.strip()]
1399
+ result = fmt
1400
+ for val in vals:
1401
+ # Find the next format field (run of # and . characters)
1402
+ idx = result.find('#')
1403
+ if idx < 0:
1404
+ break
1405
+ end = idx
1406
+ while end < len(result) and result[end] in '#.':
1407
+ end += 1
1408
+ field = result[idx:end]
1409
+ field_width = len(field)
1410
+ dot_pos = field.find('.')
1411
+ if dot_pos >= 0:
1412
+ decimals = len(field) - dot_pos - 1
1413
+ formatted = f"{val:{field_width}.{decimals}f}"
1414
+ else:
1415
+ formatted = f"{val:{field_width}.0f}"
1416
+ result = result[:idx] + formatted.rjust(field_width) + result[end:]
1417
+ self.io.writeln(result)
1418
+ return True
1419
+
1420
+ def _try_exec_open_close(self, stmt: str) -> bool:
1421
+ m = RE_OPEN.match(stmt)
1422
+ if m:
1423
+ rest = stmt[4:].strip()
1424
+ self.cmd_open(rest)
1425
+ return True
1426
+ m = RE_CLOSE.match(stmt)
1427
+ if m:
1428
+ self.cmd_close(m.group(1))
1429
+ return True
1430
+ return False
1431
+
1432
+ def _try_exec_save_expect(self, stmt: str, qc, run_vars: dict) -> bool:
1433
+ """SAVE_EXPECT <pauli> <qubits> -> <var> — inline expectation value."""
1434
+ from qbasic_core.engine import RE_SAVE_EXPECT
1435
+ m = RE_SAVE_EXPECT.match(stmt)
1436
+ if not m:
1437
+ return False
1438
+ pauli_str = m.group(1).upper()
1439
+ qubits = [int(q.strip()) for q in m.group(2).split(',') if q.strip()]
1440
+ var = m.group(3)
1441
+ try:
1442
+ from qiskit.quantum_info import SparsePauliOp
1443
+ full_pauli = ['I'] * self.num_qubits
1444
+ for i, p in enumerate(pauli_str):
1445
+ if i < len(qubits):
1446
+ full_pauli[self.num_qubits - 1 - qubits[i]] = p
1447
+ op = SparsePauliOp(''.join(full_pauli))
1448
+ qc.save_expectation_value(op, list(range(self.num_qubits)), label=f'exp_{var}')
1449
+ run_vars[var] = 0 # actual value available after simulation
1450
+ self.variables[var] = 0
1451
+ except Exception as e:
1452
+ self.io.writeln(f"?SAVE_EXPECT ERROR: {e}")
1453
+ return True
1454
+
1455
+ def _try_exec_save_probs(self, stmt: str, qc, run_vars: dict) -> bool:
1456
+ """SAVE_PROBS <qubits> -> <var> — inline probability snapshot."""
1457
+ from qbasic_core.engine import RE_SAVE_PROBS
1458
+ m = RE_SAVE_PROBS.match(stmt)
1459
+ if not m:
1460
+ return False
1461
+ qubits = [int(q.strip()) for q in m.group(1).split(',') if q.strip()]
1462
+ var = m.group(2)
1463
+ try:
1464
+ qc.save_probabilities(qubits, label=f'prob_{var}')
1465
+ run_vars[var] = 0
1466
+ self.variables[var] = 0
1467
+ except Exception as e:
1468
+ self.io.writeln(f"?SAVE_PROBS ERROR: {e}")
1469
+ return True
1470
+
1471
+ def _try_exec_save_amps(self, stmt: str, qc, run_vars: dict) -> bool:
1472
+ """SAVE_AMPS <indices> -> <var> — save specific amplitudes by index."""
1473
+ from qbasic_core.engine import RE_SAVE_AMPS
1474
+ m = RE_SAVE_AMPS.match(stmt)
1475
+ if not m:
1476
+ return False
1477
+ indices = [int(q.strip()) for q in m.group(1).split(',') if q.strip()]
1478
+ var = m.group(2)
1479
+ try:
1480
+ qc.save_amplitudes(indices, label=f'amp_{var}')
1481
+ run_vars[var] = 0
1482
+ self.variables[var] = 0
1483
+ except Exception as e:
1484
+ self.io.writeln(f"?SAVE_AMPS ERROR: {e}")
1485
+ return True
1486
+
1487
+ def _try_exec_set_state(self, stmt: str, qc) -> bool:
1488
+ """SET_STATE <statevector> — inject custom statevector mid-circuit.
1489
+
1490
+ Accepts:
1491
+ SET_STATE [0.707, 0, 0, 0.707] — explicit amplitudes
1492
+ SET_STATE |+> — named state
1493
+ SET_STATE |BELL> — named entangled state
1494
+ SET_STATE |GHZ> — GHZ state
1495
+ """
1496
+ from qbasic_core.engine import RE_SET_STATE
1497
+ m = RE_SET_STATE.match(stmt)
1498
+ if not m:
1499
+ return False
1500
+ try:
1501
+ sv_expr = m.group(1).strip()
1502
+ dim = 2 ** self.num_qubits
1503
+ # Try named states first
1504
+ if sv_expr.upper() in ('|0>', '|1>', '|+>', '|->', '|BELL>', '|GHZ>',
1505
+ '|GHZ3>', '|GHZ4>', '|W>', '|W3>'):
1506
+ sv_flat = _resolve_named_state(sv_expr.upper(), self.num_qubits)
1507
+ else:
1508
+ sv_list = self._parse_matrix(sv_expr)
1509
+ sv_flat = np.array(sv_list, dtype=complex).ravel()
1510
+ if len(sv_flat) != dim:
1511
+ raise ValueError(f"State vector length {len(sv_flat)} != 2^{self.num_qubits} = {dim}")
1512
+ norm = float(np.sum(np.abs(sv_flat) ** 2))
1513
+ if abs(norm - 1.0) > 1e-6:
1514
+ sv_flat = sv_flat / np.sqrt(norm)
1515
+ self.io.writeln(f" (normalized: ||sv||={norm:.6f} -> 1.0)")
1516
+ from qiskit.quantum_info import Statevector
1517
+ sv_obj = Statevector(sv_flat)
1518
+ from qiskit_aer.library import SetStatevector
1519
+ qc.append(SetStatevector(sv_obj), list(range(self.num_qubits)))
1520
+ except Exception as e:
1521
+ self.io.writeln(f"?SET_STATE ERROR: {e}")
1522
+ return True
1523
+
1524
+ # _split_colon_stmts, _substitute_vars, _expand_statement,
1525
+ # _offset_qubits, _apply_gate_str, _tokenize_gate, _resolve_qubit,
1526
+ # _GATE_DISPATCH, _apply_gate provided by ExecutorMixin.
1527
+
1528
+ # ── Display ───────────────────────────────────────────────────────
1529
+
1530
+ def _try_quantum_print(self, text: str, run_vars: dict) -> str | None:
1531
+ """Handle quantum PRINT expressions. Returns formatted string or None."""
1532
+ upper = text.strip().upper()
1533
+ # PRINT @REG — Dirac notation for LOCC register
1534
+ if self.locc_mode and self.locc and upper.startswith('@'):
1535
+ reg = upper[1:].strip()
1536
+ if reg in self.locc.names:
1537
+ sv = self.locc.get_sv(reg)
1538
+ n = self.locc.get_n(reg)
1539
+ return self._format_dirac(sv, n)
1540
+ # PRINT QUBIT(n) — single-qubit Bloch info
1541
+ m = re.match(r'QUBIT\s*\((\d+)\)', text, re.IGNORECASE)
1542
+ if m and self.last_sv is not None:
1543
+ q = int(m.group(1))
1544
+ x, y, z = self._bloch_vector(self.last_sv, q)
1545
+ p1 = self._peek(0x0100 + q * 8)
1546
+ return f"q{q}: P(1)={p1:.4f} Bloch=({x:.3f},{y:.3f},{z:.3f})"
1547
+ # PRINT ENTANGLEMENT(a,b) — entropy between qubits
1548
+ m = re.match(r'ENTANGLEMENT\s*\((\d+)\s*,\s*(\d+)\)', text, re.IGNORECASE)
1549
+ if m and self.last_sv is not None:
1550
+ qa, qb = int(m.group(1)), int(m.group(2))
1551
+ try:
1552
+ from qiskit.quantum_info import Statevector, entropy, partial_trace
1553
+ sv_obj = Statevector(np.ascontiguousarray(self.last_sv).ravel())
1554
+ keep = [qa]
1555
+ trace_out = [q for q in range(self.num_qubits) if q not in keep]
1556
+ rho = partial_trace(sv_obj, trace_out)
1557
+ ent = entropy(rho, base=2)
1558
+ return f"S({qa},{qb}) = {ent:.6f} bits"
1559
+ except Exception:
1560
+ return f"S({qa},{qb}) = ?"
1561
+ # PRINT STATE — full statevector in Dirac notation
1562
+ if upper == 'STATE' and self.last_sv is not None:
1563
+ return self._format_dirac(self.last_sv, self.num_qubits)
1564
+ return None
1565
+
1566
+ def _format_dirac(self, sv, n_qubits: int) -> str:
1567
+ """Format a statevector in Dirac notation."""
1568
+ from qbasic_core.engine import AMPLITUDE_THRESHOLD
1569
+ sv_flat = np.ascontiguousarray(sv).ravel()
1570
+ parts = []
1571
+ for i, amp in enumerate(sv_flat):
1572
+ if abs(amp) > AMPLITUDE_THRESHOLD:
1573
+ state = format(i, f'0{n_qubits}b')
1574
+ if abs(amp.imag) < AMPLITUDE_THRESHOLD:
1575
+ if abs(amp.real - 1.0) < 1e-6:
1576
+ parts.append(f"|{state}\u27E9")
1577
+ elif abs(amp.real + 1.0) < 1e-6:
1578
+ parts.append(f"-|{state}\u27E9")
1579
+ else:
1580
+ parts.append(f"{amp.real:+.4f}|{state}\u27E9")
1581
+ else:
1582
+ parts.append(f"({amp.real:.3f}{amp.imag:+.3f}j)|{state}\u27E9")
1583
+ if len(parts) >= 16:
1584
+ parts.append("...")
1585
+ break
1586
+ return " ".join(parts) if parts else "|0\u27E9"
1587
+
1588
+ # cmd_state, cmd_hist, cmd_probs, cmd_bloch, cmd_circuit provided by StateDisplayMixin.
1589
+
1590
+ # cmd_noise provided by NoiseMixin.
1591
+
1592
+ # cmd_expect, cmd_entropy, cmd_csv, cmd_density provided by AnalysisMixin / FileIOMixin.
1593
+
1594
+ def _is_clifford(self, qc: 'QuantumCircuit') -> bool:
1595
+ """Check if circuit contains only Clifford gates.
1596
+
1597
+ Custom/unitary gates are conservatively treated as non-Clifford.
1598
+ Verifying that an arbitrary unitary normalizes the Pauli group is
1599
+ expensive, and the consequence of a false positive (stabilizer sim
1600
+ producing wrong results) is worse than a false negative (falling
1601
+ back to statevector sim, which always works).
1602
+ """
1603
+ clifford_gates = {'h', 'x', 'y', 'z', 's', 'sdg', 'cx', 'cz', 'cy',
1604
+ 'swap', 'id', 'sx', 'barrier', 'measure', 'reset'}
1605
+ for inst in qc.data:
1606
+ name = inst.operation.name.lower()
1607
+ if name == 'unitary':
1608
+ return False # custom gates: conservatively non-Clifford
1609
+ if name not in clifford_gates:
1610
+ return False
1611
+ return True
1612
+
1613
+ # cmd_export provided by FileIOMixin.
1614
+
1615
+ def cmd_decompose(self) -> None:
1616
+ """Show gate count breakdown of last circuit."""
1617
+ if self.last_circuit is None:
1618
+ self.io.writeln("?NO CIRCUIT — RUN first")
1619
+ return
1620
+ ops = {}
1621
+ for inst in self.last_circuit.data:
1622
+ name = inst.operation.name
1623
+ ops[name] = ops.get(name, 0) + 1
1624
+ self.io.writeln(f"\n Circuit: {self.last_circuit.num_qubits} qubits, "
1625
+ f"depth {self.last_circuit.depth()}, {self.last_circuit.size()} gates")
1626
+ for name, count in sorted(ops.items(), key=lambda x: -x[1]):
1627
+ bar = '█' * min(count, 40)
1628
+ self.io.writeln(f" {name:>10} {count:>4} {bar}")
1629
+ self.io.writeln('')
1630
+
1631
+ # cmd_include, cmd_sweep provided by FileIOMixin / SweepMixin.
1632
+
1633
+ # LOCC commands (cmd_locc, cmd_send, cmd_share) provided by LOCCMixin.
1634
+
1635
+ # cmd_bench, cmd_ram, cmd_dir provided by AnalysisMixin / FileIOMixin.
1636
+
1637
+ # cmd_clear provided by ProgramMgmtMixin.
1638
+
1639
+ def cmd_undo(self) -> None:
1640
+ if not self._undo_stack:
1641
+ self.io.writeln("NOTHING TO UNDO")
1642
+ return
1643
+ self.program = self._undo_stack.pop()
1644
+ self._parsed = {num: parse_stmt(s) for num, s in self.program.items()}
1645
+ self.io.writeln(f"UNDO ({len(self.program)} lines)")
1646
+
1647
+ # LOCC execution (_locc_run, _locc_exec_line, etc.) provided by LOCCMixin.
1648
+
1649
+ # Control flow helpers (_cf_*, _exec_control_flow, _find_matching_wend)
1650
+ # provided by ControlFlowMixin.
1651
+
1652
+ # ── Help ──────────────────────────────────────────────────────────
1653
+
1654
+ def cmd_help(self) -> None:
1655
+ self.io.writeln(HELP_TEXT)
1656
+ # Auto-generated command reference from registry
1657
+ all_cmds = sorted(set(self._CMD_NO_ARG.keys()) | set(self._CMD_WITH_ARG.keys()))
1658
+ all_gates = sorted(g for g in GATE_TABLE if g not in GATE_ALIASES)
1659
+ self.io.writeln(f" ALL COMMANDS: {', '.join(all_cmds)}")
1660
+ self.io.writeln(f" ALL GATES: {', '.join(all_gates)}")
1661
+
1662
+ # ── Banner ────────────────────────────────────────────────────────
1663
+
1664
+ def print_banner(self) -> None:
1665
+ import platform
1666
+ try:
1667
+ import qiskit
1668
+ qver = qiskit.__version__
1669
+ except Exception:
1670
+ qver = '?'
1671
+ ram = _get_ram_gb()
1672
+ ram_str = f"{ram[0]:.0f} GB RAM" if ram else "RAM unknown"
1673
+ max_q = 32
1674
+ if ram:
1675
+ from qbasic_core.engine import _estimate_gb
1676
+ for n in range(32, 0, -1):
1677
+ if _estimate_gb(n) < ram[1]:
1678
+ max_q = n
1679
+ break
1680
+ if hasattr(QBasicTerminal, '_gpu_cache'):
1681
+ gpu_str = QBasicTerminal._gpu_cache
1682
+ else:
1683
+ try:
1684
+ gpu_str = ""
1685
+ import subprocess
1686
+ r = subprocess.run(['nvidia-smi', '--query-gpu=name', '--format=csv,noheader'],
1687
+ capture_output=True, text=True, timeout=2)
1688
+ if r.returncode == 0 and r.stdout.strip():
1689
+ gpu_str = f" | GPU: {r.stdout.strip().split(chr(10))[0]}"
1690
+ QBasicTerminal._gpu_cache = gpu_str
1691
+ except Exception:
1692
+ gpu_str = ""
1693
+ QBasicTerminal._gpu_cache = gpu_str
1694
+ info_line = f"Python {platform.python_version()} | Qiskit {qver} | {ram_str}{gpu_str}"
1695
+ config_line = f"{self.num_qubits} qubits | {self.shots} shots | max ~{max_q} qubits"
1696
+ self.io.writeln(BANNER_ART.format(info_line=info_line, config_line=config_line))
1697
+