qubasic 0.12.0__tar.gz → 0.14.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 (71) hide show
  1. {qubasic-0.12.0 → qubasic-0.14.0}/CHANGELOG.md +33 -0
  2. {qubasic-0.12.0/qubasic.egg-info → qubasic-0.14.0}/PKG-INFO +6 -4
  3. {qubasic-0.12.0 → qubasic-0.14.0}/README.md +5 -3
  4. {qubasic-0.12.0 → qubasic-0.14.0}/pyproject.toml +1 -1
  5. {qubasic-0.12.0 → qubasic-0.14.0/qubasic.egg-info}/PKG-INFO +6 -4
  6. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/__init__.py +1 -1
  7. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/analysis.py +5 -2
  8. qubasic-0.14.0/qubasic_core/cli.py +241 -0
  9. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/control_flow.py +9 -2
  10. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/engine_state.py +4 -1
  11. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/executor.py +3 -1
  12. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/expression.py +27 -3
  13. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/patterns.py +3 -3
  14. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/strings.py +4 -1
  15. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/terminal.py +57 -27
  16. {qubasic-0.12.0 → qubasic-0.14.0}/tests/test_features.py +22 -15
  17. {qubasic-0.12.0 → qubasic-0.14.0}/tests/test_qubasic.py +76 -1
  18. qubasic-0.12.0/qubasic_core/cli.py +0 -149
  19. {qubasic-0.12.0 → qubasic-0.14.0}/LICENSE +0 -0
  20. {qubasic-0.12.0 → qubasic-0.14.0}/MANIFEST.in +0 -0
  21. {qubasic-0.12.0 → qubasic-0.14.0}/examples/bell.qb +0 -0
  22. {qubasic-0.12.0 → qubasic-0.14.0}/examples/grover3.qb +0 -0
  23. {qubasic-0.12.0 → qubasic-0.14.0}/examples/locc_teleport.qb +0 -0
  24. {qubasic-0.12.0 → qubasic-0.14.0}/examples/sweep_rx.qb +0 -0
  25. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic.egg-info/SOURCES.txt +0 -0
  26. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic.egg-info/dependency_links.txt +0 -0
  27. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic.egg-info/entry_points.txt +0 -0
  28. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic.egg-info/requires.txt +0 -0
  29. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic.egg-info/top_level.txt +0 -0
  30. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/__main__.py +0 -0
  31. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/algorithms.py +0 -0
  32. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/algos2.py +0 -0
  33. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/backend.py +0 -0
  34. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/benchmarking.py +0 -0
  35. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/bosonic.py +0 -0
  36. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/classic.py +0 -0
  37. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/debug.py +0 -0
  38. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/demos.py +0 -0
  39. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/display.py +0 -0
  40. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/dynamics.py +0 -0
  41. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/engine.py +0 -0
  42. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/errors.py +0 -0
  43. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/exec_context.py +0 -0
  44. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/file_io.py +0 -0
  45. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/gates.py +0 -0
  46. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/help_text.py +0 -0
  47. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/io_protocol.py +0 -0
  48. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/locc.py +0 -0
  49. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/locc_commands.py +0 -0
  50. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/locc_display.py +0 -0
  51. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/locc_engine.py +0 -0
  52. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/locc_execution.py +0 -0
  53. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/memory.py +0 -0
  54. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/mock_backend.py +0 -0
  55. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/noise_mixin.py +0 -0
  56. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/parser.py +0 -0
  57. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/pauliprop.py +0 -0
  58. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/profiler.py +0 -0
  59. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/program_mgmt.py +0 -0
  60. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/protocol.py +0 -0
  61. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/qec.py +0 -0
  62. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/qol.py +0 -0
  63. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/qudits.py +0 -0
  64. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/resources.py +0 -0
  65. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/scope.py +0 -0
  66. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/screen.py +0 -0
  67. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/state_display.py +0 -0
  68. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/statements.py +0 -0
  69. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/subs.py +0 -0
  70. {qubasic-0.12.0 → qubasic-0.14.0}/qubasic_core/sweep.py +0 -0
  71. {qubasic-0.12.0 → qubasic-0.14.0}/setup.cfg +0 -0
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-06-19)
4
+
5
+ Second audit-gap round: a complete agent contract, BASIC truth values, range
6
+ comparisons, louder mid-circuit-bit handling, string-array defaults, and lazy
7
+ density inspection. The quantum engine is unchanged.
8
+
9
+ ### Added
10
+ - `qubasic --spec` now reports the full language surface: a `statements` section (QFT, MEAS, SYNDROME, EVOLVE, QADD, CTRL/INV, the control-flow keywords, ...) alongside commands, each with a `signature` field, plus `true_value: -1`.
11
+ - `DIM s$(n)` declares a string array; unread elements default to `""` instead of `0.0`.
12
+
13
+ ### Changed
14
+ - Comparisons return BASIC truth values: `-1` for true, `0` for false (so `LET t = (a > b)` is `-1`).
15
+ - Comparison chaining is Python-style, so `0 <= x <= 10` reads as `(0 <= x) AND (x <= 10)`.
16
+ - Reading a mid-circuit measurement bit (`MEAS`/`SYNDROME -> var`) in a classical expression now raises a clear error instead of silently using the placeholder 0; `IF <bit>` feedforward is unaffected.
17
+ - `DENSITY` solves the density matrix lazily, on demand, instead of on every measured `SET_DENSITY` run.
18
+
19
+ ## 0.13.0 (2026-06-19)
20
+
21
+ Closes the remaining audit gaps in the classic-BASIC layer (conventions,
22
+ inspection, and an agent contract). The quantum engine is unchanged.
23
+
24
+ ### Added
25
+ - `qubasic --spec` emits a machine-readable JSON contract (version, commands with arg flag and one-line help, gates, functions, constants, bit order) so an agent can load the exact surface of the installed version.
26
+ - `REDIM PRESERVE name(n)` keeps existing elements; plain `REDIM` clears to zeros (QBASIC semantics).
27
+ - `DENSITY` shows the density matrix after a measured `SET_DENSITY` run, not only a no-MEASURE one.
28
+
29
+ ### Changed
30
+ - `DIM` uses inclusive sizing: `DIM a(n)` spans indices base..n (the declared top index is valid), matching QBASIC instead of the previous C-style element count.
31
+ - `round()` rounds half away from zero (`round(2.5)` = 3), not Python's banker's rounding.
32
+ - `STR$(n)` reserves a leading space for non-negative numbers (`STR$(42)` = " 42").
33
+ - A chained comparison such as `a < b < c` now raises a clear "ambiguous" error instead of silently using Python chaining; write `(a < b) AND (b < c)`.
34
+ - `PRINT`ing a mid-circuit measurement bit (`MEAS`/`SYNDROME -> var`) shows "mid-circuit bit, resolved per shot" rather than the placeholder 0.
35
+
3
36
  ## 0.12.0 (2026-06-19)
4
37
 
5
38
  Correctness and robustness pass on the classic-BASIC layer, from an extended
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -78,6 +78,7 @@ python -m qubasic_core Same, without installing
78
78
  qubasic script.qb Run a script file
79
79
  qubasic --quiet script Suppress banner, output results only
80
80
  qubasic --json script Machine-readable JSON output
81
+ qubasic --spec Print a JSON contract (commands, gates, functions)
81
82
  qubasic --help Show CLI help
82
83
  ```
83
84
 
@@ -244,7 +245,7 @@ Functions and keywords are case-insensitive (`SQRT` and `sqrt` both work).
244
245
 
245
246
  ### Operators
246
247
  Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**`
