diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..188544e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: Report a bug +title: "[Bug]: " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment (please complete the following information):** + - OS: [e.g. Windows10, macOS Monteley] + - Python version: [e.g. 3.8.1] + - Related module versions if applicable: [e.g. numpy=1.23.5] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..62dbdb7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: new feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +**Describe the feature you'd like** +A clear and concise description of what you want to happen. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..bfdc987 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9c7546e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +Before submitting, please check the following: +- Make sure you have tests for the new code and that test passes (run `tox`) +- If applicable, add a line to the [unreleased] part of CHANGELOG.md, following [keep-a-changelog](https://keepachangelog.com/en/1.0.0/). +- Format added code by `black` and `isort` + - See `pyproject.toml` for configurations + +Then, please fill in below: + +**Context (if applicable):** + + +**Description of the change:** + + +**Related issue:** + + +also see that checks (github actions) pass. +If lint check keeps failing, try installing black==22.8.0 as behavior seems to vary across versions. + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af7074d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: pytest + +on: + pull_request: + branches: ["master"] + +permissions: + contents: read + +jobs: + standard: + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "windows-2022", "macos-latest"] + python: ["3.8", "3.9", "3.10", "3.11"] + + name: "Python ${{ matrix.python }} / ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install tox + run: pip install tox tox-gh-actions + + - name: Run tox + run: tox diff --git a/.github/workflows/cov.yml b/.github/workflows/cov.yml new file mode 100644 index 0000000..f3ab7d6 --- /dev/null +++ b/.github/workflows/cov.yml @@ -0,0 +1,33 @@ +name: pytest-cov + +# Need to include "push" +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install graphix + run: pip install . + + - name: Add test deps. + run: pip install -r requirements-dev.txt + + - name: Run pytest + run: pytest --cov=./graphix --cov-report=xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7d619a1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Publish Python distributions to PyPI + +on: + release: + types: [published] + +jobs: + build-n-publish: + name: Build and publish Python distributions to PyPI + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c68c78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +__pycache__/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1326087 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.5.1 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/graphix_symbolic/__init__.py b/graphix_symbolic/__init__.py new file mode 100644 index 0000000..4593f1b --- /dev/null +++ b/graphix_symbolic/__init__.py @@ -0,0 +1,3 @@ +from graphix_symbolic.sympy_parameter import SympyParameter + +__all__ = ["SympyParameter"] diff --git a/graphix_symbolic/sympy_parameter.py b/graphix_symbolic/sympy_parameter.py new file mode 100644 index 0000000..056289e --- /dev/null +++ b/graphix_symbolic/sympy_parameter.py @@ -0,0 +1,228 @@ +"""Parameter class with symbolic computation using sympy + +SympyParameter can be used in computation such as simulations. + +""" + +from __future__ import annotations + +import numbers +from typing import Mapping + +import numpy as np +import sympy as sp +from graphix.parameter import Expression, ExpressionOrComplex, ExpressionOrFloat, Parameter + + +class SympyExpression(Expression): + """Expression with parameters. + + Implements arithmetic operations. This is essentially a wrapper over + sp.Expr, exposing methods like cos, conjugate, etc., that are + expected by the simulator back-ends. + """ + + def __init__(self, expression: sp.Expr) -> None: + self._expression = expression + + def __mul__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(self._expression * other) + elif isinstance(other, SympyExpression): + return SympyExpression(self._expression * other._expression) + else: + return NotImplemented + + def __rmul__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(other * self._expression) + elif isinstance(other, SympyExpression): + return SympyExpression(other._expression * self._expression) + else: + return NotImplemented + + def __add__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(self._expression + other) + elif isinstance(other, SympyExpression): + return SympyExpression(self._expression + other._expression) + else: + return NotImplemented + + def __radd__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(other + self._expression) + elif isinstance(other, SympyExpression): + return SympyExpression(other._expression + self._expression) + else: + return NotImplemented + + def __sub__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(self._expression - other) + elif isinstance(other, SympyExpression): + return SympyExpression(self._expression - other._expression) + else: + return NotImplemented + + def __rsub__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(other - self._expression) + elif isinstance(other, SympyExpression): + return SympyExpression(other._expression - self._expression) + else: + return NotImplemented + + def __pow__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(self._expression**other) + elif isinstance(other, SympyExpression): + return SympyExpression(self._expression**other._expression) + else: + return NotImplemented + + def __rpow__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(other**self._expression) + elif isinstance(other, SympyExpression): + return SympyExpression(other._expression**self._expression) + else: + return NotImplemented + + def __neg__(self) -> ExpressionOrFloat: + return SympyExpression(-self._expression) + + def __truediv__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(self._expression / other) + elif isinstance(other, SympyExpression): + return SympyExpression(self._expression / other._expression) + else: + return NotImplemented + + def __rtruediv__(self, other) -> ExpressionOrFloat: + if isinstance(other, numbers.Number): + return SympyExpression(other / self._expression) + elif isinstance(other, SympyExpression): + return SympyExpression(other._expression / self._expression) + else: + return NotImplemented + + def __mod__(self, other) -> float: + """mod magic function returns nan so that evaluation of + mod of measurement angles in :meth:`graphix.pattern.is_pauli_measurement` + will not cause error. returns nan so that this will not be considered Pauli measurement. + """ + return np.nan + + def sin(self) -> ExpressionOrFloat: + return SympyExpression(sp.sin(self._expression)) + + def cos(self) -> ExpressionOrFloat: + return SympyExpression(sp.cos(self._expression)) + + def tan(self) -> ExpressionOrFloat: + return SympyExpression(sp.tan(self._expression)) + + def arcsin(self) -> ExpressionOrFloat: + return SympyExpression(sp.asin(self._expression)) + + def arccos(self) -> ExpressionOrFloat: + return SympyExpression(sp.acos(self._expression)) + + def arctan(self) -> ExpressionOrFloat: + return SympyExpression(sp.atan(self._expression)) + + def exp(self) -> ExpressionOrFloat: + return SympyExpression(sp.exp(self._expression)) + + def log(self) -> ExpressionOrFloat: + return SympyExpression(sp.log(self._expression)) + + def conjugate(self) -> ExpressionOrFloat: + return SympyExpression(sp.conjugate(self._expression)) + + def sqrt(self) -> ExpressionOrFloat: + return SympyExpression(sp.sqrt(self._expression)) + + @property + def expression(self) -> sp.Expr: + return self._expression + + def __repr__(self) -> str: + return str(self._expression) + + def __str__(self) -> str: + return str(self._expression) + + @staticmethod + def __check_sympy_parameter(variable: Parameter) -> None: + if not isinstance(variable, SympyParameter): + raise ValueError( + f"Sympy expressions can only be substituted with sympy parameters, not {variable.__class__}." + ) + + def subs(self, variable: Parameter, value: ExpressionOrFloat) -> ExpressionOrComplex: + self.__check_sympy_parameter(variable) + result = sp.N(self._expression.subs(variable._expression, value)) + if isinstance(result, numbers.Number) or not result.free_symbols: + return complex(result) + else: + return SympyExpression(result) + + def xreplace(self, assignment: Mapping[Parameter, ExpressionOrFloat]) -> ExpressionOrComplex: + for variable in assignment: + self.__check_sympy_parameter(variable) + sympy_assignment = {variable._expression: value for variable, value in assignment.items()} + result = sp.N(self._expression.xreplace(sympy_assignment)) + if isinstance(result, numbers.Number) or not result.free_symbols: + return complex(result) + else: + return SympyExpression(result) + + +class SympyParameter(Parameter, SympyExpression): + """Placeholder for measurement angles, which allows the pattern optimizations + without specifying measurement angles for measurement commands. + Either use for rotation gates of :class:`Circuit` class or for + the measurement angle of the measurement commands to be added with :meth:`Pattern.add` method. + Example: + .. code-block:: python + + import numpy as np + from graphix import Circuit + + circuit = Circuit(1) + alpha = Parameter("alpha") + # rotation gate + circuit.rx(0, alpha) + pattern = circuit.transpile() + # Both simulations (numeric and symbolic) will use the same + # seed for random number generation, to ensure that the + # explored branch is the same for the two simulations. + seed = np.random.integers(2**63) + # simulate with parameter assignment + sv = pattern.subs(alpha, 0.5).simulate_pattern(pr_calc=False, rng=np.random.default_rng(seed)) + # simulate without pattern assignment + # (the resulting state vector is symbolic) + # Note: pr_calc=False is mandatory since we cannot compute probabilities on + # symbolic states; we explore one arbitrary branch. + sv2 = pattern.simulate_pattern(pr_calc=False, rng=np.random.default_rng(seed)) + # Substituting alpha in the resulting state vector should yield the same result + assert np.allclose(sv.psi, sv2.subs(alpha, 0.5).psi) + """ + + def __init__(self, name: str) -> None: + """Create a new :class:`Parameter` object. + + Parameters + ---------- + name : str + name of the parameter, used for binding values. + """ + self._name = name + super().__init__(sp.Symbol(name=name)) + + @property + def name(self) -> str: + return self._name diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1920df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["setuptools", "wheel", "setuptools_scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "graphix-symbolic" +authors = [{ name = "Shinichi Sunami", email = "shinichi.sunami@gmail.com" }] +maintainers = [ + { name = "Shinichi Sunami", email = "shinichi.sunami@gmail.com" }, +] +license = { file = "LICENSE" } +description = "Symbolic plugin for graphix" +readme = "README.md" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Physics", +] +requires-python = ">=3.8,<3.12" +dynamic = ["version"] +dependencies = [ + "graphix>=0.2.15", + "sympy>=1.9", +] +[project.optional-dependencies] +dev = [ + "black==24.4.0", + "isort==5.13.2", + "pytest", + "parameterized", + "tox", +] + +[project.urls] +Documentation = "https://graphix.readthedocs.io" +"Bug Tracker" = "https://github.com/TeamGraphix/graphix/issues" + +[tool.setuptools_scm] +version_file = "graphix_symbolic/_version.py" + +[tool.ruff] +line-length = 120 +extend-exclude = ["docs"] + +[tool.ruff.lint] +extend-select = [ + "UP", + "NPY", + "A", + "B", + "W", + "PLE", + "PLW", + "FA", + "RUF", + "PERF", + "TCH", + "I", +] +ignore = [ + # TODO: Resolve this immediately + "NPY002", # Use np.random.Generator + "E74", # Ambiguous name +] + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint.extend-per-file-ignores] +"__init__.py" = [ + "F401", # Unused import +] +"examples/*.py" = [ + "E402", # Import not at top of file + "B018", # Useless expression +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4b9134d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +# Style +ruff + +# Tests +pytest +pytest-mock +pytest-cov +tox diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ddd91b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +graphix @ git+https://github.com/thierry-martinez/graphix@parameterized +sympy>=1.9 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..149bfd1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from numpy.random import PCG64, Generator + +SEED = 25 + + +@pytest.fixture() +def fx_rng() -> Generator: + return Generator(PCG64(SEED)) diff --git a/tests/test_sympy_parameter.py b/tests/test_sympy_parameter.py new file mode 100644 index 0000000..e28294d --- /dev/null +++ b/tests/test_sympy_parameter.py @@ -0,0 +1,52 @@ +import numpy as np +import pytest +from graphix import Circuit +from numpy.random import Generator + +from graphix_symbolic import SympyParameter + + +def test_parameter_circuit_simulation(fx_rng: Generator) -> None: + alpha = SympyParameter("alpha") + circuit = Circuit(1) + circuit.rz(0, alpha) + result_subs_then_simulate = circuit.subs(alpha, 0.5).simulate_statevector().statevec + result_simulate_then_subs = circuit.simulate_statevector().statevec.subs(alpha, 0.5) + assert np.allclose(result_subs_then_simulate.psi, result_simulate_then_subs.psi) + + +def test_parameter_parallel_substitution(fx_rng: Generator) -> None: + alpha = SympyParameter("alpha") + beta = SympyParameter("beta") + circuit = Circuit(2) + circuit.rz(0, alpha) + circuit.rz(1, beta) + mapping = {alpha: 0.5, beta: 0.4} + result_subs_then_simulate = circuit.xreplace(mapping).simulate_statevector().statevec + result_simulate_then_subs = circuit.simulate_statevector().statevec.xreplace(mapping) + assert np.allclose(result_subs_then_simulate.psi, result_simulate_then_subs.psi) + + +@pytest.mark.parametrize("backend", ["statevector", "densitymatrix"]) +def test_parameter_pattern_simulation(backend, fx_rng: Generator) -> None: + alpha = SympyParameter("alpha") + circuit = Circuit(1) + circuit.rz(0, alpha) + pattern = circuit.transpile().pattern + # Both simulations (numeric and symbolic) will use the same + # seed for random number generation, to ensure that the + # explored branch is the same for the two simulations. + seed = fx_rng.integers(2**63) + result_subs_then_simulate = pattern.subs(alpha, 0.5).simulate_pattern( + backend, pr_calc=False, rng=np.random.default_rng(seed) + ) + # Note: pr_calc=False is mandatory since we cannot compute + # probabilities on symbolic states; we explore one arbitrary + # branch. + result_simulate_then_subs = pattern.simulate_pattern(backend, pr_calc=False, rng=np.random.default_rng(seed)).subs( + alpha, 0.5 + ) + if backend == "statevector": + assert np.allclose(result_subs_then_simulate.psi, result_simulate_then_subs.psi) + elif backend == "densitymatrix": + assert np.allclose(result_subs_then_simulate.rho, result_simulate_then_subs.rho)