qubasic 0.6.1__tar.gz → 0.6.3__tar.gz

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 (61) hide show
  1. {qubasic-0.6.1 → qubasic-0.6.3}/CHANGELOG.md +13 -0
  2. {qubasic-0.6.1/qubasic.egg-info → qubasic-0.6.3}/PKG-INFO +1 -1
  3. {qubasic-0.6.1 → qubasic-0.6.3}/pyproject.toml +1 -1
  4. {qubasic-0.6.1 → qubasic-0.6.3/qubasic.egg-info}/PKG-INFO +1 -1
  5. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/__init__.py +1 -1
  6. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/control_flow.py +77 -37
  7. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/engine_state.py +14 -0
  8. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/executor.py +47 -19
  9. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/memory.py +8 -2
  10. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/parser.py +8 -0
  11. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/terminal.py +15 -6
  12. {qubasic-0.6.1 → qubasic-0.6.3}/tests/test_features.py +36 -0
  13. {qubasic-0.6.1 → qubasic-0.6.3}/tests/test_qubasic.py +86 -0
  14. {qubasic-0.6.1 → qubasic-0.6.3}/LICENSE +0 -0
  15. {qubasic-0.6.1 → qubasic-0.6.3}/MANIFEST.in +0 -0
  16. {qubasic-0.6.1 → qubasic-0.6.3}/README.md +0 -0
  17. {qubasic-0.6.1 → qubasic-0.6.3}/examples/bell.qb +0 -0
  18. {qubasic-0.6.1 → qubasic-0.6.3}/examples/grover3.qb +0 -0
  19. {qubasic-0.6.1 → qubasic-0.6.3}/examples/locc_teleport.qb +0 -0
  20. {qubasic-0.6.1 → qubasic-0.6.3}/examples/sweep_rx.qb +0 -0
  21. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic.egg-info/SOURCES.txt +0 -0
  22. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic.egg-info/dependency_links.txt +0 -0
  23. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic.egg-info/entry_points.txt +0 -0
  24. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic.egg-info/requires.txt +0 -0
  25. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic.egg-info/top_level.txt +0 -0
  26. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/__main__.py +0 -0
  27. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/analysis.py +0 -0
  28. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/backend.py +0 -0
  29. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/classic.py +0 -0
  30. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/cli.py +0 -0
  31. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/debug.py +0 -0
  32. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/demos.py +0 -0
  33. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/display.py +0 -0
  34. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/engine.py +0 -0
  35. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/errors.py +0 -0
  36. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/exec_context.py +0 -0
  37. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/expression.py +0 -0
  38. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/file_io.py +0 -0
  39. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/gates.py +0 -0
  40. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/help_text.py +0 -0
  41. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/io_protocol.py +0 -0
  42. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/locc.py +0 -0
  43. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/locc_commands.py +0 -0
  44. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/locc_display.py +0 -0
  45. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/locc_engine.py +0 -0
  46. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/locc_execution.py +0 -0
  47. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/mock_backend.py +0 -0
  48. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/noise_mixin.py +0 -0
  49. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/patterns.py +0 -0
  50. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/profiler.py +0 -0
  51. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/program_mgmt.py +0 -0
  52. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/protocol.py +0 -0
  53. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/qol.py +0 -0
  54. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/scope.py +0 -0
  55. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/screen.py +0 -0
  56. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/state_display.py +0 -0
  57. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/statements.py +0 -0
  58. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/strings.py +0 -0
  59. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/subs.py +0 -0
  60. {qubasic-0.6.1 → qubasic-0.6.3}/qubasic_core/sweep.py +0 -0
  61. {qubasic-0.6.1 → qubasic-0.6.3}/setup.cfg +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 (2026-06-18)