247
- Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=`
248
+ Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=` (yield -1 for true, 0 for false; chain Python-style, so `0 <= x <= 10` works)
248
249
  Logical: `AND`, `OR`, `NOT`, `XOR`
249
250
  Bitwise: `AND`, `OR`, `XOR` on integers (`6 AND 3` = 2); `NOT` is logical
250
251
  Hex/binary literals: `&HFF`, `&B10110`
@@ -256,12 +257,13 @@ DIM data(10) 1D array
256
257
  DIM matrix(3, 3) Multi-dimensional (flat storage)
257
258
  LET data(0) = PI
258
259
  LET names$(0) = "alice" String array (name$ elements hold strings)
259
- REDIM data(20) Resize (preserves existing data)
260
+ REDIM data(20) Resize, clearing to zeros
261
+ REDIM PRESERVE data(20) Resize, keeping existing data
260
262
  ERASE data Delete array
261
263
  OPTION BASE 1 Set array index base
262
264
  ```
263
265
 
264
- A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
266
+ `DIM a(n)` is inclusive: it spans indices base..n, so the declared top index is valid. A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
265
267
 
266
268
  ## Control flow
267
269
 
@@ -45,6 +45,7 @@ python -m qubasic_core Same, without installing
45
45
  qubasic script.qb Run a script file
46
46
  qubasic --quiet script Suppress banner, output results only
47
47
  qubasic --json script Machine-readable JSON output
48
+ qubasic --spec Print a JSON contract (commands, gates, functions)
48
49
  qubasic --help Show CLI help
49
50
  ```
50
51
 
@@ -211,7 +212,7 @@ Functions and keywords are case-insensitive (`SQRT` and `sqrt` both work).
211
212
 
212
213
  ### Operators
213
214
  Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**`
214
- Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=`
215
+ Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=` (yield -1 for true, 0 for false; chain Python-style, so `0 <= x <= 10` works)
215
216
  Logical: `AND`, `OR`, `NOT`, `XOR`
216
217
  Bitwise: `AND`, `OR`, `XOR` on integers (`6 AND 3` = 2); `NOT` is logical
217
218
  Hex/binary literals: `&HFF`, `&B10110`
@@ -223,12 +224,13 @@ DIM data(10) 1D array
223
224
  DIM matrix(3, 3) Multi-dimensional (flat storage)
224
225
  LET data(0) = PI
225
226
  LET names$(0) = "alice" String array (name$ elements hold strings)
226
- REDIM data(20) Resize (preserves existing data)
227
+ REDIM data(20) Resize, clearing to zeros
228
+ REDIM PRESERVE data(20) Resize, keeping existing data
227
229
  ERASE data Delete array
228
230
  OPTION BASE 1 Set array index base
229
231
  ```
230
232
 
231
- A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
233
+ `DIM a(n)` is inclusive: it spans indices base..n, so the declared top index is valid. A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
232
234
 
233
235
  ## Control flow
234
236
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubasic"
7
- version = "0.12.0"
7
+ version = "0.14.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.12.0
3
+ Version: 0.14.0
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -78,6 +78,7 @@ python -m qubasic_core Same, without installing
78
78
  qubasic script.qb Run a script file
79
79
  qubasic --quiet script Suppress banner, output results only
80
80
  qubasic --json script Machine-readable JSON output
81
+ qubasic --spec Print a JSON contract (commands, gates, functions)
81
82
  qubasic --help Show CLI help
82
83
  ```
83
84
 
@@ -244,7 +245,7 @@ Functions and keywords are case-insensitive (`SQRT` and `sqrt` both work).
244
245
 
245
246
  ### Operators
246
247
  Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**`
247
- Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=`
248
+ Comparison: `==`, `!=`, `<>`, `<`, `>`, `<=`, `>=` (yield -1 for true, 0 for false; chain Python-style, so `0 <= x <= 10` works)
248
249
  Logical: `AND`, `OR`, `NOT`, `XOR`
249
250
  Bitwise: `AND`, `OR`, `XOR` on integers (`6 AND 3` = 2); `NOT` is logical
250
251
  Hex/binary literals: `&HFF`, `&B10110`
@@ -256,12 +257,13 @@ DIM data(10) 1D array
256
257
  DIM matrix(3, 3) Multi-dimensional (flat storage)
257
258
  LET data(0) = PI
258
259
  LET names$(0) = "alice" String array (name$ elements hold strings)
259
- REDIM data(20) Resize (preserves existing data)
260
+ REDIM data(20) Resize, clearing to zeros
261
+ REDIM PRESERVE data(20) Resize, keeping existing data
260
262
  ERASE data Delete array
261
263
  OPTION BASE 1 Set array index base
262
264
  ```
263
265
 
264
- A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
266
+ `DIM a(n)` is inclusive: it spans indices base..n, so the declared top index is valid. A `DIM`med array enforces its declared bounds on write; an undimensioned array grows on first assignment.
265
267
 
266
268
  ## Control flow
267
269
 
@@ -28,7 +28,7 @@ __all__ = [
28
28
  'GATE_TABLE', 'GATE_ALIASES',
29
29
  ]
30
30
 
31
- __version__ = '0.12.0'
31
+ __version__ = '0.14.0'
32
32
 
33
33
  def __getattr__(name):
34
34
  """Lazy import heavy modules on first access."""
@@ -109,9 +109,12 @@ class AnalysisMixin:
109
109
 
110
110
  def cmd_density(self, rest: str = '') -> None:
111
111
  """Show density matrix (DENSITY [reg]); summarizes for large systems."""
112
- # A mixed state set via SET_DENSITY has a stored density matrix and no
113
- # statevector; display it directly.
112
+ # A mixed state set via SET_DENSITY has no statevector; solve its
113
+ # density matrix on demand (cached) from the captured circuit.
114
114
  dm = getattr(self, '_last_density', None)
115
+ if dm is None and getattr(self, '_last_density_qc', None) is not None:
116
+ dm = self._density_from_qc(self._last_density_qc)
117
+ self._last_density = dm
115
118
  if dm is not None and not rest.strip():
116
119
  rho = np.ascontiguousarray(dm)
