qubasic 0.5.0__tar.gz → 0.6.0__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 (59) hide show
  1. {qubasic-0.5.0 → qubasic-0.6.0}/CHANGELOG.md +29 -0
  2. {qubasic-0.5.0/qubasic.egg-info → qubasic-0.6.0}/PKG-INFO +4 -10
  3. {qubasic-0.5.0 → qubasic-0.6.0}/README.md +3 -9
  4. {qubasic-0.5.0 → qubasic-0.6.0}/pyproject.toml +1 -1
  5. {qubasic-0.5.0 → qubasic-0.6.0/qubasic.egg-info}/PKG-INFO +4 -10
  6. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.py +45 -31
  7. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/__init__.py +1 -1
  8. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/analysis.py +52 -24
  9. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/control_flow.py +89 -20
  10. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/debug.py +1 -0
  11. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/demos.py +1 -0
  12. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/display.py +22 -7
  13. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/engine_state.py +10 -1
  14. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/executor.py +35 -0
  15. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/expression.py +95 -19
  16. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/file_io.py +61 -15
  17. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/help_text.py +3 -1
  18. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/locc_commands.py +30 -4
  19. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/locc_engine.py +83 -31
  20. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/locc_execution.py +16 -5
  21. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/memory.py +62 -6
  22. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/noise_mixin.py +17 -5
  23. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/parser.py +8 -4
  24. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/patterns.py +2 -2
  25. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/profiler.py +36 -20
  26. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/program_mgmt.py +2 -4
  27. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/qol.py +81 -49
  28. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/statements.py +8 -0
  29. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/terminal.py +210 -48
  30. {qubasic-0.5.0 → qubasic-0.6.0}/LICENSE +0 -0
  31. {qubasic-0.5.0 → qubasic-0.6.0}/MANIFEST.in +0 -0
  32. {qubasic-0.5.0 → qubasic-0.6.0}/examples/bell.qb +0 -0
  33. {qubasic-0.5.0 → qubasic-0.6.0}/examples/grover3.qb +0 -0
  34. {qubasic-0.5.0 → qubasic-0.6.0}/examples/locc_teleport.qb +0 -0
  35. {qubasic-0.5.0 → qubasic-0.6.0}/examples/sweep_rx.qb +0 -0
  36. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.egg-info/SOURCES.txt +0 -0
  37. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.egg-info/dependency_links.txt +0 -0
  38. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.egg-info/entry_points.txt +0 -0
  39. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.egg-info/requires.txt +0 -0
  40. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic.egg-info/top_level.txt +0 -0
  41. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/__main__.py +0 -0
  42. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/backend.py +0 -0
  43. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/classic.py +0 -0
  44. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/engine.py +0 -0
  45. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/errors.py +0 -0
  46. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/exec_context.py +0 -0
  47. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/gates.py +0 -0
  48. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/io_protocol.py +0 -0
  49. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/locc.py +0 -0
  50. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/locc_display.py +0 -0
  51. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/mock_backend.py +0 -0
  52. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/protocol.py +0 -0
  53. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/scope.py +0 -0
  54. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/screen.py +0 -0
  55. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/state_display.py +0 -0
  56. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/strings.py +0 -0
  57. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/subs.py +0 -0
  58. {qubasic-0.5.0 → qubasic-0.6.0}/qubasic_core/sweep.py +0 -0
  59. {qubasic-0.5.0 → qubasic-0.6.0}/setup.cfg +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-06-18)