4
+
5
+ ### Fixed
6
+ - Colon-compound `@register` lines in LOCC mode now apply every gate. `@A H 0 : @A CX 0,1` (and the inherited form `@A H 0 : CX 0,1`) were captured as a single statement and mis-tokenized, so all but the first gate were silently dropped. The parser now splits them into per-statement parts with register inheritance, and the immediate-mode REPL path does the same.
7
+ - `SAVE_EXPECT`, `SAVE_PROBS`, and `SAVE_AMPS` no longer overwrite their target variable with 0 at circuit-build time. The placeholder keeps any prior value, so a program that re-runs with the SAVE line still present can read the previous result during the build pass (the post-run extraction then fills in the fresh value).
8
+ - `PRINT` honors `;` and `,` between multiple items: `PRINT "S ="; x` concatenates and `PRINT a, b` advances to the next 14-column zone. Separators inside quoted strings and inside call parentheses (`PRINT LEFT$(s$, 3)`) are no longer treated as item breaks.
9
+ - `PRINT` variable substitution no longer rewrites identifiers inside quoted string literals, so a label such as `PRINT "value of S"` is emitted verbatim even when `S` is a defined variable.
10
+
11
+ ## 0.6.2 (2026-06-18)
12
+
13
+ ### Fixed
14
+ - Changing the qubit count after a run (via `QUBITS n` or `POKE $D000`) now invalidates the cached statevector, so `MAP`, a `PEEK` of the qubit-state block, and `BLOCH` no longer reshape a stale statevector to the new qubit count and crash.
15
+
3
16
  ## 0.6.1 (2026-06-18)
4
17
 
5
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubasic"
7
- version = "0.6.1"
7
+ version = "0.6.3"
8
8
  description = "Quantum BASIC Interactive Terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -28,7 +28,7 @@ __all__ = [
28
28
  'GATE_TABLE', 'GATE_ALIASES',
29
29
  ]
30
30
 
31
- __version__ = '0.6.1'
31
+ __version__ = '0.6.3'
32
32
 
33
33
  def __getattr__(name):
34
34
  """Lazy import heavy modules on first access."""
@@ -63,46 +63,86 @@ class ControlFlowMixin:
63
63
  self.variables[name] = val
64
64
  return True, ExecResult.ADVANCE
65
65
 
66
- def _cf_print(self, stmt: str, run_vars: dict[str, Any],
67
- parsed: PrintStmt) -> tuple[bool, ExecOutcome]:
68
- raw_expr = parsed.expr
69
- text = self._substitute_vars(raw_expr.strip(), run_vars)
70
- # Determine trailing separator: ; suppresses newline, , advances to tab
71
- suppress_newline = raw_expr.rstrip().endswith(';')
72
- tab_advance = raw_expr.rstrip().endswith(',')
73
- if suppress_newline:
74
- text = text.rstrip().removesuffix(';').rstrip()
75
- elif tab_advance:
76
- text = text.rstrip().removesuffix(',').rstrip()
77
- # Evaluate SPC(n) and TAB(n) inline
78
- def _replace_spc(m_spc):
79
- n = int(self._eval_with_vars(m_spc.group(1), run_vars))
80
- return ' ' * max(0, n)
81
- def _replace_tab(m_tab):
82
- n = int(self._eval_with_vars(m_tab.group(1), run_vars))
83
- return ' ' * max(0, n)
84
- text = re.sub(r'\bSPC\s*\(([^)]+)\)', _replace_spc, text, flags=re.IGNORECASE)
85
- text = re.sub(r'\bTAB\s*\(([^)]+)\)', _replace_tab, text, flags=re.IGNORECASE)
86
- # Evaluate the expression
87
- if (text.startswith('"') and text.endswith('"')) or \
88
- (text.startswith("'") and text.endswith("'")):
89
- output = text[1:-1]
90
- else:
66
+ @staticmethod
67
+ def _split_print_items(expr: str) -> list[tuple[str, str]]:
68
+ """Split a PRINT argument list into (item, trailing-separator) pairs.
69
+
70
+ ';' and ',' are recognized only at top level (outside quotes and
71
+ parentheses/brackets), so PRINT LEFT$(s$, 3) stays one item and a comma
72
+ inside a quoted literal is preserved. The separator recorded with each
73
+ item is the one that follows it; '' marks the final item / no trailing
74
+ separator.
75
+ """
76
+ items: list[tuple[str, str]] = []
77
+ buf = ''
78
+ depth = 0
79
+ quote: str | None = None
80
+ for ch in expr:
81
+ if quote:
82
+ buf += ch
83
+ if ch == quote:
84
+ quote = None
85
+ elif ch in ('"', "'"):
86
+ quote = ch
87
+ buf += ch
88
+ elif ch in '([':
89
+ depth += 1
90
+ buf += ch
91
+ elif ch in ')]':
92
+ depth = max(0, depth - 1)
93
+ buf += ch
94
+ elif ch in ';,' and depth == 0:
95
+ items.append((buf.strip(), ch))
96
+ buf = ''
97
+ else:
98
+ buf += ch
99
+ if buf.strip():
100
+ items.append((buf.strip(), ''))
101
+ return items
102
+
103
+ def _eval_print_item(self, item: str, run_vars: dict[str, Any]) -> str:
104
+ """Evaluate a single PRINT item to its display string."""
105
+ item = item.strip()
106
+ if not item:
107
+ return ''
108
+ # Quoted literal: emit verbatim (no substitution, no SPC/TAB).
109
+ if (item[0] == '"' and item[-1] == '"') or (item[0] == "'" and item[-1] == "'"):
110
+ return item[1:-1]
111
+ text = self._substitute_vars(item, run_vars)
112
+
113
+ def _spaces(m):
91
114
  try:
92
- ns = run_vars.as_dict() if hasattr(run_vars, 'as_dict') else dict(run_vars) if not isinstance(run_vars, dict) else run_vars
93
- result = self._safe_eval(text, extra_ns=ns)
94
- output = str(result)
115
+ return ' ' * max(0, int(self._eval_with_vars(m.group(1), run_vars)))
95
116
  except Exception:
96
- output = text
97
- # Output with separator behavior
98
- if suppress_newline:
99
- self.io.write(output)
100
- elif tab_advance:
101
- col = len(output) % 14
102
- padding = 14 - col if col > 0 else 14
103
- self.io.write(output + ' ' * padding)
117
+ return ''
118
+ text = re.sub(r'\bSPC\s*\(([^)]+)\)', _spaces, text, flags=re.IGNORECASE)
119
+ text = re.sub(r'\bTAB\s*\(([^)]+)\)', _spaces, text, flags=re.IGNORECASE)
120
+ if not text.strip():
121
+ return text # standalone SPC/TAB -> whitespace
122
+ try:
123
+ ns = run_vars.as_dict() if hasattr(run_vars, 'as_dict') else (
124
+ run_vars if isinstance(run_vars, dict) else dict(run_vars))
125
+ return str(self._safe_eval(text, extra_ns=ns))
126
+ except Exception:
127
+ return text
128
+
129
+ def _cf_print(self, stmt: str, run_vars: dict[str, Any],
130
+ parsed: PrintStmt) -> tuple[bool, ExecOutcome]:
131
+ items = self._split_print_items(parsed.expr)
132
+ if not items:
133
+ self.io.writeln('') # bare PRINT -> blank line
134
+ return True, ExecResult.ADVANCE
135
+ out = ''
136
+ for item, sep in items:
137
+ out += self._eval_print_item(item, run_vars)
138
+ if sep == ',': # advance to next 14-column zone
139
+ col = len(out) % 14
140
+ out += ' ' * (14 - col if col else 14)
141
+ # A trailing ';' or ',' suppresses the newline (cursor stays on line).
142
+ if items[-1][1] in (';', ','):
143
+ self.io.write(out)
104
144
  else:
105
- self.io.writeln(output)
145
+ self.io.writeln(out)
106
146
  return True, ExecResult.ADVANCE
107
147
 
108
148
  def _cf_goto(self, stmt: str, sorted_lines: list[int],
@@ -119,3 +119,17 @@ class Engine:
119
119
  self._circuit_cache = None
120
120
  self._pending_set_state = None
121
121
  self._poke_state_prep = {}
122
+
123
+ def _invalidate_run_state(self) -> None:
124
+ """Drop cached results that describe a now-stale configuration.
125
+
126
+ Called when the qubit count changes: last_sv, last_counts,
127
+ last_circuit, and the transpile cache all assume the previous
128
+ num_qubits, so reading them afterwards would mislabel (or, for
129
+ the reshape in _peek_qubit/_bloch_vector, crash) the state.
130
+ """
131
+ self.last_counts = None
132
+ self.last_sv = None
133
+ self.last_circuit = None
134
+ self._circuit_cache_key = None
135
+ self._circuit_cache = None
@@ -328,20 +328,42 @@ class ExecutorMixin:
328
328
  set(self.subroutines.keys()) |
329
329
  set(self._custom_gates.keys())
330
330
  )