117
120
  dim = rho.shape[0]
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ QUBASIC — command-line entry point.
4
+
5
+ Usage:
6
+ qubasic Interactive REPL (installed console script)
7
+ python -m qubasic_core Same, run as a module
8
+ qubasic script.qb Run a script file
9
+ """
10
+
11
+ import sys
12
+ import os
13
+
14
+ # Force UTF-8 output on Windows
15
+ if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
16
+ try:
17
+ sys.stdout.reconfigure(encoding='utf-8')
18
+ except Exception:
19
+ pass
20
+ if sys.stderr and hasattr(sys.stderr, 'reconfigure'):
21
+ try:
22
+ sys.stderr.reconfigure(encoding='utf-8')
23
+ except Exception:
24
+ pass
25
+
26
+ from qubasic_core.terminal import QBasicTerminal
27
+ from qubasic_core.program_mgmt import ProgramMgmtMixin
28
+
29
+ # Statement-level operations that are parsed inline rather than dispatched as
30
+ # REPL commands or gates, so they are absent from the command tables and the
31
+ # gate table. Listed in --spec (name, signature, description) for agents.
32
+ _SPEC_STATEMENTS = [
33
+ ('MEAS', 'MEAS <qubit> -> <bit>', 'mid-circuit measurement into a classical bit (drives IF feedforward)'),
34
+ ('MEASURE', 'MEASURE [qubit list]', 'measure all qubits, or a subset, into the result histogram'),
35
+ ('MEASURE_X', 'MEASURE_X <qubit>', 'measure in the X basis (result in mx_<q>)'),
36
+ ('MEASURE_Y', 'MEASURE_Y <qubit>', 'measure in the Y basis (result in my_<q>)'),
37
+ ('MEASURE_Z', 'MEASURE_Z <qubit>', 'measure in the Z basis (result in mz_<q>)'),
38
+ ('SYNDROME', 'SYNDROME <paulis> <qubits> -> <var>', 'non-destructive stabilizer measurement via an ancilla'),
39
+ ('RESET', 'RESET <qubit>', 'reset a qubit to |0>'),
40
+ ('BARRIER', 'BARRIER', 'optimization barrier'),
41
+ ('QFT', 'QFT <lo>-<hi>', 'quantum Fourier transform over a qubit range'),
42
+ ('IQFT', 'IQFT <lo>-<hi>', 'inverse quantum Fourier transform'),
43
+ ('DIFFUSE', 'DIFFUSE <lo>-<hi>', 'Grover diffusion operator'),
44
+ ('MCX', 'MCX <ctrl,...>, <target>', 'multi-controlled X'),
45
+ ('MCZ', 'MCZ <ctrl,...>', 'multi-controlled Z'),
46
+ ('MCP', 'MCP <theta>, <ctrl,...>, <target>', 'multi-controlled phase'),
47
+ ('QADD', 'QADD <a-range>, <b-range>', 'in-place register add A += B (mod 2^n)'),
48
+ ('QADDC', 'QADDC <k>, <range>', 'in-place constant add A += k (mod 2^n)'),
49
+ ('QPE', 'QPE <range> <target> <UGATE>', 'quantum phase estimation of a unitary'),
50
+ ('AMPLIFY', 'AMPLIFY <marked>', 'one amplitude-amplification (Grover) step'),
51
+ ('GRAPHSTATE', 'GRAPHSTATE <a-b, b-c, ...>', 'prepare a graph/cluster state'),
52
+ ('FEATUREMAP', 'FEATUREMAP <x0> <x1> ...', 'ZZ feature-map data encoding'),
53
+ ('EVOLVE', 'EVOLVE <H>, <time>, <steps>', 'Trotterized Hamiltonian time evolution'),
54
+ ('APPLYCHANNEL', 'APPLYCHANNEL <name> <qubit>', 'apply a defined Kraus channel'),
55
+ ('SAVE_EXPECT', 'SAVE_EXPECT <obs> <qubits> -> <var>', 'record an expectation value into a variable after RUN'),
56
+ ('SAVE_PROBS', 'SAVE_PROBS <qubits> -> <array>', 'record a probability snapshot into an array after RUN'),
57
+ ('SAVE_AMPS', 'SAVE_AMPS <lo>,<hi> -> <array>', 'record amplitudes into an array after RUN'),
58
+ ('CTRL', 'CTRL <gate> <ctrl>, <target>', 'controlled version of any gate'),
59
+ ('INV', 'INV <gate> <args>', 'inverse/dagger of a gate'),
60
+ ('UNITARY', 'UNITARY <NAME> = [[...]]', 'define a custom gate from a unitary matrix'),
61
+ ('GOTO', 'GOTO <line>', 'jump to a line number'),
62
+ ('GOSUB', 'GOSUB <line>', 'call a line block; RETURN resumes'),
63
+ ('FOR', 'FOR <v> = <a> TO <b> [STEP <s>]', 'counted loop, closed by NEXT'),
64
+ ('WHILE', 'WHILE <cond>', 'pre-test loop, closed by WEND'),
65
+ ('DO', 'DO [WHILE|UNTIL <cond>]', 'loop, closed by LOOP [WHILE|UNTIL]'),
66
+ ('IF', 'IF <cond> THEN <stmt> [ELSE <stmt>]', 'conditional (single-line, or block ending in END IF)'),
67
+ ('SELECT', 'SELECT CASE <expr>', 'multi-way branch (CASE / CASE ELSE / END SELECT)'),
68
+ ('DATA', 'DATA <v1>, <v2>, ...', 'inline data consumed by READ'),
69
+ ('READ', 'READ <var>, ...', 'read the next DATA values'),
70
+ ('DEF', 'DEF <NAME>[(p)] = <gates>', 'define a gate-sequence subroutine (also DEF FN, DEF BEGIN)'),
71
+ ('SUB', 'SUB <name>(<args>)', 'structured subroutine (END SUB; LOCAL/STATIC/SHARED)'),
72
+ ('FUNCTION', 'FUNCTION <name>(<args>)', 'function returning a value (END FUNCTION)'),
73
+ ('DIM', 'DIM <name>(<size>[,...])', 'declare an array (name$ for strings; inclusive sizing)'),
74
+ ('REDIM', 'REDIM [PRESERVE] <name>(<size>)', 'resize an array; PRESERVE keeps existing data'),
75
+ ]
76
+
77
+
78
+ def run_script(path: str, terminal: 'QBasicTerminal') -> None:
79
+ """Run a .qb script file. Supports multi-line DEF blocks.
80
+
81
+ After loading all lines, auto-runs the program if it contains
82
+ numbered lines with a MEASURE statement.
83
+ """
84
+ with open(path, 'r') as f:
85
+ lines = [l.rstrip('\n\r') for l in f.readlines()]
86
+ ProgramMgmtMixin._load_lines_with_defs(
87
+ lines, lambda line: terminal.process(line, track_undo=False))
88
+
89
+ # Auto-run if the program has a reachable MEASURE (incl. in subs / IF
90
+ # clauses), unless the script already issued an explicit RUN (which would
91
+ # otherwise execute and print the program twice).
92
+ explicit_run = any(l.strip().upper() == 'RUN' for l in lines)
93
+ if (terminal.program and not explicit_run
94
+ and terminal._program_has_measure(sorted(terminal.program))):
95
+ terminal.cmd_run()
96
+
97
+
98
+ def main():
99
+ import json as _json
100
+ os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
101
+
102
+ from qubasic_core import __version__
103
+
104
+ args = sys.argv[1:]
105
+ quiet = '--quiet' in args or '-q' in args
106
+ json_mode = '--json' in args
107
+ agent_mode = '--agent' in args
108
+ spec_mode = '--spec' in args
109
+ seed_val = None
110
+ for flag in ('--quiet', '-q', '--json', '--agent', '--spec'):
111
+ args = [a for a in args if a != flag]
112
+ # Parse --seed N
113
+ filtered = []
114
+ i = 0
115
+ while i < len(args):
116
+ if args[i] == '--seed' and i + 1 < len(args):
117
+ seed_val = int(args[i + 1])
118
+ i += 2
119
+ else:
120
+ filtered.append(args[i])
121
+ i += 1
122
+ args = filtered
123
+
124
+ if any(a in ('-v', '--version') for a in args):
125
+ print(f"QUBASIC {__version__}")
126
+ sys.exit(0)
127
+
128
+ if any(a in ('-h', '--help') for a in args):
129
+ print(f"QUBASIC {__version__} — Quantum BASIC Interactive Terminal")
130
+ print()
131
+ print("Usage:")
132
+ print(" qubasic Interactive REPL")
133
+ print(" qubasic script.qb Run a script file")
134
+ print(" qubasic --quiet script Suppress banner and progress (also -q)")
135
+ print(" qubasic --json script Output results as JSON")
136
+ print(" qubasic --agent script Confine file writes to the working dir")
137
+ print(" qubasic --seed N script Set random seed for reproducibility")
138
+ print(" qubasic --version Show version (also -v)")
139
+ print(" qubasic --help Show this help (also -h)")
140
+ print(" python -m qubasic_core Run without the installed console script")
141
+ print()
142
+ print("Type HELP inside the REPL for full command reference.")
143
+ sys.exit(0)
144
+
145
+ if spec_mode:
146
+ # Machine-readable contract for agents: commands, gates, functions,
147
+ # constants, and conventions for the installed version.
148
+ from qubasic_core.engine import GATE_TABLE
149
+ from qubasic_core.expression import ExpressionMixin
150
+
151
+ def _doc_parts(mname):
152
+ doc = (getattr(getattr(QBasicTerminal, mname, None), '__doc__', '') or '').strip()
153
+ line = doc.split('\n')[0].strip()
154
+ # Docstrings format the first line as "SIGNATURE — description".
155
+ for sep in ('—', ' - '):
156
+ if sep in line:
157
+ sig, _, hlp = line.partition(sep)
158
+ return sig.strip(), hlp.strip()
159
+ return '', line
160
+
161
+ cmds = []
162
+ for tbl, takes in ((QBasicTerminal._CMD_WITH_ARG, True),
163
+ (QBasicTerminal._CMD_NO_ARG, False)):
164
+ for cname, mname in tbl.items():
165
+ sig, hlp = _doc_parts(mname)
166
+ cmds.append({'name': cname, 'takes_arg': takes,
167
+ 'signature': sig or cname, 'help': hlp})
168
+ statements = [{'name': n, 'signature': s, 'help': h}
169
+ for (n, s, h) in _SPEC_STATEMENTS]
170
+ spec = {
171
+ 'name': 'qubasic',
172
+ 'version': __version__,
173
+ 'bit_order': 'little-endian (qubit 0 = rightmost bit)',
174
+ 'true_value': -1,
175
+ 'commands': sorted(cmds, key=lambda c: c['name']),
176
+ 'statements': sorted(statements, key=lambda s: s['name']),
177
+ 'gates': sorted(GATE_TABLE.keys()),
178
+ 'functions': sorted(
179
+ set(ExpressionMixin._SAFE_FUNCS)
180
+ | {'RND', 'TIMER', 'POS', 'PEEK', 'USR', 'EOF', 'FRE',
181
+ 'LEFT$', 'RIGHT$', 'MID$', 'CHR$', 'STR$', 'HEX$', 'BIN$',
182
+ 'ASC', 'VAL', 'INSTR', 'LEN'}),
183
+ 'constants': sorted(ExpressionMixin._SAFE_CONSTS.keys()),
184
+ }
185
+ print(_json.dumps(spec, indent=2))
186
+ sys.exit(0)
187
+
188
+ term = QBasicTerminal()
189
+ # JSON mode implies agent use, so confine file writes to the working dir.
190
+ term.agent_mode = agent_mode or json_mode
191
+ if seed_val is not None:
192
+ import numpy as _np
193
+ term._seed = seed_val
194
+ _np.random.seed(seed_val)
195
+
196
+ if args:
197
+ path = args[0]
198
+ if not os.path.isfile(path):
199
+ if json_mode:
200
+ print(_json.dumps({'error': f'FILE NOT FOUND: {path}'}, indent=2))
201
+ else:
202
+ print(f"?FILE NOT FOUND: {path}")
203
+ sys.exit(1)
204
+ if quiet or json_mode:
205
+ import io
206
+ buf = io.StringIO()
207
+ old = sys.stdout
208
+ sys.stdout = buf
209
+ err = None
210
+ try:
211
+ run_script(path, term)
212
+ except Exception as e: # surface as structured error in JSON mode
213
+ err = str(e)
214
+ finally:
215
+ sys.stdout = old
216
+ if json_mode:
217
+ if err is not None:
218
+ print(_json.dumps({'error': err}, indent=2))
219
+ sys.exit(1)
220
+ print(_json.dumps(term.result(), indent=2))
221
+ else:
222
+ # --quiet (no --json): the banner was never emitted into the
223
+ # buffer (only the non-captured path calls print_banner), so the
224
+ # captured text is exactly the results. Print them, matching the
225
+ # documented "suppress banner, output results only" behavior.
226
+ print(buf.getvalue(), end='')
227
+ if err is not None:
228
+ print(f"?ERROR: {err}")
229
+ sys.exit(1)
230
+ else:
231
+ term.print_banner()
232
+ run_script(path, term)
233
+ # Exit 0 on success; 1 if a measured program produced no counts.
234
+ expects_measure = bool(term.program) and term._program_has_measure(sorted(term.program))
235
+ sys.exit(0 if term.last_counts is not None or not expects_measure else 1)
236
+ else:
237
+ term.repl()
238
+
239
+
240
+ if __name__ == '__main__':
241
+ main()
@@ -102,9 +102,10 @@ class ControlFlowMixin:
102
102
  if idx < 0:
103
103
  raise RuntimeError(f"ARRAY INDEX OUT OF RANGE: {name}({idx + base})")
104
104
  dimmed = getattr(self, '_dimmed_arrays', set())
105
+ fill = '' if name.endswith('$') else 0.0 # string arrays default to ""
105
106
  if name not in self.arrays:
106
107
  # Implicit array: created (and allowed to grow) on first assignment.
107
- self.arrays[name] = [0.0] * (idx + 1)
108
+ self.arrays[name] = [fill] * (idx + 1)
108
109
  elif idx >= len(self.arrays[name]):
109
110
  if name in dimmed:
110
111
  # Explicitly DIMmed: writes are bounds-checked like reads,
@@ -113,7 +114,7 @@ class ControlFlowMixin:
113
114
  f"ARRAY INDEX OUT OF RANGE: {name}({idx + base}), "
114
115
  f"size {len(self.arrays[name])}")
115
116
  while idx >= len(self.arrays[name]):
116
- self.arrays[name].append(0.0)
117
+ self.arrays[name].append(fill)
117
118
  self.arrays[name][idx] = val
118
119
  return True, ExecResult.ADVANCE
119
120
 
@@ -184,6 +185,12 @@ class ControlFlowMixin:
184
185
  # Quoted literal: emit verbatim (no substitution, no SPC/TAB).
185
186
  if (item[0] == '"' and item[-1] == '"') or (item[0] == "'" and item[-1] == "'"):
186
187
  return item[1:-1]
188
+ # A mid-circuit measurement bit (MEAS/SYNDROME -> var) has no single
189
+ # value in standard mode; it is resolved per shot inside the if_test.
190
+ # Show that instead of the placeholder 0.
191
+ cb = getattr(self, '_classical_bits', None)
192
+ if cb and item in cb:
193
+ return f"<{item}: mid-circuit bit, resolved per shot>"
187
194
  text = self._substitute_vars(item, run_vars)
188
195
 
189
196
  def _spaces(m):
@@ -57,8 +57,10 @@ class Engine:
57
57
  # Arrays declared with DIM/REDIM enforce their bounds on write;
58
58
  # arrays created implicitly by first assignment keep auto-growing.
59
59
  self._dimmed_arrays: set[str] = set()
60
- # Density matrix captured by a no-MEASURE run with SET_DENSITY.
60
+ # Density matrix (and the measure-free circuit to solve it lazily)
61
+ # for a run with SET_DENSITY, computed on demand by DENSITY.
61
62
  self._last_density: Any = None
63
+ self._last_density_qc: Any = None
62
64
 
63
65
  # Subroutines and registers
64
66
  self.subroutines: dict[str, Any] = {}
@@ -119,6 +121,7 @@ class Engine:
119
121
  self._array_dims.clear()
120
122
  self._dimmed_arrays.clear()
121
123
  self._last_density = None
124
+ self._last_density_qc = None
122
125
  self.last_counts = None
123
126
  self.last_sv = None
124
127
  self.last_circuit = None
@@ -113,8 +113,10 @@ class ExecutorMixin:
113
113
  self._classical_bits = {}
114
114
  # Partial-measurement subset (None = measure all at the end).
115
115
  self._measure_subset = None
116
- # Density matrix captured by a no-MEASURE run (None unless set).
116
+ # Density matrix (and circuit) captured by a SET_DENSITY run; the
117
+ # matrix is solved lazily by DENSITY from the stored circuit.
117
118
  self._last_density = None
119
+ self._last_density_qc = None
118
120
  # Apply any qubit state preparation requested via POKE to $0100.
119
121
  if getattr(self, '_poke_state_prep', None):
120
122
  self._emit_poke_state_prep(qc)
@@ -124,6 +124,18 @@ def _basic_xor(a: Any, b: Any) -> int:
124
124
  return _as_int(a) ^ _as_int(b)
125
125
 
126
126
 
127
+ def _basic_round(x: Any, ndigits: Any = None) -> float:
128
+ """Round half away from zero (BASIC convention), not Python's half-to-even.
129
+
130
+ round(2.5) == 3, round(-2.5) == -3, round(2.345, 2) == 2.35.
131
+ """
132
+ nd = int(ndigits) if ndigits is not None else 0
133
+ f = 10 ** nd
134
+ y = float(x) * f
135
+ r = math.floor(y + 0.5) if y >= 0 else math.ceil(y - 0.5)
136
+ return (r / f) if nd else float(r)
137
+
138
+
127
139
  class ExpressionMixin:
128
140
  """AST-based safe expression evaluation. No eval().