4
+
5
+ Correctness, scalability, and usability pass (61 fixes).
6
+
7
+ ### Fixed
8
+ - **Time-travel debugging now works**: STEP records statevector checkpoints, so REWIND/FORWARD/HISTORY operate (previously `_checkpoint_sv` was never called).
9
+ - **TRON, breakpoints, ON TIMER, ON MEASURE now fire**: the debug hooks are wired into the execution loop instead of being dead code.
10
+ - **PLOT and ANIMATE sweep correctly**: the transpiled-circuit cache key now includes variable bindings, so parameter sweeps no longer reuse the first circuit.
11
+ - **`$XXXX` memory addresses parse**: PEEK/POKE/DUMP/SYS/WAIT accept the canonical hex-address notation, not only `0x`/decimal/`&H`.
12
+ - **Multi-line IF/ELSE/END IF**: the untaken branch is properly skipped (previously both branches ran).
13
+ - **ON ERROR** reports the real ERR/ERL for build-path errors and runs handler lines through the program executor (no spurious state dumps).
14
+ - **OPTION BASE 1** is honored in array indexing; FOR with STEP 0 is rejected; bare `NEXT` and `NEXT i, j` are supported.
15
+ - **CSV/EXPORT** report correctly when only a statevector (no counts) is present.
16
+
17
+ ### Performance / scalability
18
+ - Stabilizer is chosen before MPS for large Clifford circuits, and the full 2^n statevector is no longer rebuilt after every measured run (a 30-qubit GHZ went from ~16s to <1s).
19
+ - STEP evolves the statevector incrementally; DENSITY and EXPECT avoid building large matrices; HEATMAP and STATS reuse work.
20
+
21
+ ### Quantum / LOCC
22
+ - LOCC depolarizing noise matches the Qiskit Aer convention; amplitude- and phase-damping channels are now simulated in the LOCC engine.
23
+ - EXPECT/ENTROPY/DENSITY/BLOCH accept a register argument in SPLIT mode; `LOCC SHOTCAP` replaces the magic shot-cap variable; SEND prefix/suffix optimization detects all control transfers.
24
+ - Per-qubit memory-mapped noise applies to multi-qubit gates; complex single-qubit amplitudes are exposed in the qubit-state block; POKE-driven state preparation is applied at build.
25
+
26
+ ### Language / interface
27
+ - User-defined TYPE record fields can be read and assigned (`p.x`); ELSEIF chains and re-parsed clauses are memoized.
28
+ - SAVE/LOAD round-trip noise, seed, screen mode, DEF FN, and TYPE definitions; CHAIN/MERGE/INCLUDE/IMPORT honor a `QUBASIC_PATH` search path; IMPORT no longer pollutes SAVE output.
29
+ - CLI gains `--version` and `--agent`; `--json` output is enriched (statevector, variables, manifest) and emits structured errors; the agent write-sandbox engages under `--json`/`--agent`.
30
+ - `SET_STATE` persists into the next RUN and warns when a named state cannot fit; per-command `HELP <name>`; dependency-version warnings are suppressed before the banner.
31
+
3
32
  ## 0.5.0 (2026-03-30)
4
33
 
5
34
  - **Colorized histograms**: bars colored green/yellow/dim by probability in terminal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -468,15 +468,9 @@ BLOCH A 0 Bloch sphere for register A, qubit 0
468
468
  LOCCINFO Protocol metrics
469
469
  ```
470
470
 
471
- SPLIT: max capacity (31+31+...), no cross-register entanglement. Use SEND/IF for classical coordination.
471
+ SPLIT: max capacity (up to 33 qubits per register), no cross-register entanglement. Use SEND/IF for classical coordination.
472
472
 
473
- JOINT: shared entanglement via SHARE. Limited to ~32 total qubits.
474
-
475
- ### Network LOCC
476
- ```
477
- CONNECT "host:port" AS C Attach remote register (local simulation stand-in)
478
- DISCONNECT C Detach
479
- ```
473
+ JOINT: shared entanglement via SHARE. Limited to 33 total qubits.
480
474
 
481
475
  ### LOCC optimization
482
476
  Programs with SEND execute the deterministic prefix (before first SEND) once, snapshot the statevector, then re-execute only the suffix per shot. Complexity: O(prefix + shots * suffix) instead of O(shots * total).
@@ -737,7 +731,7 @@ qubasic_core/
737
731
  mock_backend.py MockAerSimulator for fast testing
738
732
  ```
739
733
 
740
- `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 16 mixins. `Engine` can be used independently for headless/agent execution.
734
+ `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 20 mixins. Execution methods live on `QBasicTerminal`, so headless/agent use should instantiate `QBasicTerminal` (the `Engine` base is a state container only).
741
735
 
742
736
  ## License
743
737
 
@@ -435,15 +435,9 @@ BLOCH A 0 Bloch sphere for register A, qubit 0
435
435
  LOCCINFO Protocol metrics
436
436
  ```
437
437
 
438
- SPLIT: max capacity (31+31+...), no cross-register entanglement. Use SEND/IF for classical coordination.
438
+ SPLIT: max capacity (up to 33 qubits per register), no cross-register entanglement. Use SEND/IF for classical coordination.
439
439
 
440
- JOINT: shared entanglement via SHARE. Limited to ~32 total qubits.
441
-
442
- ### Network LOCC
443
- ```
444
- CONNECT "host:port" AS C Attach remote register (local simulation stand-in)
445
- DISCONNECT C Detach
446
- ```
440
+ JOINT: shared entanglement via SHARE. Limited to 33 total qubits.
447
441
 
448
442
  ### LOCC optimization
449
443
  Programs with SEND execute the deterministic prefix (before first SEND) once, snapshot the statevector, then re-execute only the suffix per shot. Complexity: O(prefix + shots * suffix) instead of O(shots * total).
@@ -704,7 +698,7 @@ qubasic_core/
704
698
  mock_backend.py MockAerSimulator for fast testing
705
699
  ```
706
700
 
