Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: Build Docs # and Deploy

on:
push:
branches:
- main # every commit that lands on main triggers the job

# permissions:
# contents: write # Required to push to gh-pages branch
Expand Down
8 changes: 4 additions & 4 deletions readthedocs.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# readthedocs.yml
version: 2

build:
os: ubuntu-22.04
tools:
python: "3.12"
jobs:
pre_build:
- pip install poetry
- poetry install --with dev --with docs
build:
commands:
- pip install poetry
- poetry install

sphinx:
configuration: docs/conf.py
30 changes: 30 additions & 0 deletions src/quantum_executor/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ def __init__(self, circuit: Any, shots: int, configuration: dict[str, Any] | Non
self.shots: int = shots
self.configuration: dict[str, Any] = configuration or {}

def to_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the Job.

Returns
-------
Dict[str, Any]
Dictionary containing job details: id, circuit, shots, and configuration.

"""
return {
"id": self.id,
"circuit": self.circuit,
"shots": self.shots,
"configuration": self.configuration,
}

def __repr__(self) -> str:
"""Return a string representation of the Job.

Expand Down Expand Up @@ -203,3 +219,17 @@ def items(self) -> dict[str, dict[str, list[Job]]]:

"""
return self._jobs.copy()

def to_dict(self) -> DispatchDict:
"""Return a dictionary representation of the Dispatch.

Returns
-------
Dict[str, Dict[str, List[Dict[str, Any]]]]
Nested dictionary mapping provider -> backend -> list of job-info dicts.

"""
return {
provider: {backend: [job.to_dict() for job in jobs] for backend, jobs in backends.items()}
for provider, backends in self._jobs.items()
}
98 changes: 84 additions & 14 deletions src/quantum_executor/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import threading
from collections.abc import Callable
from collections.abc import Sequence
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import as_completed
from pathlib import Path
Expand Down Expand Up @@ -235,10 +236,79 @@ def __init__( # pylint: disable=too-many-arguments too-many-positional-argument
self._policies = load_policies_from_folder(self._policies_folder, raise_exc=self._raise_exc)
logger.info("QuantumExecutor initialized.")

def generate_dispatch( # pylint: disable=too-many-positional-arguments too-many-arguments too-many-locals
self,
circuits: Any | Sequence[Any], # noqa: ANN401
shots: int | Sequence[int],
backends: dict[str, list[str]],
split_policy: str = _default_split,
split_data: dict[str, Any] | None = None,
) -> tuple[Dispatch, dict[str, Any]]:
"""Split a circuit into jobs based on the specified split policy.

Parameters
----------
circuits : Any or Sequence[Any]
Quantum circuit or list of quantum circuits.
shots : int or Sequence[int]
Number of shots or list of numbers of shots.
If a list, it must match the length of `circuits`.
If a single int, all circuits will use the same number of shots.
backends : dict[str, list[str]]
Provider → list of backends.
split_policy : str, optional
Which split policy to use.
split_data : dict, optional
Initial data for split policy.

Returns
-------
tuple[Dispatch, dict[str, Any]]
A Dispatch object containing the jobs and any updated split data.
"""
if isinstance(circuits, Sequence):
circuits = list(circuits)
shots_list = [shots] * len(circuits) if isinstance(shots, int) else list(shots)

if len(shots_list) != len(circuits):
raise ValueError(
"When passing multiple circuits, shots must be a single int or a list of the same length."
)

split_fn = self.get_split_policy(split_policy)
aggregated: dict[str, dict[str, list[Any]]] = {}

split_data = split_data or {}
for circ, sh in zip(circuits, shots_list, strict=False):
disp_i, updated_split_data = split_fn(circ, sh, backends, self._virtual_provider, split_data)
split_data = updated_split_data
for prov, back_map in disp_i.to_dict().items():
agg_back_map = aggregated.setdefault(prov, {})
for back, jobs in back_map.items():
agg_back_map.setdefault(back, []).extend(jobs)

return Dispatch(aggregated), split_data or {}

if isinstance(shots, Sequence) and len(shots) > 1:
raise ValueError("When passing a single circuit, shots must be a single int, not a list.")
if isinstance(shots, Sequence):
shots = shots[0]

# Single-circuit path
split_fn = self.get_split_policy(split_policy)
split_data = split_data or {}
return split_fn( # type: ignore[no-any-return]
circuits,
shots,
backends,
self._virtual_provider,
split_data,
)

def run_experiment( # pylint: disable=too-many-positional-arguments too-many-arguments
self,
circuit: Any, # noqa: ANN401
shots: int,
circuits: Any | Sequence[Any], # noqa: ANN401
shots: int | Sequence[int],
backends: dict[str, list[str]],
split_policy: str = _default_split,
merge_policy: str | None = None,
Expand All @@ -252,10 +322,12 @@ def run_experiment( # pylint: disable=too-many-positional-arguments too-many-ar

Parameters
----------
circuit : Any
Quantum circuit.
shots : int
Number of shots.
circuits : Any or Sequence[Any]
Quantum circuit or list of quantum circuits.
shots : int or Sequence[int]
Number of shots or list of numbers of shots.
If a list, it must match the length of `circuits`.
If a single int, all circuits will use the same number of shots.
backends : dict[str, list[str]]
Provider → list of backends.
split_policy : str, optional
Expand Down Expand Up @@ -284,14 +356,12 @@ def run_experiment( # pylint: disable=too-many-positional-arguments too-many-ar
split_policy,
merge_policy,
)
split_fn = self.get_split_policy(split_policy)
split_data = split_data or {}
dispatch_obj, updated_split = split_fn(
circuit,
shots,
backends,
self._virtual_provider,
split_data,
dispatch_obj, updated_split = self.generate_dispatch(
circuits=circuits,
shots=shots,
backends=backends,
split_policy=split_policy,
split_data=split_data,
)

return self.run_dispatch(
Expand Down
111 changes: 105 additions & 6 deletions tests/test_quantum_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ def test_quantum_executor_run_experiment_sync(
backends = {"local_aer": ["aer_simulator"]}

collector = quantum_executor.run_experiment(
circuit=qc,
circuits=qc,
shots=100,
backends=backends,
split_policy="test_policy",
Expand Down Expand Up @@ -411,7 +411,7 @@ def test_quantum_executor_run_experiment_missing_policy(

with pytest.raises(KeyError, match="Split policy 'no_such_policy' not found"):
quantum_executor.run_experiment(
circuit=qc,
circuits=qc,
shots=100,
backends=backends,
split_policy="no_such_policy",
Expand All @@ -437,7 +437,7 @@ def test_quantum_executor_run_experiment_async_wait(

logging.warning("Starting quantum experiment with async execution.")
collector = quantum_executor.run_experiment(
circuit=qc,
circuits=qc,
shots=50,
backends=backends,
split_policy="test_policy",
Expand Down Expand Up @@ -470,7 +470,7 @@ def test_quantum_executor_run_experiment_async_no_wait(
backends = {"local_aer": ["aer_simulator"]}

collector = quantum_executor.run_experiment(
circuit=qc,
circuits=qc,
shots=25,
backends=backends,
split_policy="test_policy",
Expand Down Expand Up @@ -589,7 +589,7 @@ def test_quantum_executor_run_experiment_no_backends(
backends: dict[str, list[str]] = {} # Empty backends dictionary

collector = quantum_executor.run_experiment(
circuit=qc,
circuits=qc,
shots=10,
backends=backends,
split_policy="test_policy",
Expand Down Expand Up @@ -633,7 +633,7 @@ def test_quantum_executor_run_experiment_with_fake_backends(policy_name: str) ->
backends = {"local_aer": ["fake_torino", "fake_oslo"]}

collector = executor.run_experiment(
circuit=qc,
circuits=qc,
shots=51,
backends=backends,
split_policy=policy_name,
Expand Down Expand Up @@ -812,3 +812,102 @@ def test_dispatch_init_from_dict_multiple_jobs() -> None:
# Second job: provided configuration and autogenerated ID
assert jobs[1].shots == 10, "Second job shots should match the provided value."
assert jobs[1].configuration == {"a": 1}, "Second job configuration should match provided dict."


def test_generate_dispatch_single_circuit_shots_int(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Single circuit + integer shots should produce exactly one job."""
qc = QuantumCircuit(1, 1)
backends = {"prov": ["backend"]}
dispatch, split_data = quantum_executor.generate_dispatch(
circuits=qc, shots=10, backends=backends, split_policy="test_policy"
)
assert isinstance(dispatch, Dispatch)
jobs = list(dispatch.all_jobs())
assert len(jobs) == 1
prov, back, job = jobs[0]
assert prov == "prov"
assert back == "backend"
assert job.shots == 10
assert split_data == {}


def test_generate_dispatch_single_circuit_shots_list_length1(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Single circuit + shots=[n] should be treated as shots=n."""
qc = QuantumCircuit(1, 1)
backends = {"prov": ["backend"]}
initial_data = {"foo": "bar"}
dispatch, split_data = quantum_executor.generate_dispatch(
circuits=qc, shots=[15], backends=backends, split_policy="test_policy", split_data=initial_data
)
jobs = list(dispatch.all_jobs())
assert len(jobs) == 1
assert jobs[0][2].shots == 15
# initial split_data should be preserved
assert split_data is initial_data


def test_generate_dispatch_single_circuit_shots_list_error(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Single circuit + shots list of length>1 must raise ValueError."""
qc = QuantumCircuit(1, 1)
backends = {"prov": ["backend"]}
with pytest.raises(ValueError, match="single circuit, shots must be a single int"):
quantum_executor.generate_dispatch(circuits=qc, shots=[1, 2], backends=backends, split_policy="test_policy")


def test_generate_dispatch_multiple_circuits_shots_int(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Multiple circuits + int shots should produce one job per circuit."""
qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)]
backends = {"prov": ["backend"]}
dispatch, split_data = quantum_executor.generate_dispatch(
circuits=qcs, shots=20, backends=backends, split_policy="test_policy"
)
jobs = list(dispatch.all_jobs())
assert len(jobs) == 2
for _, _, job in jobs:
assert job.shots == 20
assert split_data == {}


def test_generate_dispatch_multiple_circuits_shots_list_matching(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Multiple circuits + shots list of matching length should use per-circuit shots."""
qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)]
backends = {"prov": ["backend"]}
initial_data = {"x": 1}
dispatch, split_data = quantum_executor.generate_dispatch(
circuits=qcs, shots=[5, 6], backends=backends, split_policy="test_policy", split_data=initial_data
)
jobs = list(dispatch.all_jobs())
assert len(jobs) == 2
assert jobs[0][2].shots == 5
assert jobs[1][2].shots == 6
assert split_data is initial_data


def test_generate_dispatch_multiple_circuits_shots_list_mismatch(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Multiple circuits + shots list of wrong length must raise ValueError."""
qcs = [QuantumCircuit(1, 1), QuantumCircuit(1, 1)]
backends = {"prov": ["backend"]}
with pytest.raises(ValueError, match="single int or a list of the same length"):
quantum_executor.generate_dispatch(circuits=qcs, shots=[10], backends=backends, split_policy="test_policy")


def test_generate_dispatch_no_backends(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Empty backends dict should yield zero jobs and empty split_data."""
qc = QuantumCircuit(1, 1)
dispatch, split_data = quantum_executor.generate_dispatch(
circuits=qc, shots=10, backends={}, split_policy="test_policy"
)
assert isinstance(dispatch, Dispatch)
assert not list(dispatch.all_jobs())
assert split_data == {}


def test_generate_dispatch_preserves_initial_split_data(quantum_executor: QuantumExecutor) -> None: # pylint: disable=redefined-outer-name
"""Whatever split_data you pass in should be returned unmodified."""
qc = QuantumCircuit(1, 1)
backends = {"prov": ["backend"]}
initial_data = {"keep": True}
_, split_data = quantum_executor.generate_dispatch(
circuits=qc, shots=10, backends=backends, split_policy="test_policy", split_data=initial_data
)
assert split_data is initial_data