qex 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: qex
3
+ Version: 0.1.1
4
+ Summary: A lightweight experiment-runner and lab notebook for quantum computing
5
+ Author: qlab contributors
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/AndreaPallotta/qlab
8
+ Project-URL: Documentation, https://github.com/AndreaPallotta/qlab#readme
9
+ Project-URL: Repository, https://github.com/AndreaPallotta/qlab
10
+ Project-URL: Issues, https://github.com/AndreaPallotta/qlab/issues
11
+ Keywords: quantum,quantum-computing,cirq,experiments,quantum-simulation,bloch-sphere
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Physics
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: cirq>=1.6.1
25
+ Requires-Dist: numpy>=1.24.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
29
+ Requires-Dist: black>=23.0.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
32
+
33
+ # qlab
34
+
35
+ <div align="center">
36
+ <img src="https://raw.githubusercontent.com/AndreaPallotta/qlab/main/assets/logo.png" alt="qlab logo" width="200"/>
37
+ </div>
38
+
39
+ A lightweight experiment-runner and lab notebook for quantum computing, built on top of Cirq.
40
+
41
+ ## Overview
42
+
43
+ `qlab` is designed for running quantum experiments, tracking results, and visualizing outcomes. It focuses on reproducibility, persistence, and visualization rather than being a general-purpose quantum framework.
44
+
45
+ **Key Features:**
46
+ - ๐Ÿงช **Experiment Management**: Define parametric quantum circuits and run them systematically
47
+ - ๐Ÿ’พ **Result Persistence**: Store runs, results, and metadata in SQLite
48
+ - ๐Ÿ“Š **Visualization**: Automatic Bloch sphere visualization for 1-qubit states
49
+ - ๐Ÿ”ฌ **Reproducibility**: All parameters and results are stored for later analysis
50
+ - ๐Ÿš€ **Simple API**: Minimal, opinionated design focused on experiments
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ pip install qex
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from qlab import CirqBackend, Runner, ResultStore
62
+ from qlab.demos import hadamard_experiment
63
+ from pathlib import Path
64
+
65
+ # Setup
66
+ backend = CirqBackend()
67
+ runner = Runner(backend)
68
+ store = ResultStore(Path("qlab_data/qlab.db"))
69
+
70
+ # Run an experiment
71
+ experiment = hadamard_experiment()
72
+ record = runner.run(experiment, params={})
73
+
74
+ # Persist results
75
+ store.save_run(record)
76
+
77
+ # Retrieve and view
78
+ runs = store.list_runs(experiment_name="hadamard")
79
+ rho = runs[0].get_density_matrix()
80
+ print(f"Density matrix:\n{rho}")
81
+
82
+ store.close()
83
+ ```
84
+
85
+ ## Built-in Experiments
86
+
87
+ `qlab` includes three demo experiments for validation:
88
+
89
+ - **X Gate**: `|0โŸฉ โ†’ X โ†’ |1โŸฉ` - Simple bit flip
90
+ - **Hadamard**: `|0โŸฉ โ†’ H โ†’ superposition` - Creates equal superposition
91
+ - **Ry Sweep**: `|0โŸฉ โ†’ Ry(ฮธ)` - Rotation around Y-axis with parameter `theta`
92
+
93
+ ## Documentation
94
+
95
+ - **[API Reference](API.md)**: Complete API documentation
96
+ - **[Design Specification](DESIGN.md)**: Architecture and design decisions
97
+ - **[Database Schema](SCHEMA.md)**: SQLite schema documentation
98
+ - **[Bloch Sphere Contract](BLOCH_CONTRACT.md)**: Visualization math and interface
99
+
100
+ ## Current Scope (MVP)
101
+
102
+ - โœ… 1-qubit experiments only
103
+ - โœ… Ideal simulation (no noise)
104
+ - โœ… Density matrix results
105
+ - โœ… SQLite persistence
106
+ - โœ… Bloch sphere HTML visualization
107
+ - โœ… Cirq-based backend
108
+
109
+ ## Requirements
110
+
111
+ - Python >= 3.12
112
+ - Cirq >= 1.6.1
113
+ - NumPy >= 1.24.0
114
+
115
+ ## License
116
+
117
+ Apache License 2.0
118
+
119
+ ## Contributing
120
+
121
+ Contributions are welcome! Please see the [Design Specification](DESIGN.md) for architecture details and constraints.
@@ -0,0 +1,12 @@
1
+ qlab/__init__.py,sha256=47G37bkDpMm9lyMFWKVYyM9ELKJo65apPh41sTQYIV4,477
2
+ qlab/backend.py,sha256=V7TASI-SSCNK9AApvD6d2C1LabB7XB1xNyH-I4h65qI,2383
3
+ qlab/bloch.py,sha256=J34uLDZ07HgfMPs7ANV4aeGLSZB2LUU7QylxyKdrw2E,5903
4
+ qlab/demos.py,sha256=AFNovp2NnhvBftmAkwxNPs--gOQFqX7gglwXsLki6kQ,1704
5
+ qlab/experiment.py,sha256=PjPMb4N8T3oyx0bSyZ5Vl7uKqKh_vp16bpt-9nSTnuE,1458
6
+ qlab/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ qlab/runner.py,sha256=iHk6eGm61eZxlgQmeFKWPCZD0ziLjKh39ZlEijAdgnQ,3535
8
+ qlab/store.py,sha256=GgpkSXf61AS9dQZ01ajHsqGAskF4bEwWqknSZQl_vnA,8837
9
+ qex-0.1.1.dist-info/METADATA,sha256=KdqNuy-sJP4s8QQ6Dd8OvsMcHeK2u95C9Kc6qoAuGwY,3963
10
+ qex-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
+ qex-0.1.1.dist-info/top_level.txt,sha256=XdHlK9uLEScGqteqlmIiXaMKp12rVhyoXvdhS_TTvdg,5
12
+ qex-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ qlab
qlab/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ qlab: A lightweight experiment-runner and lab notebook for quantum computing.
3
+
4
+ Built on top of Cirq, focused on experiments, runs, reproducibility, and visualization.
5
+ """
6
+
7
+ from qlab.experiment import Experiment
8
+ from qlab.backend import Backend, CirqBackend
9
+ from qlab.runner import Runner
10
+ from qlab.store import ResultStore, RunRecord
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "Experiment",
15
+ "Backend",
16
+ "CirqBackend",
17
+ "Runner",
18
+ "ResultStore",
19
+ "RunRecord",
20
+ ]
qlab/backend.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ Backend abstraction: interface for executing quantum circuits.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any
7
+ import cirq
8
+ import numpy as np
9
+
10
+
11
+ class Backend(ABC):
12
+ """
13
+ Abstract interface for executing quantum circuits.
14
+
15
+ A backend executes a circuit and returns the final density matrix.
16
+ For MVP, all backends must support 1-qubit circuits only.
17
+ """
18
+
19
+ @abstractmethod
20
+ def run(self, circuit: cirq.Circuit) -> np.ndarray:
21
+ """
22
+ Execute a circuit and return the final density matrix.
23
+
24
+ Args:
25
+ circuit: A Cirq Circuit (must be 1-qubit for MVP).
26
+
27
+ Returns:
28
+ A 2x2 density matrix as a numpy array (complex dtype).
29
+ """
30
+ pass
31
+
32
+ @abstractmethod
33
+ def get_name(self) -> str:
34
+ """
35
+ Get the name/identifier of this backend.
36
+
37
+ Returns:
38
+ Backend name string.
39
+ """
40
+ pass
41
+
42
+
43
+ class CirqBackend(Backend):
44
+ """
45
+ Concrete backend using Cirq's ideal simulator.
46
+
47
+ Uses Cirq's Simulator for ideal (noiseless) simulation.
48
+ Results are returned as density matrices derived from statevectors.
49
+ """
50
+
51
+ def __init__(self):
52
+ """
53
+ Initialize the Cirq ideal simulator backend.
54
+ """
55
+ self._simulator = cirq.Simulator()
56
+
57
+ def run(self, circuit: cirq.Circuit) -> np.ndarray:
58
+ """
59
+ Execute circuit on ideal Cirq simulator and return density matrix.
60
+
61
+ Args:
62
+ circuit: A Cirq Circuit (must be 1-qubit for MVP).
63
+
64
+ Returns:
65
+ 2x2 density matrix (complex dtype).
66
+ """
67
+ # Validate 1-qubit constraint
68
+ qubits = circuit.all_qubits()
69
+ if len(qubits) != 1:
70
+ raise ValueError(f"Circuit must have exactly 1 qubit, got {len(qubits)}")
71
+
72
+ # Run simulation to get final state
73
+ result = self._simulator.simulate(circuit)
74
+ statevector = result.final_state_vector
75
+
76
+ # Convert statevector to density matrix: |ฯˆโŸฉโŸจฯˆ|
77
+ # For 1 qubit, statevector is length 2
78
+ rho = np.outer(statevector, np.conj(statevector))
79
+
80
+ return rho
81
+
82
+ def get_name(self) -> str:
83
+ """
84
+ Get backend name.
85
+
86
+ Returns:
87
+ "cirq_ideal"
88
+ """
89
+ return "cirq_ideal"
qlab/bloch.py ADDED
@@ -0,0 +1,176 @@
1
+ """
2
+ Bloch sphere visualization: density matrix โ†’ (x,y,z) coordinates โ†’ HTML artifact.
3
+ """
4
+
5
+ from typing import Tuple
6
+ import numpy as np
7
+
8
+
9
+ def density_matrix_to_bloch(rho: np.ndarray) -> Tuple[float, float, float]:
10
+ """
11
+ Convert a 1-qubit density matrix to Bloch sphere coordinates (x, y, z).
12
+
13
+ For a density matrix ฯ, the Bloch vector is computed as:
14
+ x = Tr(ฯ ยท ฯƒ_x)
15
+ y = Tr(ฯ ยท ฯƒ_y)
16
+ z = Tr(ฯ ยท ฯƒ_z)
17
+
18
+ where ฯƒ_x, ฯƒ_y, ฯƒ_z are the Pauli matrices.
19
+
20
+ Args:
21
+ rho: 2x2 density matrix (complex dtype).
22
+ Must be a valid density matrix (Hermitian, trace=1, positive semidefinite).
23
+
24
+ Returns:
25
+ Tuple of (x, y, z) coordinates as real floats.
26
+ Coordinates are guaranteed to satisfy xยฒ + yยฒ + zยฒ โ‰ค 1.
27
+ """
28
+ # Pauli matrices
29
+ sigma_x = np.array([[0, 1], [1, 0]], dtype=complex)
30
+ sigma_y = np.array([[0, -1j], [1j, 0]], dtype=complex)
31
+ sigma_z = np.array([[1, 0], [0, -1]], dtype=complex)
32
+
33
+ # Compute Bloch coordinates: Tr(ฯ ยท ฯƒ_i)
34
+ x = np.real(np.trace(rho @ sigma_x))
35
+ y = np.real(np.trace(rho @ sigma_y))
36
+ z = np.real(np.trace(rho @ sigma_z))
37
+
38
+ return (float(x), float(y), float(z))
39
+
40
+
41
+ def bloch_to_html(x: float, y: float, z: float, title: str = "Bloch Sphere") -> str:
42
+ """
43
+ Generate an HTML artifact visualizing a point on the Bloch sphere.
44
+
45
+ The HTML should:
46
+ - Render a 3D Bloch sphere
47
+ - Mark the point (x, y, z) on the sphere
48
+ - Display the coordinates
49
+ - Be self-contained (no external dependencies, or use CDN for 3D library)
50
+ - Be viewable in a browser
51
+
52
+ Args:
53
+ x: X coordinate on Bloch sphere.
54
+ y: Y coordinate on Bloch sphere.
55
+ z: Z coordinate on Bloch sphere.
56
+ title: Optional title for the visualization.
57
+
58
+ Returns:
59
+ Complete HTML string that can be saved to a file and opened in a browser.
60
+ """
61
+ # Use Three.js via CDN for 3D visualization
62
+ html = f"""<!DOCTYPE html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="UTF-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
67
+ <title>{title}</title>
68
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
69
+ <style>
70
+ body {{
71
+ margin: 0;
72
+ font-family: Arial, sans-serif;
73
+ background: #1a1a1a;
74
+ color: #fff;
75
+ display: flex;
76
+ flex-direction: column;
77
+ align-items: center;
78
+ padding: 20px;
79
+ }}
80
+ #container {{
81
+ width: 800px;
82
+ height: 600px;
83
+ border: 2px solid #444;
84
+ border-radius: 8px;
85
+ margin: 20px 0;
86
+ }}
87
+ #info {{
88
+ text-align: center;
89
+ margin: 10px 0;
90
+ }}
91
+ .coord {{
92
+ display: inline-block;
93
+ margin: 0 15px;
94
+ font-family: monospace;
95
+ }}
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <h1>{title}</h1>
100
+ <div id="container"></div>
101
+ <div id="info">
102
+ <div class="coord">x = {x:.4f}</div>
103
+ <div class="coord">y = {y:.4f}</div>
104
+ <div class="coord">z = {z:.4f}</div>
105
+ </div>
106
+ <script>
107
+ // Scene setup
108
+ const scene = new THREE.Scene();
109
+ const camera = new THREE.PerspectiveCamera(75, 800/600, 0.1, 1000);
110
+ const renderer = new THREE.WebGLRenderer({{ antialias: true }});
111
+ renderer.setSize(800, 600);
112
+ document.getElementById('container').appendChild(renderer.domElement);
113
+
114
+ // Lighting
115
+ const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
116
+ scene.add(ambientLight);
117
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
118
+ directionalLight.position.set(5, 5, 5);
119
+ scene.add(directionalLight);
120
+
121
+ // Bloch sphere (unit sphere)
122
+ const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);
123
+ const sphereMaterial = new THREE.MeshPhongMaterial({{
124
+ color: 0x4a90e2,
125
+ transparent: true,
126
+ opacity: 0.3,
127
+ side: THREE.DoubleSide,
128
+ wireframe: false
129
+ }});
130
+ const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
131
+ scene.add(sphere);
132
+
133
+ // Wireframe overlay
134
+ const wireframe = new THREE.WireframeGeometry(sphereGeometry);
135
+ const wireframeLine = new THREE.LineSegments(wireframe, new THREE.LineBasicMaterial({{ color: 0xffffff, opacity: 0.2 }}));
136
+ scene.add(wireframeLine);
137
+
138
+ // Axes
139
+ const axesHelper = new THREE.AxesHelper(1.2);
140
+ scene.add(axesHelper);
141
+
142
+ // Point marker
143
+ const pointGeometry = new THREE.SphereGeometry(0.05, 16, 16);
144
+ const pointMaterial = new THREE.MeshPhongMaterial({{ color: 0xff4444 }});
145
+ const point = new THREE.Mesh(pointGeometry, pointMaterial);
146
+ point.position.set({x}, {y}, {z});
147
+ scene.add(point);
148
+
149
+ // Line from origin to point
150
+ const lineGeometry = new THREE.BufferGeometry().setFromPoints([
151
+ new THREE.Vector3(0, 0, 0),
152
+ new THREE.Vector3({x}, {y}, {z})
153
+ ]);
154
+ const lineMaterial = new THREE.LineBasicMaterial({{ color: 0xff4444, linewidth: 2 }});
155
+ const line = new THREE.Line(lineGeometry, lineMaterial);
156
+ scene.add(line);
157
+
158
+ // Camera position
159
+ camera.position.set(2.5, 2.5, 2.5);
160
+ camera.lookAt(0, 0, 0);
161
+
162
+ // Animation loop
163
+ let angle = 0;
164
+ function animate() {{
165
+ requestAnimationFrame(animate);
166
+ angle += 0.01;
167
+ camera.position.x = 2.5 * Math.cos(angle);
168
+ camera.position.z = 2.5 * Math.sin(angle);
169
+ camera.lookAt(0, 0, 0);
170
+ renderer.render(scene, camera);
171
+ }}
172
+ animate();
173
+ </script>
174
+ </body>
175
+ </html>"""
176
+ return html
qlab/demos.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ Built-in demo experiments for validation.
3
+ """
4
+
5
+ from typing import Dict, Any
6
+ import cirq
7
+ from qlab.experiment import Experiment
8
+
9
+
10
+ def x_gate_experiment() -> Experiment:
11
+ """
12
+ Demo: |0โŸฉ โ†’ X โ†’ |1โŸฉ
13
+
14
+ Simple X gate that flips |0โŸฉ to |1โŸฉ.
15
+
16
+ Returns:
17
+ Experiment with no parameters.
18
+ """
19
+ def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
20
+ """Build circuit: X gate on qubit."""
21
+ return cirq.Circuit(cirq.X(qubit))
22
+
23
+ return Experiment(name="x_gate", builder=builder)
24
+
25
+
26
+ def hadamard_experiment() -> Experiment:
27
+ """
28
+ Demo: |0โŸฉ โ†’ H โ†’ superposition
29
+
30
+ Hadamard gate creating equal superposition |+โŸฉ = (|0โŸฉ + |1โŸฉ)/โˆš2.
31
+
32
+ Returns:
33
+ Experiment with no parameters.
34
+ """
35
+ def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
36
+ """Build circuit: Hadamard gate on qubit."""
37
+ return cirq.Circuit(cirq.H(qubit))
38
+
39
+ return Experiment(name="hadamard", builder=builder)
40
+
41
+
42
+ def ry_sweep_experiment() -> Experiment:
43
+ """
44
+ Demo: |0โŸฉ โ†’ Ry(ฮธ) sweep
45
+
46
+ Rotation around Y-axis with parameter ฮธ.
47
+ This experiment expects params = {"theta": float}.
48
+
49
+ Returns:
50
+ Experiment that takes "theta" parameter (in radians).
51
+ """
52
+ def builder(qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
53
+ """Build circuit: Ry(theta) rotation on qubit."""
54
+ theta = params.get("theta", 0.0)
55
+ if not isinstance(theta, (int, float)):
56
+ raise ValueError(f"theta must be a number, got {type(theta)}")
57
+ return cirq.Circuit(cirq.ry(theta)(qubit))
58
+
59
+ return Experiment(name="ry_sweep", builder=builder)
qlab/experiment.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Experiment abstraction: parametric circuit builder.
3
+ """
4
+
5
+ from typing import Callable, Dict, Any
6
+ import cirq
7
+
8
+
9
+ class Experiment:
10
+ """
11
+ An experiment defines a parametric quantum circuit builder.
12
+
13
+ An experiment has a name and a function that builds a Cirq circuit
14
+ from parameters. The circuit must be 1-qubit for MVP.
15
+
16
+ Attributes:
17
+ name: Unique identifier for the experiment.
18
+ builder: Function that takes parameters and returns a Cirq Circuit.
19
+ Must accept a single qubit as first argument.
20
+ """
21
+
22
+ def __init__(self, name: str, builder: Callable[[cirq.Qid, Dict[str, Any]], cirq.Circuit]):
23
+ """
24
+ Initialize an experiment.
25
+
26
+ Args:
27
+ name: Unique name for the experiment.
28
+ builder: Function signature: (qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit
29
+ The builder must create a 1-qubit circuit.
30
+ """
31
+ self.name = name
32
+ self.builder = builder
33
+
34
+ def build_circuit(self, qubit: cirq.Qid, params: Dict[str, Any]) -> cirq.Circuit:
35
+ """
36
+ Build the circuit for this experiment with given parameters.
37
+
38
+ Args:
39
+ qubit: The single qubit to operate on.
40
+ params: Parameter dictionary for the experiment.
41
+
42
+ Returns:
43
+ A Cirq Circuit operating on the given qubit.
44
+ """
45
+ return self.builder(qubit, params)
qlab/py.typed ADDED
File without changes
qlab/runner.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Runner: executes experiments with parameters and configuration.
3
+ """
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Dict, Any, Optional
8
+ from pathlib import Path
9
+ import cirq
10
+ import numpy as np
11
+ from qlab.experiment import Experiment
12
+ from qlab.backend import Backend
13
+ from qlab.store import RunRecord # type: ignore
14
+ from qlab.bloch import density_matrix_to_bloch, bloch_to_html
15
+
16
+
17
+ class Runner:
18
+ """
19
+ Executes an experiment with given parameters and backend configuration.
20
+
21
+ The runner coordinates experiment execution, result computation,
22
+ and artifact generation. It does not handle persistence (that's ResultStore's job).
23
+ """
24
+
25
+ def __init__(self, backend: Backend, base_dir: Optional[Path] = None):
26
+ """
27
+ Initialize a runner with a backend.
28
+
29
+ Args:
30
+ backend: The backend to use for circuit execution.
31
+ base_dir: Base directory for storing results and artifacts.
32
+ If None, defaults to current directory.
33
+ """
34
+ self.backend = backend
35
+ self.base_dir = Path(base_dir) if base_dir else Path.cwd()
36
+ self.base_dir.mkdir(parents=True, exist_ok=True)
37
+ (self.base_dir / "results").mkdir(exist_ok=True)
38
+ (self.base_dir / "artifacts").mkdir(exist_ok=True)
39
+
40
+ def run(
41
+ self,
42
+ experiment: Experiment,
43
+ params: Dict[str, Any],
44
+ config: Optional[Dict[str, Any]] = None
45
+ ) -> RunRecord:
46
+ """
47
+ Execute an experiment and return a run record.
48
+
49
+ Args:
50
+ experiment: The experiment to run.
51
+ params: Parameters for the experiment's circuit builder.
52
+ config: Optional configuration (e.g., qubit selection, metadata).
53
+ Defaults to using a single qubit.
54
+
55
+ Returns:
56
+ RunRecord containing:
57
+ - Experiment name and parameters
58
+ - Density matrix result
59
+ - Generated artifacts (e.g., Bloch sphere HTML)
60
+ - Metadata (timestamp, backend name, etc.)
61
+ """
62
+ config = config or {}
63
+
64
+ # Generate run ID
65
+ run_id = str(uuid.uuid4())
66
+ timestamp = time.time()
67
+
68
+ # Create qubit (default to GridQubit(0, 0) if not specified)
69
+ qubit = config.get("qubit", cirq.GridQubit(0, 0))
70
+
71
+ # Build circuit
72
+ circuit = experiment.build_circuit(qubit, params)
73
+
74
+ # Execute circuit
75
+ rho = self.backend.run(circuit)
76
+
77
+ # Save density matrix
78
+ rho_path = f"results/{run_id}_rho.npy"
79
+ np.save(self.base_dir / rho_path, rho)
80
+
81
+ # Generate Bloch sphere visualization
82
+ x, y, z = density_matrix_to_bloch(rho)
83
+ html_content = bloch_to_html(x, y, z, title=f"{experiment.name} - {run_id[:8]}")
84
+ html_path = f"artifacts/{run_id}_bloch.html"
85
+ (self.base_dir / html_path).write_text(html_content)
86
+
87
+ # Create artifacts dict
88
+ artifacts = {"bloch_sphere": html_path}
89
+
90
+ # Build metadata
91
+ metadata = config.get("metadata", {})
92
+ metadata["qubit"] = str(qubit)
93
+
94
+ # Create RunRecord
95
+ record = RunRecord(
96
+ run_id=run_id,
97
+ experiment_name=experiment.name,
98
+ params=params,
99
+ backend_name=self.backend.get_name(),
100
+ timestamp=timestamp,
101
+ density_matrix_path=rho_path,
102
+ artifacts=artifacts,
103
+ metadata=metadata
104
+ )
105
+
106
+ return record
qlab/store.py ADDED
@@ -0,0 +1,269 @@
1
+ """
2
+ ResultStore: SQLite persistence for runs, results, and artifacts.
3
+ """
4
+
5
+ from typing import List, Optional, Dict, Any
6
+ from pathlib import Path
7
+ import json
8
+ import sqlite3
9
+ import numpy as np
10
+ import uuid
11
+
12
+
13
+ class RunRecord:
14
+ """
15
+ Metadata and references for a single experiment run.
16
+
17
+ Contains:
18
+ - Run ID (UUID or auto-increment)
19
+ - Experiment name
20
+ - Parameters dictionary (JSON-serializable)
21
+ - Backend name
22
+ - Timestamp
23
+ - Path to density matrix file (numpy .npy)
24
+ - Path to artifacts (e.g., Bloch sphere HTML)
25
+ - Additional metadata
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ run_id: str,
31
+ experiment_name: str,
32
+ params: Dict[str, Any],
33
+ backend_name: str,
34
+ timestamp: float,
35
+ density_matrix_path: str,
36
+ artifacts: Dict[str, str], # artifact_name -> file_path
37
+ metadata: Optional[Dict[str, Any]] = None
38
+ ):
39
+ """
40
+ Initialize a run record.
41
+
42
+ Args:
43
+ run_id: Unique identifier for this run.
44
+ experiment_name: Name of the experiment.
45
+ params: Parameters used for this run.
46
+ backend_name: Name of the backend used.
47
+ timestamp: Unix timestamp of when run was executed.
48
+ density_matrix_path: Path to saved density matrix (.npy file).
49
+ artifacts: Dictionary mapping artifact names to file paths.
50
+ metadata: Optional additional metadata.
51
+ """
52
+ self.run_id = run_id
53
+ self.experiment_name = experiment_name
54
+ self.params = params
55
+ self.backend_name = backend_name
56
+ self.timestamp = timestamp
57
+ self.density_matrix_path = density_matrix_path
58
+ self.artifacts = artifacts
59
+ self.metadata = metadata or {}
60
+ self._base_dir: Optional[Path] = None # Set by ResultStore when loading
61
+
62
+ def set_base_dir(self, base_dir: Path) -> None:
63
+ """Set the base directory for resolving relative paths."""
64
+ self._base_dir = base_dir
65
+
66
+ def get_density_matrix(self) -> np.ndarray:
67
+ """
68
+ Load and return the density matrix for this run.
69
+
70
+ Returns:
71
+ 2x2 density matrix (complex dtype).
72
+ """
73
+ if self._base_dir is None:
74
+ raise ValueError("Base directory not set. Use ResultStore.get_run() to load records.")
75
+ full_path = self._base_dir / self.density_matrix_path
76
+ return np.load(full_path)
77
+
78
+
79
+ class ResultStore:
80
+ """
81
+ SQLite-based persistence layer for experiment runs.
82
+
83
+ Stores run metadata, density matrices, and artifact references.
84
+ Provides query interface for retrieving runs.
85
+ """
86
+
87
+ def __init__(self, db_path: Path):
88
+ """
89
+ Initialize the result store.
90
+
91
+ Args:
92
+ db_path: Path to SQLite database file.
93
+ If file doesn't exist, it will be created with schema.
94
+ """
95
+ self.db_path = Path(db_path).resolve()
96
+ self.base_dir = self.db_path.parent
97
+ self.conn = sqlite3.connect(str(self.db_path))
98
+ self.conn.row_factory = sqlite3.Row
99
+ self._initialize_schema()
100
+ self._ensure_directories()
101
+
102
+ def _initialize_schema(self) -> None:
103
+ """Create database tables if they don't exist."""
104
+ cursor = self.conn.cursor()
105
+
106
+ # Create runs table
107
+ cursor.execute("""
108
+ CREATE TABLE IF NOT EXISTS runs (
109
+ run_id TEXT PRIMARY KEY,
110
+ experiment_name TEXT NOT NULL,
111
+ params TEXT NOT NULL,
112
+ backend_name TEXT NOT NULL,
113
+ timestamp REAL NOT NULL,
114
+ density_matrix_path TEXT NOT NULL,
115
+ metadata TEXT
116
+ )
117
+ """)
118
+
119
+ # Create artifacts table
120
+ cursor.execute("""
121
+ CREATE TABLE IF NOT EXISTS artifacts (
122
+ artifact_id INTEGER PRIMARY KEY AUTOINCREMENT,
123
+ run_id TEXT NOT NULL,
124
+ artifact_name TEXT NOT NULL,
125
+ artifact_path TEXT NOT NULL,
126
+ artifact_type TEXT NOT NULL,
127
+ FOREIGN KEY (run_id) REFERENCES runs(run_id)
128
+ )
129
+ """)
130
+
131
+ # Create indexes
132
+ cursor.execute("""
133
+ CREATE INDEX IF NOT EXISTS idx_runs_experiment_name
134
+ ON runs(experiment_name)
135
+ """)
136
+ cursor.execute("""
137
+ CREATE INDEX IF NOT EXISTS idx_runs_timestamp
138
+ ON runs(timestamp)
139
+ """)
140
+ cursor.execute("""
141
+ CREATE INDEX IF NOT EXISTS idx_artifacts_run_id
142
+ ON artifacts(run_id)
143
+ """)
144
+
145
+ self.conn.commit()
146
+
147
+ def _ensure_directories(self) -> None:
148
+ """Create results and artifacts directories if they don't exist."""
149
+ (self.base_dir / "results").mkdir(parents=True, exist_ok=True)
150
+ (self.base_dir / "artifacts").mkdir(parents=True, exist_ok=True)
151
+
152
+ def save_run(self, record: RunRecord) -> None:
153
+ """
154
+ Persist a run record to the database.
155
+
156
+ Args:
157
+ record: The RunRecord to save.
158
+ """
159
+ cursor = self.conn.cursor()
160
+
161
+ # Save run metadata
162
+ cursor.execute("""
163
+ INSERT INTO runs (run_id, experiment_name, params, backend_name,
164
+ timestamp, density_matrix_path, metadata)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?)
166
+ """, (
167
+ record.run_id,
168
+ record.experiment_name,
169
+ json.dumps(record.params),
170
+ record.backend_name,
171
+ record.timestamp,
172
+ record.density_matrix_path,
173
+ json.dumps(record.metadata) if record.metadata else None
174
+ ))
175
+
176
+ # Save artifacts
177
+ for artifact_name, artifact_path in record.artifacts.items():
178
+ # Determine artifact type from extension
179
+ artifact_type = "text/html" if artifact_path.endswith(".html") else "application/octet-stream"
180
+ cursor.execute("""
181
+ INSERT INTO artifacts (run_id, artifact_name, artifact_path, artifact_type)
182
+ VALUES (?, ?, ?, ?)
183
+ """, (record.run_id, artifact_name, artifact_path, artifact_type))
184
+
185
+ self.conn.commit()
186
+
187
+ def get_run(self, run_id: str) -> Optional[RunRecord]:
188
+ """
189
+ Retrieve a run record by ID.
190
+
191
+ Args:
192
+ run_id: The run ID to look up.
193
+
194
+ Returns:
195
+ RunRecord if found, None otherwise.
196
+ """
197
+ cursor = self.conn.cursor()
198
+
199
+ # Get run metadata
200
+ cursor.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,))
201
+ row = cursor.fetchone()
202
+ if row is None:
203
+ return None
204
+
205
+ # Get artifacts
206
+ cursor.execute("""
207
+ SELECT artifact_name, artifact_path
208
+ FROM artifacts
209
+ WHERE run_id = ?
210
+ """, (run_id,))
211
+ artifacts = {row["artifact_name"]: row["artifact_path"] for row in cursor.fetchall()}
212
+
213
+ # Build RunRecord
214
+ record = RunRecord(
215
+ run_id=row["run_id"],
216
+ experiment_name=row["experiment_name"],
217
+ params=json.loads(row["params"]),
218
+ backend_name=row["backend_name"],
219
+ timestamp=row["timestamp"],
220
+ density_matrix_path=row["density_matrix_path"],
221
+ artifacts=artifacts,
222
+ metadata=json.loads(row["metadata"]) if row["metadata"] else None
223
+ )
224
+ record.set_base_dir(self.base_dir)
225
+ return record
226
+
227
+ def list_runs(
228
+ self,
229
+ experiment_name: Optional[str] = None,
230
+ limit: Optional[int] = None
231
+ ) -> List[RunRecord]:
232
+ """
233
+ List run records, optionally filtered by experiment name.
234
+
235
+ Args:
236
+ experiment_name: Optional filter by experiment name.
237
+ limit: Optional maximum number of records to return.
238
+
239
+ Returns:
240
+ List of RunRecord objects, ordered by timestamp (newest first).
241
+ """
242
+ cursor = self.conn.cursor()
243
+
244
+ query = "SELECT run_id FROM runs"
245
+ params = []
246
+ if experiment_name:
247
+ query += " WHERE experiment_name = ?"
248
+ params.append(experiment_name)
249
+ query += " ORDER BY timestamp DESC"
250
+ if limit:
251
+ query += " LIMIT ?"
252
+ params.append(limit)
253
+
254
+ cursor.execute(query, params)
255
+ run_ids = [row["run_id"] for row in cursor.fetchall()]
256
+
257
+ records = []
258
+ for run_id in run_ids:
259
+ record = self.get_run(run_id)
260
+ if record:
261
+ records.append(record)
262
+
263
+ return records
264
+
265
+ def close(self) -> None:
266
+ """
267
+ Close the database connection.
268
+ """
269
+ self.conn.close()