331
- # Tokenize: split on word boundaries, preserving delimiters
332
- tokens = re.split(r'(\b\w+\b)', stmt)
333
- for i, tok in enumerate(tokens):
334
- if not tok or not tok[0].isalpha():
335
- continue
336
- if tok in protected or tok.upper() in protected or tok.lower() in protected:
337
- continue
338
- if tok in merged:
339
- # Don't expand record bases (dicts) — leave p in p.x intact so
340
- # the expression evaluator can resolve the field.
341
- if isinstance(merged[tok], dict):
331
+
332
+ def _sub_segment(seg: str) -> str:
333
+ # Tokenize: split on word boundaries, preserving delimiters
334
+ tokens = re.split(r'(\b\w+\b)', seg)
335
+ for i, tok in enumerate(tokens):
336
+ if not tok or not tok[0].isalpha():
337
+ continue
338
+ if tok in protected or tok.upper() in protected or tok.lower() in protected:
342
339
  continue
343
- tokens[i] = str(merged[tok])
344
- return ''.join(tokens)
340
+ if tok in merged:
341
+ # Don't expand record bases (dicts) — leave p in p.x intact
342
+ # so the expression evaluator can resolve the field.
343
+ if isinstance(merged[tok], dict):
344
+ continue
345
+ tokens[i] = str(merged[tok])
346
+ return ''.join(tokens)
347
+
348
+ # Substitute only outside quoted string literals: a variable named in a
349
+ # PRINT label (PRINT "S ="; S) must survive verbatim inside the quotes.
350
+ out: list[str] = []
351
+ i, n = 0, len(stmt)
352
+ while i < n:
353
+ ch = stmt[i]
354
+ if ch in ('"', "'"):
355
+ j = i + 1
356
+ while j < n and stmt[j] != ch:
357
+ j += 1
358
+ out.append(stmt[i:j + 1]) # quoted span, verbatim
359
+ i = j + 1
360
+ else:
361
+ j = i
362
+ while j < n and stmt[j] not in ('"', "'"):
363
+ j += 1
364
+ out.append(_sub_segment(stmt[i:j])) # unquoted span
365
+ i = j
366
+ return ''.join(out)
345
367
 
346
368
  def _expand_statement(self, stmt, _call_stack: set[str] | None = None):
347
369
  """Expand subroutines. Returns list of gate strings.
@@ -636,18 +658,24 @@ class ExecutorMixin:
636
658
 
637
659
  Uses the same _exec_line pipeline as cmd_run for consistency.
638
660
  """
639
- # In LOCC mode, handle @register prefix via the numpy engine
640
- if self.locc_mode and self.locc:
641
- m = RE_AT_REG_LINE.match(line)
642
- if m:
661
+ # In LOCC mode, handle @register prefix via the numpy engine. Split
662
+ # colon-compound @REG lines so each gate is applied (a single @REG line
663
+ # is just the one-part case); a bare @REG line keeps prior behavior.
664
+ if self.locc_mode and self.locc and line.strip().startswith('@'):
665
+ parts = self._split_colon_stmts(line) if ':' in line else [line.strip()]
666
+ for part in parts:
667
+ m = RE_AT_REG_LINE.match(part)
668
+ if not m:
669
+ self.io.writeln(f"?BAD @register statement: {part}")
670
+ continue
643
671
  reg = m.group(1).upper()
644
672
  gate_stmt = m.group(2).strip()
645
673
  if reg not in self.locc.names:
646
674
  self.io.writeln(f"?UNKNOWN REGISTER: {reg} (have {', '.join(self.locc.names)})")
647
675
  return
648
676
  self._locc_apply_gate(reg, gate_stmt)
649
- self._locc_state()
650
- return
677
+ self._locc_state()
678
+ return
651
679
  if line.strip().startswith('@'):
652
680
  self.io.writeln("?@register syntax requires LOCC mode (try: LOCC <n1> <n2>)")
653
681
  return