129
141
 
@@ -138,7 +150,7 @@ class ExpressionMixin:
138
150
  # INT floors toward negative infinity, as in QBASIC (INT(-3.2) = -4).
139
151
  # FIX truncates toward zero (FIX(-3.2) = -3) for the other convention.
140
152
  'abs': abs, 'int': math.floor, 'fix': math.trunc, 'float': float,
141
- 'min': min, 'max': max, 'round': round, 'len': len,
153
+ 'min': min, 'max': max, 'round': _basic_round, 'len': len,
142
154
  'ceil': math.ceil, 'floor': math.floor,
143
155
  }
144
156
  _SAFE_CONSTS = {
@@ -178,6 +190,15 @@ class ExpressionMixin:
178
190
  return node.value
179
191
  raise ValueError(f"UNSUPPORTED CONSTANT: {node.value!r}")
180
192
  if isinstance(node, ast.Name):
193
+ # A mid-circuit measurement bit has no classical value (it is
194
+ # resolved per shot); reading it outside an IF feedforward is an
195
+ # error rather than a silent placeholder 0.
196
+ cb = getattr(self, '_classical_bits', None)
197
+ if cb and node.id in cb:
198
+ raise ValueError(
199
+ f"'{node.id}' is a mid-circuit measurement bit; its value is "
200
+ f"per-shot, so it can't be read in a classical expression "
201
+ f"(use IF {node.id} for feedforward, or LOCC mode for a live value)")
181
202
  if node.id in ns:
182
203
  return ns[node.id]
183
204
  raise ValueError(f"UNDEFINED: {node.id}")
@@ -203,6 +224,9 @@ class ExpressionMixin:
203
224
  result = op(result, self._ast_eval(val, ns))
204
225
  return result
205
226
  if isinstance(node, ast.Compare):
227
+ # Python-style chaining (a < b < c means (a<b) and (b<c)) so range
228
+ # checks like 0 <= x <= 10 work as written; results use BASIC truth
229
+ # values (-1 for true, 0 for false).
206
230
  left = self._ast_eval(node.left, ns)
207
231
  for op_node, comparator in zip(node.ops, node.comparators):
208
232
  op = self._AST_OPS.get(type(op_node))
@@ -210,9 +234,9 @@ class ExpressionMixin:
210
234
  raise ValueError(f"UNSUPPORTED OP: {type(op_node).__name__}")
211
235
  right = self._ast_eval(comparator, ns)
212
236
  if not op(left, right):
213
- return False
237
+ return 0
214
238
  left = right
215
- return True
239
+ return -1
216
240
  if isinstance(node, ast.Call):
217
241
  if not isinstance(node.func, ast.Name):
218
242
  raise ValueError("ONLY SIMPLE FUNCTION CALLS ALLOWED")
@@ -17,8 +17,8 @@ RE_SHARE = re.compile(r'SHARE\s+([A-Z])\s+(\d+)\s*,?\s*([A-Z])\s+(\d+)', re.IGNO
17
17
  RE_MEAS = re.compile(r'MEAS\s+(\S+)\s*->\s*(\w+)', re.IGNORECASE)
18
18
  RE_RESET = re.compile(r'RESET\s+(\S+)', re.IGNORECASE)
19
19
  RE_UNITARY = re.compile(r'UNITARY\s+(\w+)\s*=\s*(\[.+\])', re.IGNORECASE)
20
- RE_DIM = re.compile(r'DIM\s+(\w+)\((\d+)\)', re.IGNORECASE)
21
- RE_REDIM = re.compile(r'REDIM\s+(\w+)\((\d+)\)', re.IGNORECASE)
20
+ RE_DIM = re.compile(r'DIM\s+(\w+\$?)\((\d+)\)', re.IGNORECASE)
21
+ RE_REDIM = re.compile(r'REDIM\s+(PRESERVE\s+)?(\w+)\((\d+)\)', re.IGNORECASE)
22
22
  RE_ERASE = re.compile(r'ERASE\s+(\w+)', re.IGNORECASE)
23
23
  RE_GET = re.compile(r'GET\s+(\w+\$?)', re.IGNORECASE)
24
24
  RE_INPUT = re.compile(r'INPUT\s+(?:"([^"]*)"\s*,\s*)?(\w+)', re.IGNORECASE)
@@ -100,7 +100,7 @@ RE_SCREEN = re.compile(r'SCREEN\s+(\d+)', re.IGNORECASE)
100
100
  RE_LPRINT = re.compile(r'LPRINT\s+(.*)', re.IGNORECASE)
101
101
  RE_ON_MEASURE = re.compile(r'ON\s+MEASURE\s+GOSUB\s+(\d+)', re.IGNORECASE)
102
102
  RE_ON_TIMER = re.compile(r'ON\s+TIMER\s*\((\d+)\)\s+GOSUB\s+(\d+)', re.IGNORECASE)
103
- RE_DIM_MULTI = re.compile(r'DIM\s+(\w+)\((\d+(?:\s*,\s*\d+)*)\)', re.IGNORECASE)
103
+ RE_DIM_MULTI = re.compile(r'DIM\s+(\w+\$?)\((\d+(?:\s*,\s*\d+)*)\)', re.IGNORECASE)
104
104
  RE_LET_STR = re.compile(r'LET\s+(\w+\$)\s*=\s*(.*)', re.IGNORECASE)
105
105
  # Implicit LET: an assignment written without the LET keyword (x = 5, s$ = "hi",
106
106
  # a(1) = 5, p.x = 3). Anchored lvalue followed by a single '=' that is not part
@@ -29,8 +29,11 @@ def _chr_fn(n: float) -> str:
29
29
  return chr(int(n))
30
30
 
31
31
  def _str_fn(n: float) -> str:
32
+ # BASIC reserves a leading space for the sign of a non-negative number,
33
+ # so STR$(42) is " 42" and STR$(-5) is "-5".
32
34
  v = float(n)
33
- return str(int(v)) if v == int(v) else str(v)
35
+ s = str(int(v)) if v == int(v) else str(v)
36
+ return s if v < 0 else ' ' + s
34
37
 
35
38
  def _val_fn(s: str) -> float:
36
39
  try:
@@ -1244,6 +1244,28 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1244
1244
  # it skip extraction; STATE/BLOCH then report no state.
1245
1245
  _SV_EXTRACT_MAX_QUBITS = 24
1246
1246
 
1247
+ def _density_from_qc(self, qc_sv):
1248
+ """Solve the density matrix of a measure-free circuit, for DENSITY.
1249
+
1250
+ Computed on demand (not on every run) from the circuit captured when a
1251
+ SET_DENSITY state was prepared.
1252
+ """
1253
+ try:
1254
+ q2 = qc_sv.copy()
1255
+ q2.save_density_matrix()
1256
+ dm_backend = self._make_backend('density_matrix', include_noise=True)
1257
+ _kw = {}
1258
+ if self._seed is not None:
1259
+ _kw['seed_simulator'] = self._seed
1260
+ res = dm_backend.run(
1261
+ transpile(q2, dm_backend, optimization_level=self._transpile_opt_level),
1262
+ **_kw).result()
1263
+ data = res.data(0)
1264
+ dm = data.get('density_matrix') if hasattr(data, 'get') else None
1265
+ return np.array(dm) if dm is not None else None
1266
+ except Exception:
1267
+ return None
1268
+
1247
1269
  def _extract_statevector(self, qc_sv) -> None:
1248
1270
  """Run the measurement-free circuit copy to get last_sv.
1249
1271
 
@@ -1255,7 +1277,13 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1255
1277
  2^n statevector that cannot be displayed anyway.
1256
1278
  """
1257
1279
  if getattr(self, '_pending_set_density', None) is not None:
1258
- self.last_sv = None # mixed state: no pure statevector to extract
1280
+ # Mixed state: no pure statevector. Defer the density-matrix solve
1281
+ # to DENSITY (lazy) so a measured run does not pay for it unused;
1282
+ # keep the measure-free circuit to solve from.
1283
+ self.last_sv = None
1284
+ self._last_density = None
1285
+ self._last_density_qc = (qc_sv if self.num_qubits <= self._SV_EXTRACT_MAX_QUBITS
1286
+ else None)
1259
1287
  return
1260
1288
  if self.num_qubits > self._SV_EXTRACT_MAX_QUBITS:
1261
1289
  self.last_sv = None
@@ -1300,20 +1328,12 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1300
1328
  """Execute the no-MEASURE path: statevector (or density matrix), no shots."""
1301
1329
  too_large = self.num_qubits > self._SV_EXTRACT_MAX_QUBITS
1302
1330
  if getattr(self, '_pending_set_density', None) is not None and not too_large:
1303
- # A mixed state has no statevector, so capture the density matrix
1304
- # for DENSITY instead. Saving a statevector here used to raise an
1305
- # untranslatable-circuit error from Aer.
1331
+ # A mixed state has no statevector. Defer the density solve to
1332
+ # DENSITY (lazy); keep the measure-free circuit. Saving a statevector
1333
+ # here used to raise an untranslatable-circuit error from Aer.
1306
1334
  self.last_sv = None
1307
- try:
1308
- qc_sv.save_density_matrix()
1309
- dm_backend = self._make_backend('density_matrix', include_noise=True)
1310
- dm_result = dm_backend.run(
1311
- transpile(qc_sv, dm_backend, optimization_level=self._transpile_opt_level)).result()
1312
- data = dm_result.data(0)
1313
- dm = data.get('density_matrix') if hasattr(data, 'get') else None
1314
- self._last_density = np.array(dm) if dm is not None else None
1315
- except Exception:
1316
- self._last_density = None
1335
+ self._last_density = None
1336
+ self._last_density_qc = qc_sv
1317
1337
  elif too_large:
1318
1338
  self.last_sv = None
1319
1339
  else:
@@ -1943,13 +1963,17 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1943
1963
  if not m:
1944
1964
  return False
1945
1965
  name = m.group(1)
1966
+ base = getattr(self, '_option_base', 0)
1946
1967
  dims = [int(d.strip()) for d in m.group(2).split(',')]
1968
+ # Inclusive sizing (QBASIC): DIM a(n) spans indices base..n, so the
1969
+ # declared top index n is valid (n - base + 1 slots per dimension).
1970
+ sizes = [max(0, d - base + 1) for d in dims]
1947
1971
  total = 1
1948
- for d in dims:
1949
- total *= d
1950
- self.arrays[name] = [0.0] * total
1951
- if len(dims) > 1:
1952
- self._array_dims[name] = dims
1972
+ for s in sizes:
1973
+ total *= s
1974
+ self.arrays[name] = [('' if name.endswith('$') else 0.0)] * total
1975
+ if len(sizes) > 1:
1976
+ self._array_dims[name] = sizes
1953
1977
  self._dimmed_arrays.add(name)
1954
1978
  return True
1955
1979
 
@@ -1975,20 +1999,26 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1975
1999
  return True
1976
2000
 
1977
2001
  def _try_exec_redim(self, stmt: str) -> bool:
1978
- """Handle REDIM name(size) — resize an existing array."""
2002
+ """Handle REDIM [PRESERVE] name(size) — resize an array.
2003
+
2004
+ Plain REDIM clears to zeros (QBASIC semantics); REDIM PRESERVE keeps the
2005
+ existing elements. Sizing is inclusive (REDIM a(n) spans indices base..n).
2006
+ """
1979
2007
  m = RE_REDIM.match(stmt)
1980
2008
  if not m:
1981
2009
  return False
1982
- name = m.group(1)
1983
- new_size = int(m.group(2))
2010
+ preserve = bool(m.group(1))
2011
+ name = m.group(2)
2012
+ base = getattr(self, '_option_base', 0)
2013
+ new_len = max(0, int(m.group(3)) - base + 1)
1984
2014
  old = self.arrays.get(name, [])
1985
- if isinstance(old, list):
1986
- if new_size > len(old):
1987
- self.arrays[name] = old + [0.0] * (new_size - len(old))
2015
+ if preserve and isinstance(old, list):
2016
+ if new_len > len(old):
2017
+ self.arrays[name] = old + [0.0] * (new_len - len(old))
1988
2018
  else:
1989
- self.arrays[name] = old[:new_size]
2019
+ self.arrays[name] = old[:new_len]
1990
2020
  else:
1991
- self.arrays[name] = [0.0] * new_size
2021
+ self.arrays[name] = [0.0] * new_len
1992
2022
  self._dimmed_arrays.add(name)
1993
2023
  return True
1994
2024
 
@@ -487,8 +487,9 @@ class TestStrings(unittest.TestCase):
487
487
  self.assertEqual(_instr("HELLO", "XYZ"), 0.0)
488
488
  self.assertEqual(_hex_fn(255), "FF")
489
489
  self.assertEqual(_bin_fn(5), "101")
490
- self.assertEqual(_str_fn(42.0), "42")
491
- self.assertEqual(_str_fn(3.14), "3.14")
490
+ self.assertEqual(_str_fn(42.0), " 42") # leading space for non-negative
491
+ self.assertEqual(_str_fn(3.14), " 3.14")
492
+ self.assertEqual(_str_fn(-5.0), "-5")
492
493
  self.assertEqual(_val_fn("42"), 42.0)
493
494
  self.assertEqual(_val_fn("abc"), 0.0)
494
495
  self.assertEqual(_len_fn("HELLO"), 5.0)
@@ -1048,7 +1049,7 @@ class TestProgramManagement(unittest.TestCase):
1048
1049
  _, out = capture(t_dim.cmd_run)
1049
1050
  self.assertIn('matrix', t_dim.arrays)
1050
1051
  self.assertIsInstance(t_dim.arrays['matrix'], list)
1051
- self.assertEqual(len(t_dim.arrays['matrix']), 9)
1052
+ self.assertEqual(len(t_dim.arrays['matrix']), 16) # inclusive: (0..3)^2
1052
1053
 
1053
1054
  # DIM single
1054
1055
  t_dim2 = QBasicTerminal()
@@ -1056,7 +1057,7 @@ class TestProgramManagement(unittest.TestCase):
1056
1057
  t_dim2.process('20 END')
1057
1058
  capture(t_dim2.cmd_run)
1058
1059
  self.assertIn('arr', t_dim2.arrays)
1059
- self.assertEqual(len(t_dim2.arrays['arr']), 5)
1060
+ self.assertEqual(len(t_dim2.arrays['arr']), 6) # inclusive: indices 0..5
1060
1061
 
1061
1062
  # IMPORT namespace
1062
1063
  with tempfile.NamedTemporaryFile(mode='w', suffix='.qb', dir='.', delete=False) as f:
@@ -2305,20 +2306,26 @@ class TestGapCoverage(unittest.TestCase):
2305
2306
 
2306
2307
  # 16 REDIM
2307
2308
  def test_redim(self):
2308
- """REDIM resizes an existing array, preserving or truncating data."""
2309
- # DIM first
2309
+ """REDIM resizes (inclusive sizing); plain clears, PRESERVE keeps."""
2310
+ # DIM a(5) spans indices 0..5 -> 6 slots (inclusive sizing).
2310
2311
  self.t._try_exec_dim('DIM arr(5)')
2311
- self.assertEqual(len(self.t.arrays['arr']), 5)
2312
- # REDIM larger
2312
+ self.assertEqual(len(self.t.arrays['arr']), 6)
2313
+ self.t.arrays['arr'][2] = 99.0
2314
+ # Plain REDIM clears to zeros.
2313
2315
  self.t._try_exec_redim('REDIM arr(8)')
2314
- self.assertEqual(len(self.t.arrays['arr']), 8)
2315
- self.assertEqual(self.t.arrays['arr'][:5], [0.0] * 5)
2316
- # REDIM smaller
2317
- self.t._try_exec_redim('REDIM arr(3)')
2318
- self.assertEqual(len(self.t.arrays['arr']), 3)
2319
- # REDIM non-existent array creates it
2316
+ self.assertEqual(len(self.t.arrays['arr']), 9)
2317
+ self.assertEqual(self.t.arrays['arr'][2], 0.0)
2318
+ # REDIM PRESERVE keeps existing data.
2319
+ self.t.arrays['arr'][1] = 7.0
2320
+ self.t._try_exec_redim('REDIM PRESERVE arr(10)')
2321
+ self.assertEqual(len(self.t.arrays['arr']), 11)
2322
+ self.assertEqual(self.t.arrays['arr'][1], 7.0)
2323
+ # PRESERVE smaller truncates.
2324
+ self.t._try_exec_redim('REDIM PRESERVE arr(3)')
2325
+ self.assertEqual(len(self.t.arrays['arr']), 4)
2326
+ # REDIM on a non-existent array creates it.
2320
2327
  self.t._try_exec_redim('REDIM newary(4)')
2321
- self.assertEqual(len(self.t.arrays['newary']), 4)
2328
+ self.assertEqual(len(self.t.arrays['newary']), 5)
2322
2329
 
2323
2330
 
2324
2331
  # =====================================================================
@@ -2138,7 +2138,9 @@ class TestDeepFixRegressions(unittest.TestCase):
2138
2138
  t.process('10 ID 0', track_undo=False)
2139
2139
  _, out = capture(t.cmd_run)
2140
2140
  self.assertNotIn('Unable to translate', out)
2141
- self.assertIsNotNone(getattr(t, '_last_density', None))
2141
+ # Density is solved lazily: the measure-free circuit is captured now,
2142
+ # and DENSITY computes the matrix on demand.
2143
+ self.assertIsNotNone(getattr(t, '_last_density_qc', None))
2142
2144
  _, dout = capture(t.cmd_density, '')
2143
2145
  self.assertIn('Density matrix', dout)
2144
2146
 
@@ -2152,6 +2154,79 @@ class TestDeepFixRegressions(unittest.TestCase):
2152
2154
  self.assertEqual(t.eval_expr('sq(5)'), 25.0)
2153
2155
 
2154
2156
 
2157
+ class TestConventionFixesV2(unittest.TestCase):
2158
+ """round half-away, chained-comparison guard, inclusive DIM, REDIM
2159
+ PRESERVE, MEAS print hint, DENSITY after MEASURE."""
2160
+
2161
+ def setUp(self):
2162
+ self.t = QBasicTerminal(); self.t.num_qubits = 1
2163
+
2164
+ def _runp(self, lines, nq=1):
2165
+ t = QBasicTerminal(); t.num_qubits = nq
2166
+ for l in lines:
2167
+ t.process(l, track_undo=False)
2168
+ _, out = capture(t.cmd_run)
2169
+ t._out = out
2170
+ return t
2171
+
2172
+ def test_round_half_away_from_zero(self):
2173
+ self.assertEqual(self.t.eval_expr('round(2.5)'), 3.0)
2174
+ self.assertEqual(self.t.eval_expr('round(3.5)'), 4.0)
2175
+ self.assertEqual(self.t.eval_expr('round(-2.5)'), -3.0)
2176
+ self.assertAlmostEqual(self.t.eval_expr('round(2.345, 2)'), 2.35)
2177
+
2178
+ def test_comparison_truth_and_chaining(self):
2179
+ # Comparisons yield BASIC truth values: -1 for true, 0 for false.
2180
+ self.assertEqual(self.t._safe_eval('3 < 5'), -1)
2181
+ self.assertEqual(self.t._safe_eval('3 > 5'), 0)
2182
+ # Python-style chaining, so range checks work as written.
2183
+ self.assertEqual(self.t._safe_eval('0 <= 5 <= 10'), -1)
2184
+ self.assertEqual(self.t._safe_eval('0 <= 50 <= 10'), 0)
2185
+
2186
+ def test_dim_inclusive_top_index(self):
2187
+ t = self._runp(['10 DIM a(5)', '20 LET a(5)=7', '30 LET v=a(5)'])
2188
+ self.assertEqual(t.variables.get('v'), 7.0)
2189
+ t2 = self._runp(['10 DIM a(5)', '20 LET a(6)=1'])
2190
+ self.assertIn('OUT OF RANGE', t2._out)
2191
+
2192
+ def test_redim_clear_vs_preserve(self):
2193
+ t = self._runp(['10 DIM a(3)', '20 LET a(1)=9', '30 REDIM a(5)', '40 LET v=a(1)'])
2194
+ self.assertEqual(t.variables.get('v'), 0.0)
2195
+ t2 = self._runp(['10 DIM a(3)', '20 LET a(1)=9', '30 REDIM PRESERVE a(5)', '40 LET v=a(1)'])
2196
+ self.assertEqual(t2.variables.get('v'), 9.0)
2197
+
2198
+ def test_meas_print_hint(self):
2199
+ t = self._runp(['10 X 0', '20 MEAS 0 -> m', '30 PRINT m', '40 MEASURE'])
2200
+ self.assertIn('mid-circuit', t._out)
2201
+
2202
+ def test_density_after_measure(self):
2203
+ t = QBasicTerminal(); t.num_qubits = 1
2204
+ t.process('SET_DENSITY [[0.5,0],[0,0.5]]', track_undo=False)
2205
+ for l in ['10 H 0', '20 MEASURE']:
2206
+ t.process(l, track_undo=False)
2207
+ capture(t.cmd_run)
2208
+ _, dout = capture(t.cmd_density, '')
2209
+ self.assertIn('Density matrix', dout)
2210
+
2211
+ def test_meas_bit_classical_use_errors(self):
2212
+ t = QBasicTerminal(); t.num_qubits = 1
2213
+ for l in ['10 X 0', '20 MEAS 0 -> m', '30 LET y = m + 1', '40 MEASURE']:
2214
+ t.process(l, track_undo=False)
2215
+ _, out = capture(t.cmd_run)
2216
+ self.assertIn('mid-circuit', out)
2217
+
2218
+ def test_string_array_default_empty(self):
2219
+ t = QBasicTerminal(); t.num_qubits = 1
2220
+ t.process('DIM s$(3)', track_undo=False)
2221
+ self.assertEqual(t._safe_eval('s$(1)'), '')
2222
+
2223
+ def test_spec_statements_present(self):
2224
+ from qubasic_core.cli import _SPEC_STATEMENTS
2225
+ names = {n for (n, _s, _h) in _SPEC_STATEMENTS}
2226
+ for op in ('QFT', 'MEAS', 'SYNDROME', 'EVOLVE', 'QADD'):
2227
+ self.assertIn(op, names)
2228
+
2229
+
2155
2230
  if __name__ == '__main__':
2156
2231
  if hasattr(sys.stdout, 'reconfigure'):
2157
2232
  sys.stdout.reconfigure(encoding='utf-8')
@@ -1,149 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- QUBASIC — command-line entry point.
4
-
5
- Usage:
6
- qubasic Interactive REPL (installed console script)
7
- python -m qubasic_core Same, run as a module
8
- qubasic script.qb Run a script file
9
- """
10
-
11
- import sys
12
- import os
13
-
14
- # Force UTF-8 output on Windows
15
- if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
16
- try:
17
- sys.stdout.reconfigure(encoding='utf-8')
18
- except Exception:
19
- pass
20
- if sys.stderr and hasattr(sys.stderr, 'reconfigure'):
21
- try:
22
- sys.stderr.reconfigure(encoding='utf-8')
23
- except Exception:
24
- pass
25
-
26
- from qubasic_core.terminal import QBasicTerminal
27
- from qubasic_core.program_mgmt import ProgramMgmtMixin
28
-
29
-
30
- def run_script(path: str, terminal: 'QBasicTerminal') -> None:
31
- """Run a .qb script file. Supports multi-line DEF blocks.
32
-
33
- After loading all lines, auto-runs the program if it contains
34
- numbered lines with a MEASURE statement.
35
- """
36
- with open(path, 'r') as f:
37
- lines = [l.rstrip('\n\r') for l in f.readlines()]
38
- ProgramMgmtMixin._load_lines_with_defs(
39
- lines, lambda line: terminal.process(line, track_undo=False))
40
-
41
- # Auto-run if the program has a reachable MEASURE (incl. in subs / IF
42
- # clauses), unless the script already issued an explicit RUN (which would
43
- # otherwise execute and print the program twice).
44
- explicit_run = any(l.strip().upper() == 'RUN' for l in lines)
45
- if (terminal.program and not explicit_run
46
- and terminal._program_has_measure(sorted(terminal.program))):
47
- terminal.cmd_run()
48
-
49
-
50
- def main():
51
- import json as _json
52
- os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
53
-
54
- from qubasic_core import __version__
55
-
56
- args = sys.argv[1:]
57
- quiet = '--quiet' in args or '-q' in args
58
- json_mode = '--json' in args
59
- agent_mode = '--agent' in args
60
- seed_val = None
61
- for flag in ('--quiet', '-q', '--json', '--agent'):
62
- args = [a for a in args if a != flag]
63
- # Parse --seed N
64
- filtered = []
65
- i = 0
66
- while i < len(args):
67
- if args[i] == '--seed' and i + 1 < len(args):
68
- seed_val = int(args[i + 1])
69
- i += 2
70
- else:
71
- filtered.append(args[i])
72
- i += 1
73
- args = filtered
74
-
75
- if any(a in ('-v', '--version') for a in args):
76
- print(f"QUBASIC {__version__}")
77
- sys.exit(0)
78
-
79
- if any(a in ('-h', '--help') for a in args):
80
- print(f"QUBASIC {__version__} — Quantum BASIC Interactive Terminal")
81
- print()
82
- print("Usage:")
83
- print(" qubasic Interactive REPL")
84
- print(" qubasic script.qb Run a script file")
85
- print(" qubasic --quiet script Suppress banner and progress (also -q)")
86
- print(" qubasic --json script Output results as JSON")
87
- print(" qubasic --agent script Confine file writes to the working dir")
88
- print(" qubasic --seed N script Set random seed for reproducibility")
89
- print(" qubasic --version Show version (also -v)")
90
- print(" qubasic --help Show this help (also -h)")
91
- print(" python -m qubasic_core Run without the installed console script")
92
- print()
93
- print("Type HELP inside the REPL for full command reference.")
94
- sys.exit(0)
95
-
96
- term = QBasicTerminal()
97
- # JSON mode implies agent use, so confine file writes to the working dir.
98
- term.agent_mode = agent_mode or json_mode
99
- if seed_val is not None:
100
- import numpy as _np
101
- term._seed = seed_val
102
- _np.random.seed(seed_val)
103
-
104
- if args:
105
- path = args[0]
106
- if not os.path.isfile(path):
107
- if json_mode:
108
- print(_json.dumps({'error': f'FILE NOT FOUND: {path}'}, indent=2))
109
- else:
110
- print(f"?FILE NOT FOUND: {path}")
111
- sys.exit(1)
112
- if quiet or json_mode:
113
- import io
114
- buf = io.StringIO()
115
- old = sys.stdout
116
- sys.stdout = buf
117
- err = None
118
- try:
119
- run_script(path, term)
120
- except Exception as e: # surface as structured error in JSON mode
121
- err = str(e)
122
- finally:
123
- sys.stdout = old
124
- if json_mode:
125
- if err is not None:
126
- print(_json.dumps({'error': err}, indent=2))
127
- sys.exit(1)
128
- print(_json.dumps(term.result(), indent=2))
129
- else:
130
- # --quiet (no --json): the banner was never emitted into the
131
- # buffer (only the non-captured path calls print_banner), so the
132
- # captured text is exactly the results. Print them, matching the
133
- # documented "suppress banner, output results only" behavior.
134
- print(buf.getvalue(), end='')
135
- if err is not None:
136
- print(f"?ERROR: {err}")
137
- sys.exit(1)
138
- else:
139
- term.print_banner()
140
- run_script(path, term)
141
- # Exit 0 on success; 1 if a measured program produced no counts.
142
- expects_measure = bool(term.program) and term._program_has_measure(sorted(term.program))
143
- sys.exit(0 if term.last_counts is not None or not expects_measure else 1)
144
- else:
145
- term.repl()
146
-
147
-
148
- if __name__ == '__main__':
149
- main()
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