707
- `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 16 mixins. `Engine` can be used independently for headless/agent execution.
701
+ `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 20 mixins. Execution methods live on `QBasicTerminal`, so headless/agent use should instantiate `QBasicTerminal` (the `Engine` base is a state container only).
708
702
 
709
703
  ## License
710
704
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubasic"
7
- version = "0.5.0"
7
+ version = "0.6.0"
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.5.0
3
+ Version: 0.6.0
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -468,15 +468,9 @@ BLOCH A 0 Bloch sphere for register A, qubit 0
468
468
  LOCCINFO Protocol metrics
469
469
  ```
470
470
 
471
- SPLIT: max capacity (31+31+...), no cross-register entanglement. Use SEND/IF for classical coordination.
471
+ SPLIT: max capacity (up to 33 qubits per register), no cross-register entanglement. Use SEND/IF for classical coordination.
472
472
 
473
- JOINT: shared entanglement via SHARE. Limited to ~32 total qubits.
474
-
475
- ### Network LOCC
476
- ```
477
- CONNECT "host:port" AS C Attach remote register (local simulation stand-in)
478
- DISCONNECT C Detach
479
- ```
473
+ JOINT: shared entanglement via SHARE. Limited to 33 total qubits.
480
474
 
481
475
  ### LOCC optimization
482
476
  Programs with SEND execute the deterministic prefix (before first SEND) once, snapshot the statevector, then re-execute only the suffix per shot. Complexity: O(prefix + shots * suffix) instead of O(shots * total).
@@ -737,7 +731,7 @@ qubasic_core/
737
731
  mock_backend.py MockAerSimulator for fast testing
738
732
  ```
739
733
 