@@ -103,7 +103,10 @@ class MemoryMixin:
103
103
  qubit, field = off // QUBIT_BLOCK, off % QUBIT_BLOCK
104
104
  if self.last_sv is None or qubit >= self.num_qubits:
105
105
  return 0.0
106
- sv = np.ascontiguousarray(self.last_sv).ravel().reshape([2] * self.num_qubits)
106
+ sv_flat = np.ascontiguousarray(self.last_sv).ravel()
107
+ if sv_flat.size != 2 ** self.num_qubits:
108
+ return 0.0 # stale statevector from a different qubit count
109
+ sv = sv_flat.reshape([2] * self.num_qubits)
107
110
  ax = self.num_qubits - 1 - qubit
108
111
  t = np.moveaxis(sv, ax, 0)
109
112
  t0, t1 = t[0].flatten(), t[1].flatten()
@@ -155,7 +158,10 @@ class MemoryMixin:
155
158
  self._zero_page[a] = v
156
159
  return
157
160
  if a == 0xD000:
158
- self.num_qubits = max(1, min(32, int(v)))
161
+ new_n = max(1, min(32, int(v)))
162
+ if new_n != self.num_qubits:
163
+ self._invalidate_run_state()
164
+ self.num_qubits = new_n
159
165
  elif a == 0xD001:
160
166
  self.shots = max(1, int(v))
161
167
  elif a == 0xD002:
@@ -554,6 +554,14 @@ def parse_stmt(raw: str) -> Stmt:
554
554
 
555
555
  # ── @REG lines ────────────────────────────────────────────────
556
556
  if text.startswith('@'):
557
+ # A colon-compound @REG line (e.g. "@A H 0 : @A CX 0,1" or
558
+ # "@A H 0 : CX 0,1") must split into per-statement parts with
559
+ # @register inheritance; otherwise the whole tail is captured as one
560
+ # AtRegStmt.inner and the LOCC executor mis-tokenizes it.
561
+ if ':' in text:
562
+ parts = _split_colon_stmts(text)
563
+ if len(parts) > 1:
564
+ return CompoundStmt(raw=raw, parts=tuple(parts))
557
565
  m = RE_AT_REG_LINE.match(text)
558
566
  if m:
