diff --git a/.github/workflows/selene-plugins.yml b/.github/workflows/selene-plugins.yml index 20f9ba471..0d791de64 100644 --- a/.github/workflows/selene-plugins.yml +++ b/.github/workflows/selene-plugins.yml @@ -136,6 +136,8 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, macos-15-intel, windows-2022] plugin: + - name: pecos-selene-general-noise + package: pecos_selene_general_noise - name: pecos-selene-stabilizer package: pecos_selene_stabilizer - name: pecos-selene-statevec diff --git a/Cargo.lock b/Cargo.lock index 0786f279c..cef0105da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,6 +4790,18 @@ dependencies = [ "selene-core", ] +[[package]] +name = "pecos-selene-general-noise" +version = "0.2.0-dev.0" +dependencies = [ + "anyhow", + "pecos-core", + "pecos-engines", + "selene-core", + "serde", + "serde_json", +] + [[package]] name = "pecos-selene-mast" version = "0.2.0-dev.0" diff --git a/Cargo.toml b/Cargo.toml index 1609070b1..4f25c74e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "python/pecos-rslib-exp", "python/pecos-rslib-cuda", "python/pecos-rslib-llvm", + "python/selene-plugins/pecos-selene-general-noise", "python/selene-plugins/pecos-selene-mast", "python/selene-plugins/pecos-selene-stab-mps", "python/selene-plugins/pecos-selene-stab-vec", diff --git a/pyproject.toml b/pyproject.toml index 5a5f9569c..6e4f24775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ members = [ "python/pecos-rslib-cuda", "python/pecos-rslib-llvm", "python/quantum-pecos", + "python/selene-plugins/pecos-selene-general-noise", "python/selene-plugins/pecos-selene-mast", "python/selene-plugins/pecos-selene-stab-mps", "python/selene-plugins/pecos-selene-stab-vec", @@ -102,6 +103,7 @@ reinstall-package = [ "pecos-rslib-cuda", "pecos-rslib-exp", "pecos-rslib-llvm", + "pecos-selene-general-noise", ] [tool.black] diff --git a/python/selene-plugins/pecos-selene-general-noise/Cargo.toml b/python/selene-plugins/pecos-selene-general-noise/Cargo.toml new file mode 100644 index 000000000..49cf2b36a --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pecos-selene-general-noise" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +description = "PECOS general noise model plugin for the Selene quantum emulator" + +[lib] +name = "pecos_selene_general_noise" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +pecos-core = { workspace = true } +pecos-engines = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } + +[lints] +workspace = true diff --git a/python/selene-plugins/pecos-selene-general-noise/README.md b/python/selene-plugins/pecos-selene-general-noise/README.md new file mode 100644 index 000000000..ec94d4a48 --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/README.md @@ -0,0 +1,58 @@ +# PECOS General Noise Selene Plugin + +This package exposes PECOS's general noise model as a device-neutral Selene +error-model plugin. It contains no calibrated hardware preset: every error +channel is disabled until the user configures it. Consequently, +`GeneralNoiseParameters()` is guaranteed to produce a noiseless model. + +```python +from pecos_selene_general_noise import GeneralNoiseParameters, GeneralNoisePlugin + +parameters = ( + GeneralNoiseParameters() + .with_p_prep(1e-3) + .with_p_meas_0(2e-3) + .with_p_meas_1(3e-3) + .with_average_p1(1e-4) + .with_average_p2(1e-3) + .with_p_idle_linear(2e-3, {"Z": 1.0}) +) +noise = GeneralNoisePlugin(parameters=parameters, random_seed=7) +``` + +For a first experiment, `GeneralNoiseParameters.uniform(1e-3)` applies a +common process-infidelity/error probability to preparation, measurement, +one-qubit, and two-qubit operations. + +The fluent method names intentionally follow PECOS's Rust +`GeneralNoiseModelBuilder`. Parameter objects are immutable: every `with_*` +call returns a new validated configuration, making presets safe to reuse. +`with_seed` is intentionally not a parameter method because Selene owns and +supplies the per-shot error-model seed; set `random_seed` on +`GeneralNoisePlugin` instead. PECOS's historical `auto()` demonstration preset +is also omitted so that this package remains explicitly device-neutral. + +## Capabilities + +- process infidelity or average infidelity for gate errors +- custom Pauli and spontaneous-emission distributions +- leakage, seepage, and continuous leakage-to-depolarization scaling +- asymmetric readout errors +- linear stochastic, sine-squared stochastic, and coherent idle noise +- angle-dependent two-qubit noise and post-two-qubit idle sites +- all-to-all and topology-defined local crosstalk +- per-family and global scaling, plus arbitrary PECOS noiseless gate names + +Idle rates use seconds because the adapter converts Selene's nanosecond schedule +to the units expected by PECOS. Local crosstalk is described with neutral qubit +groups rather than a hard-coded device layout. + +## Development + +From the PECOS repository root: + +```bash +uv run --package pecos-selene-general-noise pytest \ + python/selene-plugins/pecos-selene-general-noise/tests +cargo test -p pecos-selene-general-noise +``` diff --git a/python/selene-plugins/pecos-selene-general-noise/hatch_build.py b/python/selene-plugins/pecos-selene-general-noise/hatch_build.py new file mode 100644 index 000000000..11011775e --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/hatch_build.py @@ -0,0 +1,59 @@ +"""Build and bundle the native Selene error-model plugin.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + + +class PecosSeleneGeneralNoiseBuildHook(BuildHookInterface): + """Compile the Rust cdylib and add it to the platform wheel.""" + + def _set_wheel_tag(self, build_data: dict[str, Any]) -> None: + build_data["pure_python"] = False + tag = next(tag for tag in sys_tags() if "manylinux" not in tag.platform and "musllinux" not in tag.platform) + target_platform = tag.platform + if sys.platform == "darwin": + from hatchling.builders.macos import process_macos_plat_tag + + target_platform = process_macos_plat_tag(target_platform, compat=False) + build_data["tag"] = f"py3-none-{target_platform}" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + """Build the native library unless an existing artifact is available.""" + root = Path(self.root) + dist_dir = root / "python" / "pecos_selene_general_noise" / "_dist" + lib_dir = dist_dir / "lib" + + system = platform.system() + if system == "Linux": + prefix, suffix = "lib", ".so" + elif system == "Darwin": + prefix, suffix = "lib", ".dylib" + elif system == "Windows": + prefix, suffix = "", ".dll" + else: + message = f"Unsupported platform: {system}" + raise RuntimeError(message) + + workspace_root = root.parent.parent.parent + subprocess.run( + ["cargo", "build", "--release", "--package", "pecos-selene-general-noise"], + cwd=workspace_root, + check=True, + ) + filename = f"{prefix}pecos_selene_general_noise{suffix}" + source = workspace_root / "target" / "release" / filename + lib_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, lib_dir / filename) + build_data["artifacts"] += [ + artifact.relative_to(root).as_posix() for artifact in dist_dir.rglob("*") if artifact.is_file() + ] + self._set_wheel_tag(build_data) diff --git a/python/selene-plugins/pecos-selene-general-noise/pyproject.toml b/python/selene-plugins/pecos-selene-general-noise/pyproject.toml new file mode 100644 index 000000000..3615f931f --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "pecos-selene-general-noise" +version = "0.10.0.dev0" +requires-python = ">=3.10" +description = "PECOS general noise model plugin for the Selene quantum emulator" +readme = "README.md" +license = "Apache-2.0" +dependencies = ["selene-core>=0.2"] + +[project.optional-dependencies] +test = [ + "pytest>=9.0", + "selene-sim>=0.2,<0.3", + "guppylang>=0.14,<1.0", +] + +[project.urls] +homepage = "https://pecos.io" +repository = "https://github.com/PECOS-packages/PECOS" + +[build-system] +requires = ["hatchling", "packaging"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["python/pecos_selene_general_noise"] + +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + +[tool.uv] +cache-keys = [ + { file = "src/**/*.rs" }, + { file = "Cargo.toml" }, +] diff --git a/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/__init__.py b/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/__init__.py new file mode 100644 index 000000000..b14de49e9 --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/__init__.py @@ -0,0 +1,23 @@ +"""PECOS general-noise plugin for Selene.""" + +from pecos_selene_general_noise.plugin import ( + GateNoise, + GeneralNoiseParameters, + GeneralNoisePlugin, + IdleNoise, + MeasurementNoise, + NoiseScaling, + PreparationNoise, + TwoQubitGateNoise, +) + +__all__ = [ + "GateNoise", + "GeneralNoiseParameters", + "GeneralNoisePlugin", + "IdleNoise", + "MeasurementNoise", + "NoiseScaling", + "PreparationNoise", + "TwoQubitGateNoise", +] diff --git a/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/plugin.py b/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/plugin.py new file mode 100644 index 000000000..68c795812 --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/python/pecos_selene_general_noise/plugin.py @@ -0,0 +1,502 @@ +"""User-facing configuration for the PECOS general noise model.""" + +from __future__ import annotations + +import json +import math +import platform +from dataclasses import asdict, dataclass, field, replace +from pathlib import Path +from typing import ClassVar + +from selene_core import ErrorModel + +Distribution = dict[str, float] +ONE_QUBIT_PAULIS = frozenset({"X", "Y", "Z"}) +ONE_QUBIT_CHANNELS = ONE_QUBIT_PAULIS | {"L"} + + +def _probability(name: str, value: float | None) -> None: + if value is not None and (not math.isfinite(value) or not 0.0 <= value <= 1.0): + message = f"{name} must be a finite probability between 0 and 1" + raise ValueError(message) + + +def _non_negative(name: str, value: float | None) -> None: + if value is not None and (not math.isfinite(value) or value < 0.0): + message = f"{name} must be finite and non-negative" + raise ValueError(message) + + +def _positive(name: str, value: float | None) -> None: + if value is not None and (not math.isfinite(value) or value <= 0.0): + message = f"{name} must be finite and positive" + raise ValueError(message) + + +def _distribution( + name: str, + value: Distribution | None, + allowed: frozenset[str], + *, + normalized: bool = True, +) -> None: + if value is None: + return + invalid = set(value) - allowed + if invalid: + message = f"{name} contains unsupported entries: {sorted(invalid)}" + raise ValueError(message) + for key, weight in value.items(): + _non_negative(f"{name}[{key!r}]", weight) + if normalized and value and not math.isclose(sum(value.values()), 1.0, abs_tol=1e-9): + message = f"{name} weights must sum to 1" + raise ValueError(message) + + +@dataclass(frozen=True) +class PreparationNoise: + """Preparation faults and all-to-all preparation crosstalk.""" + + probability: float | None = None + leakage_ratio: float | None = None + crosstalk_probability: float | None = None + average_crosstalk_probability: float | None = None + scale: float | None = None + crosstalk_scale: float | None = None + + def validate(self) -> None: + """Validate preparation parameters.""" + _probability("preparation.probability", self.probability) + _probability("preparation.leakage_ratio", self.leakage_ratio) + _probability("preparation.crosstalk_probability", self.crosstalk_probability) + _probability("preparation.average_crosstalk_probability", self.average_crosstalk_probability) + if self.crosstalk_probability is not None and self.average_crosstalk_probability is not None: + message = "preparation accepts crosstalk_probability or average_crosstalk_probability, not both" + raise ValueError(message) + if self.average_crosstalk_probability is not None and self.average_crosstalk_probability > 5.0 / 18.0: + message = "preparation.average_crosstalk_probability cannot exceed 5/18" + raise ValueError(message) + _non_negative("preparation.scale", self.scale) + _non_negative("preparation.crosstalk_scale", self.crosstalk_scale) + + +@dataclass(frozen=True) +class GateNoise: + """Noise shared by one- and two-qubit gates. + + Set either ``probability`` (process infidelity) or ``average_infidelity``. + """ + + probability: float | None = None + average_infidelity: float | None = None + pauli_model: Distribution | None = None + emission_ratio: float | None = None + emission_model: Distribution | None = None + seepage_probability: float | None = None + scale: float | None = None + + _paulis: ClassVar[frozenset[str]] = ONE_QUBIT_PAULIS + _emissions: ClassVar[frozenset[str]] = ONE_QUBIT_CHANNELS + + def validate(self, name: str = "single_qubit") -> None: + """Validate gate-channel parameters.""" + if self.probability is not None and self.average_infidelity is not None: + message = f"{name} accepts probability or average_infidelity, not both" + raise ValueError(message) + _probability(f"{name}.probability", self.probability) + _probability(f"{name}.average_infidelity", self.average_infidelity) + _probability(f"{name}.emission_ratio", self.emission_ratio) + _probability(f"{name}.seepage_probability", self.seepage_probability) + _non_negative(f"{name}.scale", self.scale) + _distribution(f"{name}.pauli_model", self.pauli_model, self._paulis) + _distribution(f"{name}.emission_model", self.emission_model, self._emissions) + if name == "single_qubit" and self.average_infidelity is not None and self.average_infidelity > 2.0 / 3.0: + message = "single_qubit.average_infidelity cannot exceed 2/3" + raise ValueError(message) + + +@dataclass(frozen=True) +class TwoQubitGateNoise(GateNoise): + """Two-qubit gate noise, including optional angle-dependent scaling.""" + + angle_coefficients: tuple[float, float, float, float] | None = None + angle_power: float | None = None + idle_after_gate: float | None = None + + _paulis: ClassVar[frozenset[str]] = frozenset(a + b for a in "IXYZ" for b in "IXYZ" if a + b != "II") + _emissions: ClassVar[frozenset[str]] = frozenset(a + b for a in "IXYZL" for b in "IXYZL" if a + b != "II") + + def validate(self, name: str = "two_qubit") -> None: + """Validate two-qubit channel parameters.""" + super().validate(name) + if self.angle_coefficients is not None and ( + len(self.angle_coefficients) != 4 or not all(math.isfinite(value) for value in self.angle_coefficients) + ): + message = "two_qubit.angle_coefficients must contain four finite values" + raise ValueError(message) + if self.average_infidelity is not None and self.average_infidelity > 0.8: + message = "two_qubit.average_infidelity cannot exceed 0.8" + raise ValueError(message) + _positive("two_qubit.angle_power", self.angle_power) + _non_negative("two_qubit.idle_after_gate", self.idle_after_gate) + + +@dataclass(frozen=True) +class IdleNoise: + """Time-dependent idle channels; rates use Selene's seconds-based schedule.""" + + linear_rate: float | None = None + linear_model: Distribution | None = None + sin_squared_rate: float | None = None + sin_squared_model: Distribution | None = None + coherent_rate: float | None = None + coherent_model: Distribution | None = None + scale: float | None = None + + def validate(self) -> None: + """Validate idle-channel parameters.""" + for name in ("linear_rate", "sin_squared_rate", "coherent_rate", "scale"): + _non_negative(f"idle.{name}", getattr(self, name)) + _distribution("idle.linear_model", self.linear_model, ONE_QUBIT_CHANNELS) + _distribution( + "idle.sin_squared_model", + self.sin_squared_model, + ONE_QUBIT_CHANNELS, + normalized=False, + ) + _distribution( + "idle.coherent_model", + self.coherent_model, + frozenset({"RX", "RY", "RZ"}), + normalized=False, + ) + + +@dataclass(frozen=True) +class MeasurementNoise: + """Readout noise and optional topology-aware measurement crosstalk. + + ``local_groups`` describes device-neutral neighborhoods. Each qubit in a group + is local to the other members. Global crosstalk applies to every prepared qubit + outside the measured set. + """ + + p0_to_1: float | None = None + p1_to_0: float | None = None + global_crosstalk_probability: float | None = None + local_crosstalk_probability: float | None = None + crosstalk_model: Distribution | None = None + local_groups: tuple[tuple[int, ...], ...] = () + scale: float | None = None + crosstalk_scale: float | None = None + + def validate(self) -> None: + """Validate measurement-channel parameters.""" + for name in ( + "p0_to_1", + "p1_to_0", + "global_crosstalk_probability", + "local_crosstalk_probability", + ): + _probability(f"measurement.{name}", getattr(self, name)) + _non_negative("measurement.scale", self.scale) + _non_negative("measurement.crosstalk_scale", self.crosstalk_scale) + transitions = self.crosstalk_model + _distribution( + "measurement.crosstalk_model", + transitions, + frozenset({"0->0", "0->1", "0->L", "1->0", "1->1", "1->L"}), + normalized=False, + ) + if transitions is not None: + for source in ("0", "1"): + total = sum(weight for key, weight in transitions.items() if key.startswith(f"{source}->")) + if not math.isclose(total, 1.0, abs_tol=1e-9): + message = f"measurement.crosstalk_model {source}->* weights must sum to 1" + raise ValueError(message) + if any(qubit < 0 for group in self.local_groups for qubit in group): + message = "measurement.local_groups cannot contain negative qubit indices" + raise ValueError(message) + + +@dataclass(frozen=True) +class NoiseScaling: + """Global controls that apply across noise families.""" + + overall: float | None = None + leakage: float | None = None + emission: float | None = None + + def validate(self) -> None: + """Validate scale factors.""" + _non_negative("scaling.overall", self.overall) + _probability("scaling.leakage", self.leakage) + _non_negative("scaling.emission", self.emission) + + +@dataclass(frozen=True) +class GeneralNoiseParameters: + """Immutable, fluent configuration for PECOS ``GeneralNoiseModel``.""" + + preparation: PreparationNoise = field(default_factory=PreparationNoise) + measurement: MeasurementNoise = field(default_factory=MeasurementNoise) + single_qubit: GateNoise = field(default_factory=GateNoise) + two_qubit: TwoQubitGateNoise = field(default_factory=TwoQubitGateNoise) + idle: IdleNoise = field(default_factory=IdleNoise) + scaling: NoiseScaling = field(default_factory=NoiseScaling) + noiseless_gates: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Validate the complete configuration after construction.""" + self.preparation.validate() + self.measurement.validate() + self.single_qubit.validate() + self.two_qubit.validate() + self.idle.validate() + self.scaling.validate() + + @classmethod + def uniform(cls, probability: float) -> GeneralNoiseParameters: + """Create a simple device-neutral model with one common error probability.""" + _probability("probability", probability) + return cls( + preparation=PreparationNoise(probability=probability), + measurement=MeasurementNoise(p0_to_1=probability, p1_to_0=probability), + single_qubit=GateNoise(probability=probability), + two_qubit=TwoQubitGateNoise(probability=probability), + ) + + def _with_preparation(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, preparation=replace(self.preparation, **changes)) + + def _with_measurement(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, measurement=replace(self.measurement, **changes)) + + def _with_single_qubit(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, single_qubit=replace(self.single_qubit, **changes)) + + def _with_two_qubit(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, two_qubit=replace(self.two_qubit, **changes)) + + def _with_idle(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, idle=replace(self.idle, **changes)) + + def _with_scaling(self, **changes: object) -> GeneralNoiseParameters: + return replace(self, scaling=replace(self.scaling, **changes)) + + def with_noiseless_gate(self, gate: str) -> GeneralNoiseParameters: + """Add a gate parsed by PECOS to the noiseless gate set.""" + return replace(self, noiseless_gates=(*self.noiseless_gates, gate)) + + def with_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the overall noise scale.""" + return self._with_scaling(overall=scale) + + def with_leakage_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the fraction of leakage events retained as leakage.""" + return self._with_scaling(leakage=scale) + + def with_emission_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the spontaneous-emission scale.""" + return self._with_scaling(emission=scale) + + def with_seepage_prob(self, probability: float) -> GeneralNoiseParameters: + """Set both one- and two-qubit seepage probabilities.""" + return replace( + self, + single_qubit=replace(self.single_qubit, seepage_probability=probability), + two_qubit=replace(self.two_qubit, seepage_probability=probability), + ) + + def with_p_idle_linear(self, rate: float, model: Distribution) -> GeneralNoiseParameters: + """Set linear stochastic idle noise.""" + return self._with_idle(linear_rate=rate, linear_model=dict(model)) + + def with_p_idle_sin_squared(self, rate: float, model: Distribution) -> GeneralNoiseParameters: + """Set independent sine-squared idle channels.""" + return self._with_idle(sin_squared_rate=rate, sin_squared_model=dict(model)) + + def with_p_idle_coherent(self, rate: float, model: Distribution) -> GeneralNoiseParameters: + """Set coherent idle rotations.""" + return self._with_idle(coherent_rate=rate, coherent_model=dict(model)) + + def with_idle_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the idle-noise scale.""" + return self._with_idle(scale=scale) + + def with_p_prep(self, probability: float) -> GeneralNoiseParameters: + """Set preparation fault probability.""" + return self._with_preparation(probability=probability) + + def with_prep_leak_ratio(self, ratio: float) -> GeneralNoiseParameters: + """Set the preparation leakage ratio.""" + return self._with_preparation(leakage_ratio=ratio) + + def with_p_prep_crosstalk(self, probability: float) -> GeneralNoiseParameters: + """Set preparation crosstalk process probability.""" + return self._with_preparation( + crosstalk_probability=probability, + average_crosstalk_probability=None, + ) + + def with_average_p_prep_crosstalk(self, probability: float) -> GeneralNoiseParameters: + """Set preparation crosstalk average probability.""" + return self._with_preparation( + crosstalk_probability=None, + average_crosstalk_probability=probability, + ) + + def with_prep_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the preparation-noise scale.""" + return self._with_preparation(scale=scale) + + def with_p_prep_crosstalk_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the preparation-crosstalk scale.""" + return self._with_preparation(crosstalk_scale=scale) + + def with_p1(self, probability: float) -> GeneralNoiseParameters: + """Set one-qubit process infidelity.""" + return self._with_single_qubit(probability=probability, average_infidelity=None) + + def with_average_p1(self, probability: float) -> GeneralNoiseParameters: + """Set one-qubit average infidelity.""" + return self._with_single_qubit(probability=None, average_infidelity=probability) + + def with_p1_emission_ratio(self, ratio: float) -> GeneralNoiseParameters: + """Set the one-qubit spontaneous-emission ratio.""" + return self._with_single_qubit(emission_ratio=ratio) + + def with_p1_emission_model(self, model: Distribution) -> GeneralNoiseParameters: + """Set the one-qubit emission distribution.""" + return self._with_single_qubit(emission_model=dict(model)) + + def with_p1_seepage_prob(self, probability: float) -> GeneralNoiseParameters: + """Set one-qubit seepage probability.""" + return self._with_single_qubit(seepage_probability=probability) + + def with_p1_pauli_model(self, model: Distribution) -> GeneralNoiseParameters: + """Set the one-qubit Pauli distribution.""" + return self._with_single_qubit(pauli_model=dict(model)) + + def with_p1_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the one-qubit noise scale.""" + return self._with_single_qubit(scale=scale) + + def with_p2(self, probability: float) -> GeneralNoiseParameters: + """Set two-qubit process infidelity.""" + return self._with_two_qubit(probability=probability, average_infidelity=None) + + def with_average_p2(self, probability: float) -> GeneralNoiseParameters: + """Set two-qubit average infidelity.""" + return self._with_two_qubit(probability=None, average_infidelity=probability) + + def with_p2_angle_params(self, a: float, b: float, c: float, d: float) -> GeneralNoiseParameters: + """Set signed angle-dependent two-qubit coefficients.""" + return self._with_two_qubit(angle_coefficients=(a, b, c, d)) + + def with_p2_angle_power(self, power: float) -> GeneralNoiseParameters: + """Set the angle-dependent two-qubit exponent.""" + return self._with_two_qubit(angle_power=power) + + def with_p2_emission_ratio(self, ratio: float) -> GeneralNoiseParameters: + """Set the two-qubit spontaneous-emission ratio.""" + return self._with_two_qubit(emission_ratio=ratio) + + def with_p2_emission_model(self, model: Distribution) -> GeneralNoiseParameters: + """Set the two-qubit emission distribution.""" + return self._with_two_qubit(emission_model=dict(model)) + + def with_p2_seepage_prob(self, probability: float) -> GeneralNoiseParameters: + """Set two-qubit seepage probability.""" + return self._with_two_qubit(seepage_probability=probability) + + def with_p2_pauli_model(self, model: Distribution) -> GeneralNoiseParameters: + """Set the two-qubit Pauli distribution.""" + return self._with_two_qubit(pauli_model=dict(model)) + + def with_idle_after_2q(self, duration: float) -> GeneralNoiseParameters: + """Set the idle duration applied after two-qubit gates.""" + return self._with_two_qubit(idle_after_gate=duration) + + def with_p2_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the two-qubit noise scale.""" + return self._with_two_qubit(scale=scale) + + def with_p_meas_0(self, probability: float) -> GeneralNoiseParameters: + """Set readout probability for 0 becoming 1.""" + return self._with_measurement(p0_to_1=probability) + + def with_p_meas_1(self, probability: float) -> GeneralNoiseParameters: + """Set readout probability for 1 becoming 0.""" + return self._with_measurement(p1_to_0=probability) + + def with_p_meas(self, probability: float) -> GeneralNoiseParameters: + """Set symmetric readout probability.""" + return self._with_measurement(p0_to_1=probability, p1_to_0=probability) + + def with_p_meas_crosstalk_global(self, probability: float) -> GeneralNoiseParameters: + """Set global measurement-crosstalk probability.""" + return self._with_measurement(global_crosstalk_probability=probability) + + def with_p_meas_crosstalk_local(self, probability: float) -> GeneralNoiseParameters: + """Set local measurement-crosstalk probability.""" + return self._with_measurement(local_crosstalk_probability=probability) + + def with_p_meas_crosstalk(self, probability: float) -> GeneralNoiseParameters: + """Set both global and local measurement-crosstalk probabilities.""" + return self._with_measurement( + global_crosstalk_probability=probability, + local_crosstalk_probability=probability, + ) + + def with_p_meas_crosstalk_model(self, model: Distribution) -> GeneralNoiseParameters: + """Set measurement-crosstalk transition distributions.""" + return self._with_measurement(crosstalk_model=dict(model)) + + def with_meas_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the measurement-noise scale.""" + return self._with_measurement(scale=scale) + + def with_p_meas_crosstalk_scale(self, scale: float) -> GeneralNoiseParameters: + """Set the measurement-crosstalk scale.""" + return self._with_measurement(crosstalk_scale=scale) + + def with_local_crosstalk_groups(self, *groups: tuple[int, ...]) -> GeneralNoiseParameters: + """Set device-neutral local-crosstalk neighborhoods for the Selene adapter.""" + return self._with_measurement(local_groups=tuple(tuple(group) for group in groups)) + + +@dataclass +class GeneralNoisePlugin(ErrorModel): + """Thin Selene wrapper around PECOS general-noise parameters.""" + + parameters: GeneralNoiseParameters = field(default_factory=GeneralNoiseParameters) + random_seed: int | None = None + + def __post_init__(self) -> None: + """Validate Selene-owned plugin options.""" + if self.random_seed is not None and self.random_seed < 0: + message = "random_seed must be non-negative" + raise ValueError(message) + + def get_init_args(self) -> list[str]: + """Serialize the complete configuration as one versionable JSON argument.""" + return [json.dumps(asdict(self.parameters), separators=(",", ":"), sort_keys=True)] + + @property + def library_file(self) -> Path: + """Return the bundled native Selene plugin library.""" + libdir = Path(__file__).parent / "_dist" / "lib" + system = platform.system().lower() + if system == "darwin": + patterns = ["libpecos_selene_general_noise*.dylib"] + elif system == "windows": + patterns = ["pecos_selene_general_noise*.dll", "pecos_selene_general_noise*.pyd"] + else: + patterns = ["libpecos_selene_general_noise*.so"] + for pattern in patterns: + if matches := sorted(libdir.glob(pattern)): + return matches[0] + message = f"Could not find PECOS general-noise library in {libdir}" + raise FileNotFoundError(message) diff --git a/python/selene-plugins/pecos-selene-general-noise/src/lib.rs b/python/selene-plugins/pecos-selene-general-noise/src/lib.rs new file mode 100644 index 000000000..71039744f --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/src/lib.rs @@ -0,0 +1,638 @@ +//! Device-neutral Selene adapter for PECOS's general noise model. + +mod simulator; + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsStr; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, Result, anyhow, bail}; +use pecos_core::Angle64; +use pecos_core::gate_type::GateType; +use pecos_engines::prelude::*; +use selene_core::error_model::BatchResult; +use selene_core::error_model::interface::{ErrorModelInterface, ErrorModelInterfaceFactory}; +use selene_core::export_error_model_plugin; +use selene_core::runtime::{BatchOperation, Operation}; +use selene_core::simulator::Simulator; +use selene_core::utils::MetricValue; +use serde::Deserialize; + +use crate::simulator::SeleneSimulator; + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PreparationConfig { + probability: Option, + leakage_ratio: Option, + crosstalk_probability: Option, + average_crosstalk_probability: Option, + scale: Option, + crosstalk_scale: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct GateConfig { + probability: Option, + average_infidelity: Option, + pauli_model: Option>, + emission_ratio: Option, + emission_model: Option>, + seepage_probability: Option, + scale: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct TwoQubitConfig { + probability: Option, + average_infidelity: Option, + pauli_model: Option>, + emission_ratio: Option, + emission_model: Option>, + seepage_probability: Option, + scale: Option, + angle_coefficients: Option<[f64; 4]>, + angle_power: Option, + idle_after_gate: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct IdleConfig { + linear_rate: Option, + linear_model: Option>, + sin_squared_rate: Option, + sin_squared_model: Option>, + coherent_rate: Option, + coherent_model: Option>, + scale: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct MeasurementConfig { + p0_to_1: Option, + p1_to_0: Option, + global_crosstalk_probability: Option, + local_crosstalk_probability: Option, + crosstalk_model: Option>, + local_groups: Vec>, + scale: Option, + crosstalk_scale: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ScalingConfig { + overall: Option, + leakage: Option, + emission: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Config { + preparation: PreparationConfig, + measurement: MeasurementConfig, + single_qubit: GateConfig, + two_qubit: TwoQubitConfig, + idle: IdleConfig, + scaling: ScalingConfig, + noiseless_gates: Vec, +} + +impl Config { + fn build_model(&self) -> Result> { + catch_unwind(AssertUnwindSafe(|| { + let mut builder = GeneralNoiseModel::builder(); + + if let Some(value) = self.scaling.overall { + builder = builder.with_scale(value); + } + if let Some(value) = self.scaling.leakage { + builder = builder.with_leakage_scale(value); + } + if let Some(value) = self.scaling.emission { + builder = builder.with_emission_scale(value); + } + for name in &self.noiseless_gates { + let gate = GateType::from_str(name) + .unwrap_or_else(|message| panic!("invalid noiseless gate {name:?}: {message}")); + builder = builder.with_noiseless_gate(gate); + } + + let prep = &self.preparation; + assert!( + prep.crosstalk_probability.is_none() + || prep.average_crosstalk_probability.is_none(), + "preparation accepts crosstalk_probability or average_crosstalk_probability, not both" + ); + if let Some(value) = prep.probability { + builder = builder.with_p_prep(value); + } + if let Some(value) = prep.leakage_ratio { + builder = builder.with_prep_leak_ratio(value); + } + if let Some(value) = prep.crosstalk_probability { + builder = builder.with_p_prep_crosstalk(value); + } + if let Some(value) = prep.average_crosstalk_probability { + assert!( + value.is_finite() && (0.0..=5.0 / 18.0).contains(&value), + "average preparation crosstalk probability must be between 0 and 5/18" + ); + builder = builder.with_average_p_prep_crosstalk(value); + } + if let Some(value) = prep.scale { + builder = builder.with_prep_scale(value); + } + if let Some(value) = prep.crosstalk_scale { + builder = builder.with_p_prep_crosstalk_scale(value); + } + + let one = &self.single_qubit; + assert!( + one.probability.is_none() || one.average_infidelity.is_none(), + "single_qubit accepts probability or average_infidelity, not both" + ); + if let Some(value) = one.probability { + builder = builder.with_p1(value); + } + if let Some(value) = one.average_infidelity { + builder = builder.with_average_p1(value); + } + if let Some(value) = &one.pauli_model { + builder = builder.with_p1_pauli_model(value); + } + if let Some(value) = one.emission_ratio { + builder = builder.with_p1_emission_ratio(value); + } + if let Some(value) = &one.emission_model { + builder = builder.with_p1_emission_model(value); + } + if let Some(value) = one.seepage_probability { + builder = builder.with_p1_seepage_prob(value); + } + if let Some(value) = one.scale { + builder = builder.with_p1_scale(value); + } + + let two = &self.two_qubit; + assert!( + two.probability.is_none() || two.average_infidelity.is_none(), + "two_qubit accepts probability or average_infidelity, not both" + ); + if let Some(value) = two.probability { + builder = builder.with_p2(value); + } + if let Some(value) = two.average_infidelity { + builder = builder.with_average_p2(value); + } + if let Some(value) = &two.pauli_model { + builder = builder.with_p2_pauli_model(value); + } + if let Some(value) = two.emission_ratio { + builder = builder.with_p2_emission_ratio(value); + } + if let Some(value) = &two.emission_model { + builder = builder.with_p2_emission_model(value); + } + if let Some(value) = two.seepage_probability { + builder = builder.with_p2_seepage_prob(value); + } + if let Some(value) = two.scale { + builder = builder.with_p2_scale(value); + } + if let Some([a, b, c, d]) = two.angle_coefficients { + builder = builder.with_p2_angle_params(a, b, c, d); + } + if let Some(value) = two.angle_power { + builder = builder.with_p2_angle_power(value); + } + if let Some(value) = two.idle_after_gate { + builder = builder.with_idle_after_2q(value); + } + + let idle = &self.idle; + if let Some(rate) = idle.linear_rate { + let model = idle.linear_model.clone().unwrap_or_else(|| { + BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]) + }); + builder = builder.with_p_idle_linear(rate, &model); + } else if idle.linear_model.is_some() { + panic!("idle.linear_model requires idle.linear_rate"); + } + if let Some(rate) = idle.sin_squared_rate { + let model = idle + .sin_squared_model + .clone() + .unwrap_or_else(|| BTreeMap::from([("Z".to_string(), 1.0)])); + builder = builder.with_p_idle_sin_squared(rate, &model); + } else if idle.sin_squared_model.is_some() { + panic!("idle.sin_squared_model requires idle.sin_squared_rate"); + } + if let Some(rate) = idle.coherent_rate { + let model = idle + .coherent_model + .clone() + .unwrap_or_else(|| BTreeMap::from([("RZ".to_string(), 1.0)])); + builder = builder.with_p_idle_coherent(rate, &model); + } else if idle.coherent_model.is_some() { + panic!("idle.coherent_model requires idle.coherent_rate"); + } + if let Some(value) = idle.scale { + builder = builder.with_idle_scale(value); + } + + let measurement = &self.measurement; + if let Some(value) = measurement.p0_to_1 { + builder = builder.with_p_meas_0(value); + } + if let Some(value) = measurement.p1_to_0 { + builder = builder.with_p_meas_1(value); + } + if let Some(value) = measurement.global_crosstalk_probability { + builder = builder.with_p_meas_crosstalk_global(value); + } + if let Some(value) = measurement.local_crosstalk_probability { + builder = builder.with_p_meas_crosstalk_local(value); + } + if let Some(value) = &measurement.crosstalk_model { + builder = builder.with_p_meas_crosstalk_model(value); + } + if let Some(value) = measurement.scale { + builder = builder.with_meas_scale(value); + } + if let Some(value) = measurement.crosstalk_scale { + builder = builder.with_p_meas_crosstalk_scale(value); + } + + Box::new(builder.build()) as Box + })) + .map_err(|payload| { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("unknown validation failure"); + anyhow!("invalid general-noise configuration: {message}") + }) + } +} + +#[derive(Clone, Copy)] +enum MeasurementKind { + Bool, + Leakage, +} + +struct MeasurementResult { + id: u64, + kind: MeasurementKind, +} + +struct GeneralNoiseErrorModel { + system: QuantumSystem, + builder: ByteMessageBuilder, + last_operation_end: Vec, + local_groups: Vec>, +} + +impl GeneralNoiseErrorModel { + fn new( + model: Box, + simulator: Simulator, + n_qubits: usize, + config: &Config, + ) -> Result { + for group in &config.measurement.local_groups { + if let Some(qubit) = group.iter().find(|qubit| **qubit >= n_qubits) { + bail!( + "local crosstalk group contains qubit {qubit}, but the simulation has {n_qubits} qubits" + ); + } + } + Ok(Self { + system: QuantumSystem::new(model, Box::new(SeleneSimulator::new(simulator))), + builder: ByteMessage::quantum_operations_builder(), + last_operation_end: vec![0; n_qubits], + local_groups: config + .measurement + .local_groups + .iter() + .map(|group| group.iter().copied().collect()) + .collect(), + }) + } + + fn qubit(&self, id: u64) -> Result { + let qubit = usize::try_from(id).context("qubit index does not fit usize")?; + if qubit >= self.last_operation_end.len() { + bail!( + "qubit {id} is out of bounds for {} qubits", + self.last_operation_end.len() + ); + } + Ok(qubit) + } + + fn add_idle_before(&mut self, qubit: usize, start: u64) -> Result<()> { + let previous = self.last_operation_end[qubit]; + if start < previous { + bail!( + "operation on qubit {qubit} starts at {start}ns before its previous operation ended at {previous}ns" + ); + } + if start > previous { + let seconds = std::time::Duration::from_nanos(start - previous).as_secs_f64(); + self.builder.idle(seconds, &[qubit]); + } + Ok(()) + } + + fn local_victims(&self, measured: &BTreeSet) -> Vec { + self.local_groups + .iter() + .filter(|group| !group.is_disjoint(measured)) + .flat_map(BTreeSet::iter) + .filter(|qubit| !measured.contains(qubit)) + .copied() + .collect::>() + .into_iter() + .collect() + } + + fn simulator_mut(&mut self) -> Result<&mut SeleneSimulator> { + self.system + .quantum_engine_mut() + .as_any_mut() + .downcast_mut::() + .ok_or_else(|| anyhow!("internal Selene simulator adapter has the wrong type")) + } +} + +impl ErrorModelInterface for GeneralNoiseErrorModel { + fn exit(&mut self) -> Result<()> { + Ok(()) + } + + fn shot_start(&mut self, shot_id: u64, error_seed: u64, simulator_seed: u64) -> Result<()> { + self.system.noise_model_mut().set_seed(error_seed); + self.simulator_mut()? + .shot_start(shot_id, simulator_seed) + .map_err(|error| anyhow!(error.to_string()))?; + self.last_operation_end.fill(0); + self.builder.reset(); + let _ = self.builder.for_quantum_operations(); + Ok(()) + } + + fn shot_end(&mut self) -> Result<()> { + self.system + .reset() + .map_err(|error| anyhow!(error.to_string()))?; + self.simulator_mut()? + .shot_end() + .map_err(|error| anyhow!(error.to_string())) + } + + fn dump_simulator_state(&mut self, file: &std::path::Path, qubits: &[u64]) -> Result<()> { + self.simulator_mut()?.dump_state(file, qubits) + } + + fn handle_operations(&mut self, operations: BatchOperation) -> Result { + let start: u64 = operations.start().into(); + let end: u64 = operations.end().into(); + let measured = operations + .iter_ops() + .filter_map(|operation| match operation { + Operation::Measure { qubit_id, .. } | Operation::MeasureLeaked { qubit_id, .. } => { + usize::try_from(*qubit_id).ok() + } + _ => None, + }) + .collect::>(); + + if !measured.is_empty() { + self.builder + .meas_crosstalk_global_payload(&measured.iter().copied().collect::>()); + let local = self.local_victims(&measured); + if !local.is_empty() { + self.builder.meas_crosstalk_local_payload(&local); + } + } + + let mut expected = Vec::new(); + for operation in operations { + match operation { + Operation::RXYGate { + qubit_id, + theta, + phi, + } => { + let qubit = self.qubit(qubit_id)?; + self.add_idle_before(qubit, start)?; + self.builder.r1xy( + Angle64::from_radians(theta), + Angle64::from_radians(phi), + &[qubit], + ); + self.last_operation_end[qubit] = end; + } + Operation::RZGate { qubit_id, theta } => { + let qubit = self.qubit(qubit_id)?; + self.add_idle_before(qubit, start)?; + self.builder.rz(Angle64::from_radians(theta), &[qubit]); + self.last_operation_end[qubit] = end; + } + Operation::RZZGate { + qubit_id_1, + qubit_id_2, + theta, + } => { + let first = self.qubit(qubit_id_1)?; + let second = self.qubit(qubit_id_2)?; + self.add_idle_before(first, start)?; + self.add_idle_before(second, start)?; + self.builder + .rzz(Angle64::from_radians(theta), &[(first, second)]); + self.last_operation_end[first] = end; + self.last_operation_end[second] = end; + } + Operation::Measure { + qubit_id, + result_id, + } => { + let qubit = self.qubit(qubit_id)?; + self.add_idle_before(qubit, start)?; + self.builder.mz(&[qubit]); + self.last_operation_end[qubit] = end; + expected.push(MeasurementResult { + id: result_id, + kind: MeasurementKind::Bool, + }); + } + Operation::MeasureLeaked { + qubit_id, + result_id, + } => { + let qubit = self.qubit(qubit_id)?; + self.add_idle_before(qubit, start)?; + self.builder.measure_leakages(&[qubit]); + self.last_operation_end[qubit] = end; + expected.push(MeasurementResult { + id: result_id, + kind: MeasurementKind::Leakage, + }); + } + Operation::Reset { qubit_id } => { + let qubit = self.qubit(qubit_id)?; + self.builder.pz(&[qubit]); + self.last_operation_end[qubit] = end; + } + Operation::Custom { custom_tag, .. } => { + bail!( + "custom Selene runtime operation {custom_tag} has no device-neutral PECOS meaning" + ); + } + } + } + + if self.builder.message_count() == 0 { + return Ok(BatchResult::default()); + } + let message = self.builder.build(); + self.builder.reset(); + let _ = self.builder.for_quantum_operations(); + let output = self + .system + .process(message) + .map_err(|error| anyhow!(error.to_string()))?; + let outcomes = output + .outcomes() + .map_err(|error| anyhow!(error.to_string()))?; + if outcomes.len() != expected.len() { + bail!( + "PECOS returned {} user outcomes for {} Selene measurements", + outcomes.len(), + expected.len() + ); + } + let mut result = BatchResult::default(); + for (measurement, outcome) in expected.into_iter().zip(outcomes) { + match measurement.kind { + MeasurementKind::Bool if outcome <= 1 => { + result.set_bool_result(measurement.id, outcome == 1); + } + MeasurementKind::Leakage if outcome <= 2 => { + result.set_u64_result(measurement.id, u64::from(outcome)); + } + MeasurementKind::Bool => bail!("PECOS returned non-boolean outcome {outcome}"), + MeasurementKind::Leakage => { + bail!("PECOS returned invalid leakage outcome {outcome}") + } + } + } + Ok(result) + } + + fn get_metric(&mut self, _nth_metric: u8) -> Result> { + Ok(None) + } + + fn get_simulator_metric(&mut self, nth_metric: u8) -> Result> { + self.simulator_mut()?.metric(nth_metric) + } +} + +#[derive(Default)] +struct GeneralNoiseFactory; + +impl ErrorModelInterfaceFactory for GeneralNoiseFactory { + type Interface = GeneralNoiseErrorModel; + + fn init( + self: Arc, + n_qubits: u64, + error_model_args: &[impl AsRef], + simulator_path: &impl AsRef, + simulator_args: &[impl AsRef], + ) -> Result> { + if error_model_args.len() != 2 { + bail!( + "PECOS general noise expects one JSON configuration argument, got {}", + error_model_args.len().saturating_sub(1) + ); + } + let config: Config = serde_json::from_str(error_model_args[1].as_ref()) + .context("could not parse PECOS general-noise JSON configuration")?; + let model = config.build_model()?; + let simulator = Simulator::load_from_file(simulator_path, n_qubits, simulator_args)?; + Ok(Box::new(GeneralNoiseErrorModel::new( + model, + simulator, + usize::try_from(n_qubits).context("qubit count does not fit usize")?, + &config, + )?)) + } +} + +export_error_model_plugin!(crate::GeneralNoiseFactory); + +#[cfg(test)] +mod tests { + use super::Config; + + #[test] + fn rich_configuration_builds_the_current_pecos_model() { + let json = r#"{ + "preparation":{ + "probability":0.001, + "leakage_ratio":0.2, + "average_crosstalk_probability":0.0001 + }, + "measurement":{ + "p0_to_1":0.002, + "p1_to_0":0.003, + "global_crosstalk_probability":0.0001, + "local_crosstalk_probability":0.0002, + "crosstalk_model":{ + "0->0":0.9,"0->1":0.1, + "1->0":0.2,"1->1":0.8 + }, + "local_groups":[[0,1],[2,3]] + }, + "single_qubit":{"average_infidelity":0.0001}, + "two_qubit":{ + "average_infidelity":0.001, + "angle_coefficients":[1.0,0.0,1.0,0.0], + "angle_power":1.0, + "idle_after_gate":0.000005 + }, + "idle":{ + "linear_rate":0.01, + "sin_squared_rate":0.02, + "coherent_rate":0.03 + }, + "scaling":{"overall":0.5,"leakage":0.75,"emission":1.25}, + "noiseless_gates":["RZ"] + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + config.build_model().unwrap(); + } + + #[test] + fn unknown_configuration_fields_are_rejected() { + let error = serde_json::from_str::(r#"{"quantinuum_device":"H2"}"#) + .expect_err("device-specific fields must not enter the generic API"); + assert!(error.to_string().contains("unknown field")); + } +} diff --git a/python/selene-plugins/pecos-selene-general-noise/src/simulator.rs b/python/selene-plugins/pecos-selene-general-noise/src/simulator.rs new file mode 100644 index 000000000..27ccd03ec --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/src/simulator.rs @@ -0,0 +1,149 @@ +use std::any::Any; +use std::f64::consts::PI; + +use pecos_core::errors::PecosError; +use pecos_engines::byte_message::GateType; +use pecos_engines::prelude::*; +use selene_core::simulator::{Simulator, SimulatorInterface}; +use selene_core::utils::MetricValue; + +pub struct SeleneSimulator { + inner: Simulator, +} + +impl SeleneSimulator { + pub fn new(inner: Simulator) -> Self { + Self { inner } + } + + fn error(context: &str, error: impl std::fmt::Display) -> PecosError { + PecosError::Generic(format!("Selene simulator {context} failed: {error}")) + } + + pub fn shot_start(&mut self, shot_id: u64, seed: u64) -> Result<(), PecosError> { + self.inner + .shot_start(shot_id, seed) + .map_err(|error| Self::error("shot_start", error)) + } + + pub fn shot_end(&mut self) -> Result<(), PecosError> { + self.inner + .shot_end() + .map_err(|error| Self::error("shot_end", error)) + } + + pub fn metric(&mut self, index: u8) -> anyhow::Result> { + self.inner.get_metric(index) + } + + pub fn dump_state(&mut self, file: &std::path::Path, qubits: &[u64]) -> anyhow::Result<()> { + self.inner.dump_state(file, qubits) + } + + fn rz(&mut self, qubit: usize, angle: f64) -> Result<(), PecosError> { + self.inner + .rz(qubit as u64, angle) + .map_err(|error| Self::error("RZ", error)) + } + + fn rxy(&mut self, qubit: usize, theta: f64, phi: f64) -> Result<(), PecosError> { + self.inner + .rxy(qubit as u64, theta, phi) + .map_err(|error| Self::error("RXY", error)) + } + + fn rzz(&mut self, first: usize, second: usize, theta: f64) -> Result<(), PecosError> { + self.inner + .rzz(first as u64, second as u64, theta) + .map_err(|error| Self::error("RZZ", error)) + } +} + +impl Clone for SeleneSimulator { + fn clone(&self) -> Self { + panic!("a loaded Selene simulator cannot be cloned") + } +} + +unsafe impl Send for SeleneSimulator {} +unsafe impl Sync for SeleneSimulator {} + +impl std::fmt::Debug for SeleneSimulator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SeleneSimulator") + .finish_non_exhaustive() + } +} + +impl Engine for SeleneSimulator { + type Input = ByteMessage; + type Output = ByteMessage; + + fn process(&mut self, message: Self::Input) -> Result { + let mut outcomes = Vec::new(); + for gate in message.quantum_ops()? { + let qubits = gate + .qubits + .iter() + .map(|qubit| usize::from(*qubit)) + .collect::>(); + let angle = |index: usize| gate.angles[index].to_radians_signed(); + match gate.gate_type { + GateType::X => self.rxy(qubits[0], PI, 0.0)?, + GateType::Y => self.rxy(qubits[0], PI, PI / 2.0)?, + GateType::Z => self.rz(qubits[0], PI)?, + GateType::H => self.rxy(qubits[0], PI / 2.0, -PI / 2.0)?, + GateType::RX => self.rxy(qubits[0], angle(0), 0.0)?, + GateType::RY => self.rxy(qubits[0], angle(0), PI / 2.0)?, + GateType::RZ => self.rz(qubits[0], angle(0))?, + GateType::R1XY => self.rxy(qubits[0], angle(0), angle(1))?, + GateType::RZZ => self.rzz(qubits[0], qubits[1], angle(0))?, + GateType::SZZ => self.rzz(qubits[0], qubits[1], PI / 2.0)?, + GateType::SZZdg => self.rzz(qubits[0], qubits[1], -PI / 2.0)?, + GateType::SZ => self.rz(qubits[0], PI / 2.0)?, + GateType::SZdg => self.rz(qubits[0], -PI / 2.0)?, + GateType::T => self.rz(qubits[0], PI / 4.0)?, + GateType::Tdg => self.rz(qubits[0], -PI / 4.0)?, + GateType::MZ | GateType::MeasureLeaked => { + outcomes.push(usize::from( + self.inner + .measure(qubits[0] as u64) + .map_err(|error| Self::error("measurement", error))?, + )); + } + GateType::PZ => self + .inner + .reset(qubits[0] as u64) + .map_err(|error| Self::error("reset", error))?, + GateType::Idle + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload => {} + unsupported => { + return Err(PecosError::Generic(format!( + "PECOS general-noise bridge produced unsupported gate {unsupported}" + ))); + } + } + } + let mut builder = ByteMessage::outcomes_builder(); + builder.add_outcomes(&outcomes); + Ok(builder.build()) + } + + fn reset(&mut self) -> Result<(), PecosError> { + Ok(()) + } +} + +impl QuantumEngine for SeleneSimulator { + fn set_seed(&mut self, _seed: u64) {} + + fn as_any(&self) -> &(dyn Any + 'static) { + self + } + + fn as_any_mut(&mut self) -> &mut (dyn Any + 'static) { + self + } +} diff --git a/python/selene-plugins/pecos-selene-general-noise/tests/test_plugin.py b/python/selene-plugins/pecos-selene-general-noise/tests/test_plugin.py new file mode 100644 index 000000000..69a9e1155 --- /dev/null +++ b/python/selene-plugins/pecos-selene-general-noise/tests/test_plugin.py @@ -0,0 +1,218 @@ +"""Tests for the public general-noise configuration API.""" + +import json + +import pytest +from guppylang import guppy +from guppylang.std.angles import pi +from guppylang.std.builtins import result +from guppylang.std.quantum import measure, qubit, rz +from pecos_selene_general_noise import ( + GateNoise, + GeneralNoiseParameters, + GeneralNoisePlugin, +) +from selene_sim import Stim +from selene_sim.build import build + + +def config(parameters: GeneralNoiseParameters) -> dict[str, object]: + return json.loads(GeneralNoisePlugin(parameters=parameters).get_init_args()[0]) + + +def test_default_is_explicitly_noiseless() -> None: + payload = config(GeneralNoiseParameters()) + assert payload["preparation"]["probability"] is None + assert payload["single_qubit"]["probability"] is None + assert payload["two_qubit"]["probability"] is None + + +def test_uniform_convenience_model() -> None: + payload = config(GeneralNoiseParameters.uniform(0.001)) + assert payload["preparation"]["probability"] == 0.001 + assert payload["measurement"]["p0_to_1"] == 0.001 + assert payload["measurement"]["p1_to_0"] == 0.001 + assert payload["single_qubit"]["probability"] == 0.001 + assert payload["two_qubit"]["probability"] == 0.001 + + +def test_exposes_current_pecos_capabilities() -> None: + parameters = ( + GeneralNoiseParameters() + .with_average_p1(1e-4) + .with_p2(2e-3) + .with_p2_angle_params(1.0, 0.0, 1.0, 0.0) + .with_idle_after_2q(5e-6) + .with_p_idle_linear(0.01, {"Z": 1.0}) + .with_p_idle_sin_squared(0.02, {"Z": 1.0}) + .with_p_idle_coherent(0.03, {"RZ": 1.0}) + .with_local_crosstalk_groups((0, 1), (2, 3)) + ) + payload = config(parameters) + assert payload["single_qubit"]["average_infidelity"] == 1e-4 + assert payload["idle"]["coherent_rate"] == 0.03 + assert payload["measurement"]["local_groups"] == [[0, 1], [2, 3]] + + +def test_rejects_ambiguous_infidelity() -> None: + with pytest.raises(ValueError, match="not both"): + GeneralNoiseParameters(single_qubit=GateNoise(probability=0.1, average_infidelity=0.1)) + + +def test_rejects_bad_distribution() -> None: + with pytest.raises(ValueError, match="sum to 1"): + GeneralNoiseParameters(single_qubit=GateNoise(pauli_model={"X": 0.2, "Z": 0.2})) + + +def test_accepts_crosstalk_transition_model_per_input_state() -> None: + model = { + "0->0": 0.8, + "0->1": 0.2, + "1->0": 0.1, + "1->1": 0.9, + } + parameters = GeneralNoiseParameters().with_p_meas_crosstalk_model(model) + assert config(parameters)["measurement"]["crosstalk_model"] == model + + +def test_fluent_setters_are_immutable_and_last_infidelity_wins() -> None: + original = GeneralNoiseParameters() + updated = original.with_average_p1(0.01).with_p1(0.02) + assert original.single_qubit.probability is None + assert updated.single_qubit.probability == 0.02 + assert updated.single_qubit.average_infidelity is None + + +def test_matches_all_configurable_pecos_builder_setters() -> None: + expected = { + "with_average_p1", + "with_average_p2", + "with_average_p_prep_crosstalk", + "with_emission_scale", + "with_idle_after_2q", + "with_idle_scale", + "with_leakage_scale", + "with_meas_scale", + "with_noiseless_gate", + "with_p1", + "with_p1_emission_model", + "with_p1_emission_ratio", + "with_p1_pauli_model", + "with_p1_scale", + "with_p1_seepage_prob", + "with_p2", + "with_p2_angle_params", + "with_p2_angle_power", + "with_p2_emission_model", + "with_p2_emission_ratio", + "with_p2_pauli_model", + "with_p2_scale", + "with_p2_seepage_prob", + "with_p_idle_coherent", + "with_p_idle_linear", + "with_p_idle_sin_squared", + "with_p_meas", + "with_p_meas_0", + "with_p_meas_1", + "with_p_meas_crosstalk", + "with_p_meas_crosstalk_global", + "with_p_meas_crosstalk_local", + "with_p_meas_crosstalk_model", + "with_p_meas_crosstalk_scale", + "with_p_prep", + "with_p_prep_crosstalk", + "with_p_prep_crosstalk_scale", + "with_prep_leak_ratio", + "with_prep_scale", + "with_scale", + "with_seepage_prob", + } + assert expected <= set(dir(GeneralNoiseParameters)) + + +def test_plugin_executes_in_selene() -> None: + """A deterministic preparation fault traverses both plugin boundaries.""" + + @guppy + def main() -> None: + q = qubit() + outcome = measure(q) + result("outcome", outcome) + + runner = build(main.compile()) + noise = GeneralNoisePlugin( + parameters=GeneralNoiseParameters().with_p_prep(1.0), + random_seed=7, + ) + results = dict(runner.run(Stim(random_seed=11), n_qubits=1, error_model=noise)) + assert results["outcome"] == 1 + + +def test_default_parameters_are_noiseless_in_selene() -> None: + """Default parameters preserve a deterministic noiseless outcome.""" + + @guppy + def main() -> None: + q = qubit() + outcome = measure(q) + result("outcome", outcome) + + runner = build(main.compile()) + noise = GeneralNoisePlugin(parameters=GeneralNoiseParameters(), random_seed=7) + results = dict(runner.run(Stim(random_seed=11), n_qubits=1, error_model=noise)) + assert results["outcome"] == 0 + + +def test_plugin_executes_with_pecos_statevec() -> None: + """The error model composes with PECOS's in-tree Selene simulator.""" + statevec_module = pytest.importorskip("pecos_selene_statevec") + + @guppy + def main() -> None: + q = qubit() + outcome = measure(q) + result("outcome", outcome) + + runner = build(main.compile()) + noise = GeneralNoisePlugin( + parameters=GeneralNoiseParameters().with_p_prep(1.0), + random_seed=7, + ) + simulator = statevec_module.StateVecPlugin(random_seed=11) + results = dict(runner.run(simulator, n_qubits=1, error_model=noise)) + assert results["outcome"] == 1 + + +def test_asymmetric_measurement_noise_executes_in_selene() -> None: + """A configured 0-to-1 readout fault is deterministic.""" + + @guppy + def main() -> None: + q = qubit() + outcome = measure(q) + result("outcome", outcome) + + runner = build(main.compile()) + noise = GeneralNoisePlugin( + parameters=GeneralNoiseParameters().with_p_meas_0(1.0), + random_seed=7, + ) + results = dict(runner.run(Stim(random_seed=11), n_qubits=1, error_model=noise)) + assert results["outcome"] == 1 + + +def test_custom_single_qubit_pauli_channel_executes_in_selene() -> None: + """A deterministic Pauli model is honored after a single-qubit gate.""" + + @guppy + def main() -> None: + q = qubit() + rz(q, pi / 2) + outcome = measure(q) + result("outcome", outcome) + + runner = build(main.compile()) + parameters = GeneralNoiseParameters().with_p1(1.0).with_p1_pauli_model({"X": 1.0}) + noise = GeneralNoisePlugin(parameters=parameters, random_seed=7) + results = dict(runner.run(Stim(random_seed=11), n_qubits=1, error_model=noise)) + assert results["outcome"] == 1 diff --git a/scripts/ci/unsafe-allowlist.txt b/scripts/ci/unsafe-allowlist.txt index 3bbec7c13..3c9046968 100644 --- a/scripts/ci/unsafe-allowlist.txt +++ b/scripts/ci/unsafe-allowlist.txt @@ -24,3 +24,4 @@ go/pecos-go-ffi julia/pecos-julia-ffi python/pecos-rslib python/pecos-rslib-llvm +python/selene-plugins/pecos-selene-general-noise diff --git a/uv.lock b/uv.lock index 0af93e538..aedb406ed 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "pecos-rslib-cuda", "pecos-rslib-exp", "pecos-rslib-llvm", + "pecos-selene-general-noise", "pecos-selene-mast", "pecos-selene-stab-mps", "pecos-selene-stab-vec", @@ -2421,6 +2422,30 @@ test = [ dev = [] test = [{ name = "pytest", specifier = ">=9.0" }] +[[package]] +name = "pecos-selene-general-noise" +version = "0.10.0.dev0" +source = { editable = "python/selene-plugins/pecos-selene-general-noise" } +dependencies = [ + { name = "selene-core" }, +] + +[package.optional-dependencies] +test = [ + { name = "guppylang" }, + { name = "pytest" }, + { name = "selene-sim" }, +] + +[package.metadata] +requires-dist = [ + { name = "guppylang", marker = "extra == 'test'", specifier = ">=0.14,<1.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" }, + { name = "selene-core", specifier = ">=0.2" }, + { name = "selene-sim", marker = "extra == 'test'", specifier = ">=0.2,<0.3" }, +] +provides-extras = ["test"] + [[package]] name = "pecos-selene-mast" version = "0.11.0.dev0"