740
- `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 16 mixins. `Engine` can be used independently for headless/agent execution.
734
+ `Engine` holds all program state. `QBasicTerminal` inherits `Engine` + 20 mixins. Execution methods live on `QBasicTerminal`, so headless/agent use should instantiate `QBasicTerminal` (the `Engine` base is a state container only).
741
735
 
742
736
  ## License
743
737
 
@@ -50,14 +50,15 @@ def main():
50
50
  import json as _json
51
51
  os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
52
52
 
53
+ from qubasic_core import __version__
54
+
53
55
  args = sys.argv[1:]
54
56
  quiet = '--quiet' in args or '-q' in args
55
57
  json_mode = '--json' in args
58
+ agent_mode = '--agent' in args
56
59
  seed_val = None
57
- if quiet:
58
- args = [a for a in args if a not in ('--quiet', '-q')]
59
- if json_mode:
60
- args = [a for a in args if a != '--json']
60
+ for flag in ('--quiet', '-q', '--json', '--agent'):
61
+ args = [a for a in args if a != flag]
61
62
  # Parse --seed N
62
63
  filtered = []
63
64
  i = 0
@@ -70,8 +71,11 @@ def main():
70
71
  i += 1
71
72
  args = filtered
72
73
 
74
+ if any(a in ('-v', '--version') for a in args):
75
+ print(f"QUBASIC {__version__}")
76
+ sys.exit(0)
77
+
73
78
  if any(a in ('-h', '--help') for a in args):
74
- from qubasic_core import __version__
75
79
  print(f"QUBASIC {__version__} — Quantum BASIC Interactive Terminal")
76
80
  print()
77
81
  print("Usage:")
@@ -79,13 +83,17 @@ def main():
79
83
  print(" qubasic script.qb Run a script file")
80
84
  print(" qubasic --quiet script Suppress banner and progress")
81
85
  print(" qubasic --json script Output results as JSON")
86
+ print(" qubasic --agent script Confine file writes to the working dir")
82
87
  print(" qubasic --seed N script Set random seed for reproducibility")
88
+ print(" qubasic --version Show version")
83
89
  print(" qubasic --help Show this help")
84
90
  print()
85
91
  print("Type HELP inside the REPL for full command reference.")
86
92
  sys.exit(0)
87
93
 
88
94
  term = QBasicTerminal()
95
+ # JSON mode implies agent use, so confine file writes to the working dir.
96
+ term.agent_mode = agent_mode or json_mode
89
97
  if seed_val is not None:
90
98
  import numpy as _np
91
99
  term._seed = seed_val
@@ -93,35 +101,41 @@ def main():
93
101
 
94
102
  if args:
95
103
  path = args[0]
96
- if os.path.isfile(path):
97
- if quiet or json_mode:
98
- import io
99
- buf = io.StringIO()
100
- old = sys.stdout
101
- sys.stdout = buf
102
- try:
103
- run_script(path, term)
104
- finally:
105
- sys.stdout = old
106
- if json_mode:
107
- result = {
108
- 'counts': term.last_counts or {},
109
- 'num_qubits': term.num_qubits,
110
- 'shots': term.shots,
111
- }
112
- print(_json.dumps(result, indent=2))
113
- elif not quiet:
114
- print(buf.getvalue())
104
+ if not os.path.isfile(path):
105
+ if json_mode:
106
+ print(_json.dumps({'error': f'FILE NOT FOUND: {path}'}, indent=2))
115
107
  else:
116
- term.print_banner()
108
+ print(f"?FILE NOT FOUND: {path}")
109
+ sys.exit(1)
110
+ if quiet or json_mode:
111
+ import io
112
+ buf = io.StringIO()
113
+ old = sys.stdout
114
+ sys.stdout = buf
115
+ err = None
116
+ try:
117
117
  run_script(path, term)
118
- # Exit code: 0 if results exist, 1 if error
119
- sys.exit(0 if term.last_counts is not None or not any(
120
- term.program.get(ln, '').strip().upper() == 'MEASURE'
121
- for ln in term.program) else 1)
118
+ except Exception as e: # surface as structured error in JSON mode
119
+ err = str(e)
120
+ finally:
121
+ sys.stdout = old
122
+ if json_mode:
123
+ if err is not None:
124
+ print(_json.dumps({'error': err}, indent=2))
125
+ sys.exit(1)
126
+ print(_json.dumps(term.result(), indent=2))
127
+ elif not quiet:
128
+ print(buf.getvalue())
129
+ if err is not None:
130
+ print(f"?ERROR: {err}")
131
+ sys.exit(1)
122
132
  else:
123
- print(f"?FILE NOT FOUND: {path}")
124
- sys.exit(1)
133
+ term.print_banner()
134
+ run_script(path, term)
135
+ # Exit 0 on success; 1 if a measured program produced no counts.
136
+ sys.exit(0 if term.last_counts is not None or not any(
137
+ term.program.get(ln, '').strip().upper() == 'MEASURE'
138
+ for ln in term.program) else 1)
125
139
  else:
126
140
  term.repl()
127
141
 
@@ -28,7 +28,7 @@ __all__ = [
28
28
  'GATE_TABLE', 'GATE_ALIASES',
29
29
  ]
30
30
 
31
- __version__ = '0.5.0'
31
+ __version__ = '0.6.0'
32
32
 
33
33
  def __getattr__(name):
34
34
  """Lazy import heavy modules on first access."""
@@ -22,24 +22,48 @@ class AnalysisMixin:
22
22
  self.locc, self.locc_mode.
23
23
  """
24
24
 
25
+ def _resolve_analysis_target(self, rest: str):
26
+ """Return (sv, n_qubits, remaining_rest) for a state-analysis command.
27
+
28
+ In LOCC SPLIT mode a leading register letter selects that register
29
+ (e.g. ``EXPECT A Z 0``); otherwise the active statevector is used.
30
+ """
31
+ parts = rest.split()
32
+ if self.locc_mode and self.locc and not self.locc.joint and parts:
33
+ cand = parts[0].upper()
34
+ if cand in self.locc.names:
35
+ sv, n = self._active_sv_for_reg(cand)
36
+ return sv, n, ' '.join(parts[1:])
37
+ return self._active_sv, self._active_nqubits, rest
38
+
25
39
  def cmd_expect(self, rest: str) -> None:
26
- """EXPECT <pauli> [qubits] — compute expectation value.
27
- Examples: EXPECT Z 0, EXPECT ZZ 0 1, EXPECT X 0"""
28
- sv = self._active_sv
40
+ """EXPECT [reg] <pauli> [qubits] — compute expectation value.
41
+ Examples: EXPECT Z 0, EXPECT ZZ 0 1, EXPECT A Z 0 (SPLIT register A)"""
42
+ sv, n, rest = self._resolve_analysis_target(rest)
29
43
  if sv is None:
30
44
  if self.locc_mode and self.locc and not self.locc.joint:
31
- self.io.writeln("?SPLIT mode: use STATE A / STATE B for per-register inspection")
45
+ self.io.writeln("?SPLIT mode: prefix a register, e.g. EXPECT A Z 0")
32
46
  else:
33
47
  self.io.writeln("?NO STATE — RUN first")
34
48
  return
35
49
  parts = rest.split()
36
50
  if not parts:
37
- self.io.writeln("?USAGE: EXPECT <Z|X|Y|ZZ|...> [qubits]")
51
+ self.io.writeln("?USAGE: EXPECT [reg] <Z|X|Y|ZZ|...> [qubits]")
38
52
  return
39
53
  pauli_str = parts[0].upper()
40
54
  qubits = [int(q) for q in parts[1:]] if len(parts) > 1 else list(range(len(pauli_str)))
41
-
42
- n = self._active_nqubits
55
+ # Fast numpy path for diagonal (Z/I-only) observables — no qiskit copy.
56
+ if set(pauli_str) <= {'Z', 'I'}:
57
+ svf = np.ascontiguousarray(sv).ravel()
58
+ probs = np.abs(svf) ** 2
59
+ idx = np.arange(svf.size)
60
+ sign = np.ones(svf.size)
61
+ for i, p in enumerate(pauli_str):
62
+ if p == 'Z' and i < len(qubits):
63
+ sign = sign * np.where(((idx >> qubits[i]) & 1) == 1, -1.0, 1.0)
64
+ val = float(np.sum(probs * sign))
65
+ self.io.writeln(f" <{pauli_str}> on qubits {qubits} = {val:.6f}")
66
+ return
43
67
  try:
44
68
  from qiskit.quantum_info import Statevector, SparsePauliOp
45
69
  sv_q = Statevector(np.ascontiguousarray(sv).ravel())
@@ -54,17 +78,19 @@ class AnalysisMixin:
54
78
  self.io.writeln(f"?EXPECT ERROR: {e}")
55
79
 
56
80
  def cmd_entropy(self, rest: str = '') -> None:
57
- """ENTROPY [qubits] — entanglement entropy of specified qubits vs rest.
58
- Examples: ENTROPY 0 | ENTROPY 0 1 | ENTROPY (defaults to qubit 0)"""
59
- sv = self._active_sv
81
+ """ENTROPY [reg] [qubits] — entanglement entropy of qubits vs rest.
82
+ Examples: ENTROPY 0 | ENTROPY 0 1 | ENTROPY A 0 (SPLIT register A)"""
83
+ sv, n, rest = self._resolve_analysis_target(rest)
60
84
  if sv is None:
61
- self.io.writeln("?NO STATE RUN first")
85
+ if self.locc_mode and self.locc and not self.locc.joint:
86
+ self.io.writeln("?SPLIT mode: prefix a register, e.g. ENTROPY A 0")
87
+ else:
88
+ self.io.writeln("?NO STATE — RUN first")
62
89
  return
63
90
  if rest.strip():
64
91
  partition_a = [int(q) for q in rest.replace(',', ' ').split() if q.strip()]
65
92
  else:
66
93
  partition_a = [0]
67
- n = self._active_nqubits
68
94
  try:
69
95
  from qiskit.quantum_info import Statevector, entropy, partial_trace
70
96
  sv_obj = Statevector(np.ascontiguousarray(sv).ravel())
@@ -81,25 +107,27 @@ class AnalysisMixin:
81
107
  except Exception as e:
82
108
  self.io.writeln(f"?ENTROPY ERROR: {e}")
83
109
 
84
- def cmd_density(self) -> None:
85
- """Show density matrix (or partial trace for small systems)."""
86
- sv = self._active_sv
110
+ def cmd_density(self, rest: str = '') -> None:
111
+ """Show density matrix (DENSITY [reg]); summarizes for large systems."""
112
+ sv, n, rest = self._resolve_analysis_target(rest)
87
113
  if sv is None:
88
- self.io.writeln("?NO STATE RUN first")
114
+ if self.locc_mode and self.locc and not self.locc.joint:
115
+ self.io.writeln("?SPLIT mode: prefix a register, e.g. DENSITY A")
116
+ else:
117
+ self.io.writeln("?NO STATE — RUN first")
89
118
  return
90
119
  sv = np.ascontiguousarray(sv).ravel()
91
- rho = np.outer(sv, sv.conj())
92
- n = self._active_nqubits
93
120
  dim = 2**n
94
121
  if dim > 16:
122
+ # |psi><psi| is rank-1, so purity is ||psi||^4 and the von Neumann
123
+ # entropy is 0; report directly instead of building a 2^n x 2^n matrix.
124
+ norm2 = float(np.vdot(sv, sv).real)
95
125
  self.io.writeln(f" Density matrix: {dim}x{dim} (too large to display)")
96
- self.io.writeln(f" Purity: {np.real(np.trace(rho @ rho)):.6f}")
97
- self.io.write(f" Von Neumann entropy: ")
98
- eigvals = np.linalg.eigvalsh(rho)
99
- eigvals = eigvals[eigvals > 1e-15]
100
- ent = -np.sum(eigvals * np.log2(eigvals))
101
- self.io.writeln(f"{ent:.6f} bits")
126
+ self.io.writeln(f" Pure state |psi><psi|: purity {norm2 ** 2:.6f}")
127
+ self.io.writeln(f" Von Neumann entropy: 0.000000 bits")
128
+ self.io.writeln(f" (use ENTROPY <qubits> for reduced-state entanglement)")
102
129
  return
130
+ rho = np.outer(sv, sv.conj())
103
131
  self.io.writeln(f"\n Density matrix ({dim}x{dim}):\n")
104
132
  for i in range(dim):
105
133
  row = []
@@ -15,7 +15,7 @@ from qubasic_core.statements import (
15
15
  LetArrayStmt, LetStmt, PrintStmt, GotoStmt, GosubStmt,
16
16
  ForStmt, NextStmt, WhileStmt, IfThenStmt,
17
17
  DataStmt, ReadStmt, OnGotoStmt, OnGosubStmt,
18
- SelectCaseStmt, CaseStmt, EndSelectStmt,
18
+ SelectCaseStmt, CaseStmt, EndSelectStmt, ElseStmt, EndIfStmt,
19
19
  DoStmt, LoopStmt, ExitStmt,
20
20
  SwapStmt, DefFnStmt, OptionBaseStmt,
21
21
  SubStmt, EndSubStmt, FunctionStmt, EndFunctionStmt, CallStmt,
@@ -43,8 +43,11 @@ class ControlFlowMixin:
43
43
  def _cf_let_array(self, stmt: str, run_vars: dict[str, Any],
44
44
  parsed: LetArrayStmt) -> tuple[bool, ExecOutcome]:
45
45
  name, idx_expr, val_expr = parsed.name, parsed.index_expr, parsed.value_expr
46
- idx = int(self._eval_with_vars(idx_expr, run_vars))
46
+ base = getattr(self, '_option_base', 0)
47
+ idx = int(self._eval_with_vars(idx_expr, run_vars)) - base
47
48
  val = self._eval_with_vars(val_expr, run_vars)
49
+ if idx < 0:
50
+ raise RuntimeError(f"ARRAY INDEX OUT OF RANGE: {name}({idx + base})")
48
51
  if name not in self.arrays:
49
52
  self.arrays[name] = [0.0] * (idx + 1)
50
53
  while idx >= len(self.arrays[name]):
@@ -126,6 +129,8 @@ class ControlFlowMixin:
126
129
  start = self._eval_with_vars(start_expr, run_vars)
127
130
  end = self._eval_with_vars(end_expr, run_vars)
128
131
  step = self._eval_with_vars(step_expr, run_vars) if step_expr else 1
132
+ if step == 0:
133
+ raise RuntimeError(f"FOR {var}: STEP 0 would never terminate")
129
134
  try:
130
135
  if start == int(start): start = int(start)
131
136
  except (OverflowError, ValueError):
@@ -146,22 +151,25 @@ class ControlFlowMixin:
146
151
 
147
152
  def _cf_next(self, stmt: str, run_vars: dict[str, Any], loop_stack: list[dict[str, Any]],
148
153
  parsed: NextStmt) -> tuple[bool, ExecOutcome]:
149
- var = parsed.var
150
- if not loop_stack or loop_stack[-1].get('var') != var:
151
- if loop_stack:
152
- expected = loop_stack[-1].get('var', '?')
153
- raise RuntimeError(f"NEXT {var} does not match current FOR {expected}")
154
- raise RuntimeError(f"NEXT {var} without matching FOR")
155
- loop = loop_stack[-1]
156
- loop['current'] += loop['step']
157
- if (loop['step'] > 0 and loop['current'] <= loop['end']) or \
158
- (loop['step'] < 0 and loop['current'] >= loop['end']):
159
- run_vars[var] = loop['current']
160
- self.variables[var] = loop['current']
161
- return True, loop['return_ip'] + 1
162
- else:
154
+ # Supports bare ``NEXT`` (close the innermost FOR) and ``NEXT i, j``
155
+ # (close i, then j). Each named variable must match the FOR on top of
156
+ # the loop stack at that point.
157
+ names = [v.strip() for v in parsed.var.split(',')] if parsed.var.strip() else ['']
158
+ for name in names:
159
+ if not loop_stack or loop_stack[-1].get('var') is None:
160
+ raise RuntimeError(f"NEXT {name}".rstrip() + " without matching FOR")
161
+ loop = loop_stack[-1]
162
+ cur_var = loop['var']
163
+ if name and cur_var != name:
164
+ raise RuntimeError(f"NEXT {name} does not match current FOR {cur_var}")
165
+ loop['current'] += loop['step']
166
+ if (loop['step'] > 0 and loop['current'] <= loop['end']) or \
167
+ (loop['step'] < 0 and loop['current'] >= loop['end']):
168
+ run_vars[cur_var] = loop['current']
169
+ self.variables[cur_var] = loop['current']
170
+ return True, loop['return_ip'] + 1
163
171
  loop_stack.pop()
164
- return True, ExecResult.ADVANCE
172
+ return True, ExecResult.ADVANCE
165
173
 
166
174
  def _find_matching_wend(self, sorted_lines: list[int], ip: int) -> int:
167
175
  """Find the ip after the WEND matching the WHILE at ip.
@@ -218,8 +226,21 @@ class ControlFlowMixin:
218
226
  cond_vars = run_vars
219
227
  if self.locc_mode and self.locc:
220
228
  cond_vars = {**run_vars, **self.locc.classical}
221
- result = ExecResult.ADVANCE
222
- if self._eval_condition(cond_str, cond_vars):
229
+ cond_true = self._eval_condition(cond_str, cond_vars)
230
+
231
+ # Block form: "IF cond THEN" with no inline THEN/ELSE clauses spans
232
+ # following lines up to a matching ELSE / END IF.
233
+ if not then_clause and else_clause is None:
234
+ else_ip, endif_ip = self._find_if_block(sorted_lines, ip)
235
+ if cond_true:
236
+ return True, ExecResult.ADVANCE # fall into the THEN block
237
+ if else_ip is not None:
238
+ return True, else_ip + 1 # jump to the ELSE block
239
+ return True, endif_ip + 1 # no ELSE: skip past END IF
240
+
241
+ # Single-line form.
242
+ result: ExecOutcome = ExecResult.ADVANCE
243
+ if cond_true:
223
244
  if then_clause:
224
245
  r = exec_fn(then_clause, loop_stack, sorted_lines, ip, run_vars)
225
246
  if r is not None and r is not ExecResult.ADVANCE:
@@ -230,6 +251,52 @@ class ControlFlowMixin:
230
251
  result = r
231
252
  return True, result
232
253
 
254
+ @staticmethod
255
+ def _is_block_if(line: str) -> bool:
256
+ """True for a block-opening ``IF ... THEN`` (nothing after THEN)."""
257
+ return re.match(r'IF\b.*\bTHEN\s*$', line.strip(), re.IGNORECASE) is not None
258
+
259
+ def _find_if_block(self, sorted_lines: list[int], ip: int) -> tuple[int | None, int]:
260
+ """From a block IF at ip, find (else_ip, endif_ip) handling nesting."""
261
+ depth = 1
262
+ else_ip: int | None = None
263
+ scan = ip + 1
264
+ while scan < len(sorted_lines):
265
+ s = self.program[sorted_lines[scan]].strip()
266
+ su = s.upper()
267
+ if self._is_block_if(s):
268
+ depth += 1
269
+ elif su == 'END IF':
270
+ depth -= 1
271
+ if depth == 0:
272
+ return else_ip, scan
273
+ elif su == 'ELSE' and depth == 1 and else_ip is None:
274
+ else_ip = scan
275
+ scan += 1
276
+ raise RuntimeError(f"IF block at line {sorted_lines[ip]} has no matching END IF")
277
+
278
+ def _cf_else(self, sorted_lines: list[int], ip: int) -> tuple[bool, ExecOutcome]:
279
+ """ELSE reached while running a THEN block: skip past the END IF."""
280
+ depth = 1
281
+ scan = ip + 1
282
+ while scan < len(sorted_lines):
283
+ s = self.program[sorted_lines[scan]].strip()
284
+ su = s.upper()
285
+ if self._is_block_if(s):
286
+ depth += 1
287
+ elif su == 'END IF':
288
+ depth -= 1
289
+ if depth == 0:
290
+ return True, scan + 1
291
+ scan += 1
292
+ return True, ExecResult.ADVANCE
293
+
294
+ def _cf_return(self) -> tuple[bool, ExecOutcome]:
295
+ """RETURN — pop the GOSUB stack, or error if there is nothing to return to."""
296
+ if not self._gosub_stack:
297
+ raise RuntimeError("RETURN WITHOUT GOSUB")
298
+ return True, self._gosub_stack.pop()
299
+
233
300
  # ── Type-based dispatch table ────────────────────────────────────
234
301
  # Maps parsed Stmt types to handler lambdas. Each lambda receives
235
302
  # (self, stmt, parsed, loop_stack, sorted_lines, ip, run_vars,
@@ -240,7 +307,7 @@ class ControlFlowMixin:
240
307
  RemStmt: lambda s, st, p, ls, sl, ip, rv, ef: (True, ExecResult.ADVANCE),
241
308
  MeasureStmt: lambda s, st, p, ls, sl, ip, rv, ef: (True, ExecResult.ADVANCE),
242
309
  EndStmt: lambda s, st, p, ls, sl, ip, rv, ef: (True, ExecResult.END),
243
- ReturnStmt: lambda s, st, p, ls, sl, ip, rv, ef: (_ for _ in ()).throw(RuntimeError("RETURN WITHOUT GOSUB")) if not s._gosub_stack else (True, s._gosub_stack.pop()),
310
+ ReturnStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_return(),
244
311
  # Handlers defined in control_flow.py (parsed is positional)
245
312
  WendStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_wend(rv, ls, sl, ip),
246
313
  LetArrayStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_let_array(st, rv, p),
@@ -260,6 +327,8 @@ class ControlFlowMixin:
260
327
  SelectCaseStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_select_case(st, rv, sl, ip, parsed=p),
261
328
  CaseStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_case(st, sl, ip, parsed=p),
262
329
  EndSelectStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_end_select(st, parsed=p),
330
+ ElseStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_else(sl, ip),
331
+ EndIfStmt: lambda s, st, p, ls, sl, ip, rv, ef: (True, ExecResult.ADVANCE),
263
332
  DoStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_do(st, rv, ls, sl, ip, parsed=p),
264
333
  LoopStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_loop(st, rv, ls, sl, ip, parsed=p),
265
334
  ExitStmt: lambda s, st, p, ls, sl, ip, rv, ef: s._cf_exit(st, ls, sl, ip, parsed=p),
@@ -32,6 +32,7 @@ class DebugMixin:
32
32
  self._breakpoints: set[int] = set()
33
33
  self._watches: list[str] = []
34
34
  self._on_measure_target: int | None = None
35
+ self._on_measure_fired: bool = False
35
36
  self._on_timer_target: int | None = None
36
37
  self._on_timer_interval: float = 0.0
37
38
  self._on_timer_last: float = 0.0
@@ -297,6 +297,7 @@ class DemoMixin:
297
297
 
298
298
  def _demo_stress(self):
299
299
  self.cmd_new()
300
+ self.io.writeln(" (note: STRESS builds a highly entangled 20-qubit state; this can take a while)")
300
301
  n = 20
301
302
  self.num_qubits = n
302
303
  self.program = {
@@ -25,6 +25,21 @@ class DisplayMixin:
25
25
  Requires: TerminalProtocol — uses self.num_qubits, self.arrays.
26
26
  """
27
27
 
28
+ def _rich_print(self, *renderables) -> None:
29
+ """Render rich content to a string and emit it through the IOPort.
30
+
31
+ Routing rich output through self.io (instead of straight to stdout)
32
+ means headless/agent callers with a custom IOPort capture tables and
33
+ statevectors too, and the IOPort's encoding fallback applies uniformly.
34
+ ANSI colour is kept only when stdout is a real terminal.
35
+ """
36
+ import io as _io
37
+ from rich.console import Console
38
+ buf = _io.StringIO()
39
+ force = bool(sys.stdout and sys.stdout.isatty())
40
+ Console(file=buf, highlight=False, force_terminal=force).print(*renderables)
41
+ self.io.write(buf.getvalue())
42
+
28
43
  def print_histogram(self, counts: dict[str, int]) -> None:
29
44
  """Measurement histogram with optional rich-table formatting."""
30
45
  total = sum(counts.values())
@@ -48,11 +63,11 @@ class DisplayMixin:
48
63
 
49
64
  max_count = max(c for _, c in display) if display else 1
50
65
  if len(sorted_counts) > MAX_HISTOGRAM_STATES:
51
- _get_console().print(
66
+ self._rich_print(
52
67
  f"\n [dim]Showing top {MAX_HISTOGRAM_STATES} of "
53
68
  f"{len(sorted_counts)} outcomes:[/dim]\n")
54
69
  else:
55
- _get_console().print()
70
+ self._rich_print()
56
71
 
57
72
  for state, count in display:
58
73
  pct = 100 * count / total
@@ -72,8 +87,8 @@ class DisplayMixin:
72
87
  f"{100*rest_count/total:5.1f}%", "[dim](remaining)[/dim]")
73
88
 
74
89
  try:
75
- _get_console().print(table)
76
- _get_console().print()
90
+ self._rich_print(table)
91
+ self._rich_print()
77
92
  except UnicodeEncodeError:
78
93
  self._print_histogram_plain(display, sorted_counts, total)
79
94
 
@@ -122,7 +137,7 @@ class DisplayMixin:
122
137
  table.add_column("State", justify="right", style="bold")
123
138
  table.add_column("Amplitude", justify="right")
124
139
  table.add_column("P", justify="right")
125
- _get_console().print(f"\n [bold]Statevector ({n} qubits):[/bold]")
140
+ self._rich_print(f"\n [bold]Statevector ({n} qubits):[/bold]")
126
141
  count = 0
127
142
  for i, amp in enumerate(sv):
128
143
  if abs(amp) > AMPLITUDE_THRESHOLD:
@@ -140,8 +155,8 @@ class DisplayMixin:
140
155
  table.add_row("...", "", f"+{remaining} more")
141
156
  break
142
157
  try:
143
- _get_console().print(table)
144
- _get_console().print()
158
+ self._rich_print(table)
159
+ self._rich_print()
145
160
  return
146
161
  except UnicodeEncodeError:
147
162
  pass # fall through to plain-text path below