559
567
  return AtRegStmt(raw=raw, reg=m.group(1).upper(),
@@ -571,6 +571,8 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
571
571
  from qubasic_core.errors import QBasicRangeError
572
572
  raise QBasicRangeError(f"RANGE: 1-{MAX_QUBITS}")
573
573
 
574
+ if n != self.num_qubits:
575
+ self._invalidate_run_state()
574
576
  self.num_qubits = n
575
577
  self.registers.clear()
576
578
  est = _estimate_gb(n)
@@ -1980,8 +1982,13 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1980
1982
  full_pauli[self.num_qubits - 1 - qubits[i]] = p
1981
1983
  op = SparsePauliOp(''.join(full_pauli))
1982
1984
  qc.save_expectation_value(op, list(range(self.num_qubits)), label=f'exp_{var}')
1983
- run_vars[var] = 0 # actual value available after simulation
1984
- self.variables[var] = 0
1985
+ # Placeholder until the run fills the real value via
1986
+ # _extract_save_results. Preserve any prior value instead of
1987
+ # zeroing it, so a re-run that re-includes this SAVE line leaves
1988
+ # earlier results readable to LET/PRINT during the build pass.
1989
+ _prev = self.variables.get(var, 0)
1990
+ run_vars[var] = _prev
1991
+ self.variables[var] = _prev
1985
1992
  except Exception as e:
1986
1993
  self.io.writeln(f"?SAVE_EXPECT ERROR: {e}")
1987
1994
  return True
@@ -1996,8 +2003,9 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1996
2003
  var = m.group(2)
1997
2004
  try:
1998
2005
  qc.save_probabilities(qubits, label=f'prob_{var}')
1999
- run_vars[var] = 0
2000
- self.variables[var] = 0
2006
+ _prev = self.variables.get(var, 0) # preserve prior value (see SAVE_EXPECT)
2007
+ run_vars[var] = _prev
2008
+ self.variables[var] = _prev
2001
2009
  except Exception as e:
2002
2010
  self.io.writeln(f"?SAVE_PROBS ERROR: {e}")
2003
2011
  return True
@@ -2012,8 +2020,9 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
2012
2020
  var = m.group(2)
2013
2021
  try:
2014
2022
  qc.save_amplitudes(indices, label=f'amp_{var}')
2015
- run_vars[var] = 0
2016
- self.variables[var] = 0
2023
+ _prev = self.variables.get(var, 0) # preserve prior value (see SAVE_EXPECT)
2024
+ run_vars[var] = _prev
2025
+ self.variables[var] = _prev
2017
2026
  except Exception as e:
2018
2027
  self.io.writeln(f"?SAVE_AMPS ERROR: {e}")
2019
2028
  return True
@@ -359,6 +359,42 @@ class TestMemoryMap(unittest.TestCase):
359
359
  self.assertEqual(ntype, 2)
360
360
  self.assertAlmostEqual(nparam, 0.1)
361
361
 
362
+ def test_qubit_count_change_invalidates_stale_state(self):
363
+ """Changing the qubit count after a run must not leave a stale last_sv
364
+ that MAP/PEEK/BLOCH then reshape to the wrong size and crash."""
365
+ import numpy as np
366
+
367
+ # Run a 1-qubit circuit so last_sv has size 2.
368
+ t = QBasicTerminal()
369
+ t.num_qubits = 1
370
+ t.process('10 H 0')
371
+ t.process('20 MEASURE')
372
+ capture(t.cmd_run)
373
+
374
+ # QUBITS grows the count: the size-2 statevector no longer applies.
375
+ capture(t.cmd_qubits, '2')
376
+ self.assertIsNone(t.last_sv)
377
+ self.assertIsNone(t._circuit_cache_key)
378
+ self.assertEqual(t._peek(0x0100), 0.0) # would have crashed on reshape
379
+ capture(t.cmd_map) # MAP walks the qubit block
380
+ capture(t.cmd_bloch, '0') # BLOCH reshapes too
381
+
382
+ # The $D000 POKE path invalidates the same way.
383
+ t2 = QBasicTerminal()
384
+ t2.num_qubits = 1
385
+ t2.process('10 H 0')
386
+ t2.process('20 MEASURE')
387
+ capture(t2.cmd_run)
388
+ t2._poke(0xD000, 3)
389
+ self.assertIsNone(t2.last_sv)
390
+ self.assertEqual(t2._peek(0x0100), 0.0)
391
+
392
+ # Defensive guard: even a hand-set mismatched last_sv reads as 0, not a crash.
393
+ t3 = QBasicTerminal()
394
+ t3.num_qubits = 2
395
+ t3.last_sv = np.array([1.0, 0.0], dtype=complex) # size 2, but 2 qubits
396
+ self.assertEqual(t3._peek(0x0100), 0.0)
397
+
362
398
  def test_sys(self):
363
399
  """SYS builtin BELL, SYS unmapped, SYS in program, SYS INSTALL."""
364
400
  # SYS 0xE000 BELL demo
@@ -1432,6 +1432,92 @@ class TestAdditionalCoverage(unittest.TestCase):
1432
1432
  self.assertEqual(t4.last_counts.get('1', 0), 100)
1433
1433
 
1434
1434
 
1435
+ # ---------------------------------------------------------------------------
1436
+ # TestBugFixes063 — regressions fixed in 0.6.3
1437
+ # ---------------------------------------------------------------------------
1438
+ class TestBugFixes063(unittest.TestCase):
1439
+ """Regression tests for the 0.6.3 fixes."""
1440
+
1441
+ def _ghz3(self):
1442
+ v = np.zeros(8, dtype=complex)
1443
+ v[0] = v[7] = 1 / np.sqrt(2)
1444
+ return v
1445
+
1446
+ def test_locc_colon_compound_at_register(self):
1447
+ """LOCC: colon-compound @REG lines apply every gate (explicit and inherited)."""
1448
+ for line in ('@A H 0 : @A CX 0,1 : @A CX 0,2', # explicit @A per clause
1449
+ '@A H 0 : CX 0,1 : CX 0,2'): # @A inherited
1450
+ t = QBasicTerminal()
1451
+ t.locc = LOCCEngine([3], joint=True)
1452
+ t.locc_mode = True
1453
+ t.shots = 1
1454
+ t.process(f'10 {line}', track_undo=False)
1455
+ t.process('20 MEASURE', track_undo=False)
1456
+ capture(t.cmd_run)
1457
+ sv = np.ascontiguousarray(t.locc.sv).ravel()
1458
+ fid = abs(np.vdot(self._ghz3(), sv)) ** 2
1459
+ self.assertGreater(fid, 0.999, f"GHZ not built by: {line}")
1460
+
1461
+ def test_save_expect_preserved_across_rerun(self):
1462
+ """SAVE_EXPECT keeps prior values on re-run instead of zeroing them."""
1463
+ t = QBasicTerminal()
1464
+ t.num_qubits = 2
1465
+ t.shots = 64
1466
+ t.process('10 H 0', track_undo=False)
1467
+ t.process('20 CX 0,1', track_undo=False)
1468
+ t.process('30 SAVE_EXPECT ZZ 0,1 -> zz', track_undo=False)
1469
+ t.process('40 SAVE_EXPECT XX 0,1 -> xx', track_undo=False)
1470
+ capture(t.cmd_run)
1471
+ self.assertAlmostEqual(t.variables['zz'], 1.0, places=6)
1472
+ self.assertAlmostEqual(t.variables['xx'], 1.0, places=6)
1473
+ # A second run that still contains the SAVE lines must not zero zz/xx
1474
+ # before the LET on line 50 reads them.
1475
+ t.process('50 LET chsh = SQRT2 * (zz + xx)', track_undo=False)
1476
+ capture(t.cmd_run)
1477
+ self.assertAlmostEqual(t.variables['chsh'], 2 * math.sqrt(2), places=6)
1478
+
1479
+ def test_print_multi_item_separators(self):
1480
+ """PRINT concatenates ';' items and tab-aligns ',' items."""
1481
+ t = QBasicTerminal()
1482
+ t.num_qubits = 1
1483
+ t.variables['S'] = 2.8284271247
1484
+ t.process('10 PRINT "S ="; S', track_undo=False)
1485
+ _, out = capture(t.cmd_run)
1486
+ self.assertIn('S =2.8284', out)
1487
+
1488
+ t2 = QBasicTerminal()
1489
+ t2.num_qubits = 1
1490
+ t2.process('10 PRINT "a", "b"', track_undo=False)
1491
+ _, out2 = capture(t2.cmd_run)
1492
+ line = next(l for l in out2.splitlines() if 'a' in l and 'b' in l)
1493
+ self.assertGreaterEqual(line.index('b') - line.index('a'), 14)
1494
+
1495
+ def test_print_does_not_substitute_inside_quotes(self):
1496
+ """A variable name inside a quoted PRINT literal is emitted verbatim."""
1497
+ t = QBasicTerminal()
1498
+ t.num_qubits = 1
1499
+ t.variables['S'] = 99.0
1500
+ t.process('10 PRINT "value of S here"', track_undo=False)
1501
+ _, out = capture(t.cmd_run)
1502
+ self.assertIn('value of S here', out)
1503
+ self.assertNotIn('99', out)
1504
+
1505
+ def test_print_preserves_commas_in_quotes_and_calls(self):
1506
+ """Top-level split skips commas inside quotes and inside call parens."""
1507
+ t = QBasicTerminal()
1508
+ t.num_qubits = 1
1509
+ t.process('10 PRINT "x,y,z"', track_undo=False)
1510
+ _, out = capture(t.cmd_run)
1511
+ self.assertIn('x,y,z', out)
1512
+
1513
+ t2 = QBasicTerminal()
1514
+ t2.num_qubits = 1
1515
+ t2.process('10 LET name$ = "hello"', track_undo=False)
1516
+ t2.process('20 PRINT LEFT$(name$, 3)', track_undo=False)
1517
+ _, out2 = capture(t2.cmd_run)
1518
+ self.assertIn('hel', out2)
1519
+
1520
+
1435
1521
  if __name__ == '__main__':
1436
1522
  if hasattr(sys.stdout, 'reconfigure'):
1437
1523
  sys.stdout.reconfigure(encoding='utf-8')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes