diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..242837d9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + */graphiq/data_collection/* diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7938e963..17d54043 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -21,7 +21,9 @@ jobs: python -m pip install --upgrade pip pip install flake8 pytest pip install . - pip install .[all] + # optax needs jaxlib, which has no Python 3.8 wheels on PyPI any more, + # so on 3.8 only ray is installed from the "all" extra. + if [ "${{ matrix.python-version }}" = "3.8" ]; then pip install ray; else pip install .[all]; fi # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | diff --git a/examples/example_solve.py b/examples/example_solve.py index 8f5dc861..f606f027 100644 --- a/examples/example_solve.py +++ b/examples/example_solve.py @@ -6,7 +6,10 @@ import time from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.metrics import Infidelity from graphiq.visualizers.density_matrix import density_matrix_bars @@ -15,11 +18,8 @@ if __name__ == "__main__": - # %% here we have access - EvolutionarySolver.n_stop = 40 - EvolutionarySolver.n_pop = 150 - EvolutionarySolver.n_hof = 10 - EvolutionarySolver.tournament_k = 10 + # %% solver settings + setting = EvolutionarySolverSetting(n_stop=40, n_pop=150, n_hof=10, tournament_k=10) # %% comment/uncomment for reproducibility EvolutionarySolver.seed(3) @@ -40,6 +40,7 @@ compiler=compiler, n_photon=n_photon, n_emitter=n_emitter, + solver_setting=setting, ) # %% call the solver.solve() function to implement the random search algorithm diff --git a/examples/startup_guide/0_implementer_quickstart.ipynb b/examples/startup_guide/0_implementer_quickstart.ipynb index d41f9d78..1c776caa 100644 --- a/examples/startup_guide/0_implementer_quickstart.ipynb +++ b/examples/startup_guide/0_implementer_quickstart.ipynb @@ -42,7 +42,7 @@ "3. Running a circuit (CompilerBase, DensityMatrixCompiler, StabilizerCompiler)\n", "4. Noise models\n", "5. Metrics (MetricBase, Infidelity, TraceDistance, Metrics)\n", - "6. Solvers (SolverBase, EvolutionarySolver, DeterministicSolver)\n", + "6. Solvers (SolverBase, EvolutionarySolver, TimeReversedSolver)\n", "7. Benchmarking\n", "8. (upcoming) Differentiable circuits\n", "\n", diff --git a/examples/startup_guide/6_solvers.ipynb b/examples/startup_guide/6_solvers.ipynb index 3c958bc9..ce515df5 100644 --- a/examples/startup_guide/6_solvers.ipynb +++ b/examples/startup_guide/6_solvers.ipynb @@ -24,7 +24,7 @@ "\n", "The role of a solver is to generate one or more candidate circuits which produce a target quantum state.\n", "\n", - "The central mapping algorithm uses the protocol outlined by Li *et al.* and is implemented in the `DeterministicSolver` class. \n", + "The central mapping algorithm uses the protocol outlined by Li *et al.* and is implemented in the `TimeReversedSolver` class. \n", "The `EvolutionarySolver` class is a heuristic optimization algorithm which can be used to find a circuit which produces a target state, however, does not scale well to large numbers of qubits." ] }, @@ -56,7 +56,7 @@ "from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler\n", "from graphiq.metrics import Infidelity\n", "from graphiq.state import QuantumState\n", - "from graphiq.solvers.evolutionary_solver import EvolutionarySolver\n", + "from graphiq.solvers.evolutionary_solver import EvolutionarySolver, EvolutionarySolverSetting\n", "\n", "\n", "ghz3_target_dm = dmf.ket2dm(\n", @@ -122,9 +122,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -133,7 +133,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 2, @@ -154,7 +154,7 @@ " n_photons, n_emitter\n", ")\n", "\n", - "circuit = solver.initialization(emission_assignment, measurement_assignment, {})\n", + "circuit = solver.initialization(emission_assignment, measurement_assignment)\n", "circuit.draw_circuit()" ], "metadata": { @@ -191,9 +191,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -202,7 +202,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 3, @@ -230,9 +230,9 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAV0AAADNCAYAAAAbmaNAAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAdtUlEQVR4nO3de3xU5b3v8c8vk4SbEiEgIopRQeVipcjFxo0K1Zel2lOr6N46tKVsi6hFR1Chu92H6tYK3eIZdisq9liKjO1BammPl3oFL7BVlKq1Uq1WlCINECJ3SDJ59h9rBZOYZCaXWWsm+b5fr7wms9aa9fwyM/nOM8961ow55xARkWDkhV2AiEhnotAVEQmQQldEJEAKXRGRACl0RUQCpNAVEQmQQldEJEAKXRGRACl0RUQCpNAVEQmQQldEJEAKXRGRACl0RUQCpNAVEQmQQldEJEAKXRGRACl0RUQCpNAVEQmQQldEJEAKXRGRACl0RUQClB92AZIdYgnygUiGdp+MR6nO0L5FcorpK9jFD9wtQJ8MNbEd6K/gFdHwgngiZC5w8fedqV60SE7R8EKWiyUoAIqAKmB3PEpNyCWJSBsodLNQLEEh8A3ge8A/1Vl1IJbgQeCeeJQ/hlKciLSJhheyTCzBxcBm4NfAlxqs7gpMBdbHEqyJJRgQdH0i0jYK3SwSSzANWAEU+4saGwetXTYWWBdLcGIQtYlI+1DoZolYgguAewHzfw4pjMCxvb3LOiJAX+CpWIJeQdUpIm2TlaFrZkea2R/MbJ+ZvW1mZ4RdUybFEhjwf5pa368IZk30LhvIB0qAqzNWnIi0q6wMXbwe39/w3mbPA35jZl3CLSmjzgEG06CHmyYDro0lNCVLJBeEFrpmdp2ZPWZmz5pZhZm9YmYDzOxw4ALgR865/c65ZUAFXjB1VNdCq08cMOBovPssY3Zt28iK286pt2zJzEGZbFKkQwqzpzsMGAPMBY4CyoAb8Xp85c65rXW2/RMwNPAKg3MmbZu+V413X4pIlgtznu4wYK5z7iUAM1sBXAb0AHY12HYXcFhzOzOzHsCQDNSZcTOWVhfl5XmjA4WRz4/d9utZ/7JW2U6oTIJzNezatnGw2YmjWtP+4cXHFUxduLE1N03bA9eXjFw4+aOqjDYiEq4Nzrm9qTYKO3RX1rl+JFAO7AUaxAs9gT0p9jcEWNdexQXp4N4Kuh3unYVbe9CsMZPPrH99wROwaQfUJKvz//rqisvwXrRabN/OLWltt+3jN+oNMeyp2NySNta2tC6RHDMaeC3VRqGErpkNAI4A6g4hfB3vANpfgWIz6+uc2+avGw4sSbHbDXh/dM6J5HdZ6pw7xcysbKcXpnX16+kF7rI1UFbnPUDZTu8yL1LgBpw87j+Bh1vTfvei/gVAylDsO3AEk364+tD1lozpdi/qX7q7XD1d6dA2pLNRWD3dYUASuNTMHgFm4PVmlzvnqszsMWCumd0IXAz0BlY3t0O/W5/yVSYbxRLEgfvAGy7YtKPx7cp2Nb7OzKr6D/7SfOdcE7dM2X7GZ4ZMXbhxfTzKwUy3I5LtwjqQNgy4H5iO19s9B/iKc662J3Q13gG1HcAPgEuccx35HzaBN6zSGtVAIh6lVYErIsEKM3Rfdc6d7Zwrcs5d6Jw7NLDonCtzzp3vnOvunBvmnHs5pDoDEY+yF1gItPTDjWu3X9i+FX1ez74l9YYWAKbc9X6mmxXpcMIM3XdDajtbzQUep5HgrR3nrR3D9Tm8ObrfiUd5M5AKRaTNwgrdoSh06/G/VeESYLm/6NDJErXjvJXJQ5sn/fX/Eo+yLMg6RaRtQgldf0ihPIy2s5l/oOly4DzgUWj0A8srgDuBk+NR/l+A5YlIO9CHmGeZeBQHPAM8E0twLHA28AXgJmAmsEizAERyV7Z+4I0A8Sib/OGD2iGHFxW4IrlNoSsiEiCFroB3YG57Bve/3W9DpNNT6ErtzIn+eN/Blomf/n4bIp2eDqQJcCh4FYwiGaaerohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgHRGmmSdWIJ8IJKh3Sd1SnL7KXz6iYw+VpXnTexwj5V6upJV/MDdAhzI0M8Wvw1pIz9wM/pY+W10KApdyTYRoE8G99+HzPXMOhs9Vq3Q4V5FJDNiCQYCk4GBeJ8c9imwFvhtPEpViKWJ5BSFrjQrluBsvK8J+hreNxAn8b6F2AHXA9tiCe4B/iseRd97J5KChhekSbEEM4FVwFfxgjYPKMB7sS7wN+sL/BBYF0swKIw6RXKJQlcaFUswA1iAF7ap3hHl4Q07PB9LcHSmaxPJZVkbumZ2i5m9Y2Y1ZjYp7Ho6k1iC04CFja0rjMCxvb3LBiJAP2BZZqsTyW1ZG7rAX/HGDF8Nu5BO6Fqa+E6zfkUwa6J32YgIMD6WYGgGaxPJaaGGrpldZ2aPmdmzZlZhZq+Y2QAA59wy59zTePP1JCCxBEXAN2n9QdYkML39KpJc5jZ/QtU11+GqvAkuyRWPkPzFL0OuKlxh93SHAWOAucBRQBlwY6gVydfwpoS1VgQvtDNq17aNrLjtnHrLlszUcbxsYwOOJu+sf6Lm18txW/5BzbPPkRe9POyyQhX2lLFhwFzn3EsAZrYCuKw1OzKzHsCQdqwta4yLLhgycuJM1j9x1xCbPCujbV3+H6+N6lsyssbM8sAbu607lNCvZ/3Lusp2QqU3KHFEt8OKxx7Yu6PFX7t+ePFxBVMXbmx54S3wwPUlIxdO/khzi9vqyCMLCpYtSblZ3qWXkIzNIvnqOiLTp2GFhWk3UTV5ykjbujVXHqsNzrm9qTbKhtBdWef6kdDquZ5DgHVtLSgbvbv2IUZOnMm7ax9amum2/vrqw/QZeBoW8Z4atWO4DU0+8/PLFjwBm3Z4v1dV7n25Ne3v27mlNTdraRtrM95IZ1CxI63NLBLBTvsCNS++hJ06vKVt5NJjNRp4LdVGoYWuP3Z7BLC1zuKvA/e2cpcb8P7oDufk0iuGAEtPLr3iW1s/fH1DJts6fsSFF+dF8r9fe71spxemtfr19AJ32Roo21X/tmU7vUvnaqqSVQdLW9N+96L+BXhnuqW07eM36g0x7KnYnG4bpbvL1dNts16903qs3MebcG++Rd6YMdQ8+jiR/3VhS9ooJYd6uulsFGZPdxjeQZdLzewRYAbQE1gOYGYFeOODeUCBmXUFDjrnXGM787v1KV9lclEs4V2OnDhzwwvLZmb0b4wlqADm4M3PpTL5We+1rrJdjS8Hqs3yHnfOtarOWIIu6W7bd+AIJv1w9aHr6Y7pTl24cX08ysEWFyf1FD79RMrHyjlH8qd3k/e9a7CBA6mOzSTvzFKsuHdabRQsW7K+8ryJHeqxCvNA2jDgfrwj3VuBc4CvOOdqX9XuB/YD44CH/N+PC77MziUe5QPgKZqYMpaGfODu9qtIclnNY09gx5eQd/JJWLeuRL7zbZKLWvtmtmMIu6e7xjl3dWMrnXNTgClBFiSH/Aw4vxW3qwE+Ap5t33IkV0Uu/Gq963lnjCXvjLEhVZMdwu7pvhti+9K0x/GGeWpacBvnbz81Hm3R7VqlZ9+SekMLAFPuej/TzYq0WZihOxSFblbyQ/PbwBN4YXpI7YG12oNmvhqgGvjneJTVAZUpkpNCC13nXJFzTh8FmKXiUQ4AFwG34n12LkBN7YE1fz5u7VeprAPOiUd5JOAyRXJO2PN0JYv53yX2o1iCeXgnrfwrcBLe2YMf4R1wWxSP8kZoRYrkGIWupOT3epcCS2MJRuH1bCfFox1zip5IJoX92QsiIp2KQldEJEAKXck2SWB7Bve/ndaf+CH16bFqBY3pSlaJR6mOJehP5r56O+kfIJQ2qjxvYnXh00+kfKyufPuTkd2rkmur8vKmFtTUPLCvIFL68+FHr0+jiWTleRM73GOl0JWs44dih/tn64j8UGz2sZqzfnEVQH6yphqgZ2WyqqN9nkJLaHhBRCRACl0RkQApdEVEAqTQFREJkEJXRCRACl0RkQApdEVEAqTQFREJkE6OEEkhliAfnSEn7UQ9XZFm+IG7BTiQoZ8tfhvSSSh0RZoXAfpkcP99yFwvWrKQXmElJ8US5AH9gCPweoxb41H2hlqUSBoUupJTYgl6A1OAGUBJnVXVsQTLgUXA2ni0/hdqimQLDS9IToglsFiCmXjjq3cCxzXYJB/ve9xeAtbFEhwTcIkiaVHoSq6YBywACgHzfxqqfed2Gl7wnhBQbSJpy8rQNbMuZrbEzDab2adm9qyZnRx2XRKOWIJrgJsbW1cYgWN7e5d15OMdoHoylqBn5isUSV9Whi7eP837wBigGHgGWB5qRRKKWIKuwG1Nre9XBLMmepcN5AMn4o3/imSNUEPXzK4zs8f8nmyFmb1iZgOcc3udc7c55zY755LAz4AvmFnXMOuVUEwCerXh9jNiiUaHIkRCEXZPdxheb3YucBRQBtzYyHZnAhudcwcCrE2yw/eAmlbe1oBBwPj2K+fzdm3byIrbzqm3bMnMQZlsUnJYNoTuXOfcS865g8AKYHDdDcysN3A38IMQ6pPwjaBtz1Pn70MkK4Q9T3cYsLLO9SOB8torZtYN+D3wa+fcQ83tyMx6AEMyUGPoxkUXDBk5cSbrn7hriE2e1Wlq6dqjd+Sq+8q71F4vjHx+7LZfz/qXtcp2QmUSnHM1Oza/c4rZ8FGtqeHw4uMKpi7c2Jqbpu2B60tGLpz8UVVGGwnRhAsuHTJ63LlsfP8vx5cMOoV1Lz4zxOZcFXZZmbDBOZfyBJ3QQtfMBuCdTbS1zuKvA/f66/PxDp6955xLp5c7BFjXzmVmhXfXPsTIiTN5d+1DSztTLQf27qAmWUVepAD47KBZYyafWf/6gidg0w5wriay4cVffhf4bmtq2LdzS1rbbfv4jXpDDHsqNrekjbUtrSuXvPPGq4wedy5vv772lpJBp/DOG6+G/jzOkNHAa6k2CrOnOwxIApea2SN4Zxj15LNZCj/He1s5Lc39bcD7ozuck0uvGAIsPbn0im9t/fD1DZ2plppk9W8sL/9YM7OynV6Y1tWvpxe4y9ZA2a7Plpft9C7z8iIcffK4m19/7D9Xtab97kX9C4CUodh34Agm/XD1oestGdPtXtS/dHd5x+3pDh0xZgiwdPjppXOBW4aOGPOtf2z+KNTncYak9TeFHbr3A9PxercvAl9xzlWZ2XHAt/HOqf/U7NDB56HOuY8b25nfrU/5KpOLYgnvcuTEmRteWDYz1L8x6FpiCeJ4J0VQmfR6r40p29Xkuu0njPzaQudcZSvb75J6q7aZunDj+niUg5luJyxz5i8GoGTQKR8CjB537oZnH13eIf9X0xF26K5xzl3dcIVz7iMaP+NIOp8lwB3QqvCrAe6JR2lV4IpkQpizF4YB74bYvuSAeJQKvNkrLf0AmySwB7iv3YtqoGffknpDCwBT7no/081KjgozdIei0JX0zAaepJHgrR3nrR3D9SXxerlfi0dJ/4iWSABCC13nXJFzrjz1ltLZ+V9ncxFQO23w0Nfb1I7zViYBL5QdsBs4Jx7lhWArFUkt7JMjRNLiH2j6JlAK/Boa/V6xvwLXASXxaOoZByJhCPvkCJG0+R9M/t/Af/ufrTsa+CLeB+JMAZbqw8sl26mnKzkpHmVbPMrjeGO9AH9W4EouUOiKiARIoSvSvCSwPYP73+63IZ2EQlekGf7Mif5A1wz99PfbkE5CB9JEUvBDUcEo7UI9XRGRACl0RUQCpNAVEQmQQldEJEAKXRGRACl0RUQCpNAVEQmQQldEJEAKXRGRAHWqM9JiCfKBSIZ2n9TpnJJpZpbR57BzLmefw7ly33Sa0PUDdwvQJ0NNbI8ldB69ZI4fKhl9DptZ/1wM3ly6bzrT8EKEzD0g+PvO1KusCOg53JycuW86U+iKiIROoSsiEiCFrohIgDrNgTSRzqJbt24MHjyYoqIi9u/fz6ZNmygrK/vcdhMmTODll19m3759IVTZeWVtT9fMjjSzP5jZPjN728zOCLsmkWw2ePBg7rrrLjZv3sybb77JCy+8wLp16/jkk0/43e9+x/nnn4+ZATBp0iSefPJJbrjhhpCr7nyyNnSBe4G/AcXAPOA3ZtYl3JKCFUswMpbg/wOr/EXXxBIUhVRL11iC7wOP+ovuiSU4K4xa/HrOjCV4BljtL7oylqB7WPWEKRKJcPfdd/Pee+8xZcoUfvGLX3D22Wdz6qmnMmbMGGbMmMEJJ5zAH/7wB9avX8/06dP51a9+xW9/+1vmz58fdvmBWLVq1aHfo9Eoa9euZe3atUyYMIFjjjmGZcuWBVZLqKFrZhEz+zcz+8DMys3sLn/54cAFwI+cc/udc8uACuCcTNaza9tGVtxWv4klMwdlsskmxRKcA6wBvgoc5i+OAi/HEoeuB1VLAfAs8O9AP3/x6cBTsQSTgqzFr+frfj0TgB7+4inA6liCTvXCnJeXx/Lly7nqqqu48cYbGTBgALNmzeKFF17g7bffZt26dSxatIhTTz2V8ePHM2DAABYtWsRTTz3FFVdcQXV1zk3JbZOioiJuuukmxo8fz4UXXkg8HueTTz7hqKOOIj8/mNHWsHu684BxwBnAScCXzewCYDBQ7pzbWmfbPwFDgy8xeLEEBizG++LCuo9RIXAccGXAJV0CjAC61VlmQBfgPv/Ek0DEEkSA+/y2rc6qLsAw4LKgaskG8+fP56KLLuKyyy5jwYIF7N+/v8lt+/TpQ69evdi3bx8nnHACBQUFAVaaHcaOHctLL73EwYMH2bFjB5s2beLEE0/k+eef56yzgnnjFtqBNDMbAFwFHO+cK/eX/RYYBTwH7Gpwk13QdA/PzHoAQ5paf3jxcQVTF25sY9XNe+D6kpELJ39U1db9DJ9w1VETvrPoeLNGXxO7VR7Yc7XZ4S+1tZ10fXfR1mu69+zb6Fv3mppk9z89e983bfK1fwqilnFX3HnKF78SO8LyGp2n3v3Anh3XmBVvCKKWENRLyb59+zJjxgxuv/12HnnkkWZvOGnSpENDCnfccQfr16/n8ssv54EHHmi46Ugza/NzuK4JF1w6ZPS4c9n4/l+OLxl0CutefGaIzbmqPZuABvdNU4qLi6moqDh0vaKiguLiYlauXMmVV17Jc88919zNU903G5xze1PVEObshS/j9Zw+qB3cx6vnfwN7gZ4Ntu8J7Glmf0OAdU2t3LdzS1pFbfv4jXpDDHsqNqd1O7+NtWlv3IxNbz9NTXUVkYLG3ynv2vbhSTTzt7a3rR++TslpX2l0XXXl/q7vvfyrz/3nZsp7ryzn1C9Pp6BLj0bXb/v4zTMI8L4J09SpUzEzfvrTnza7Xd3ArR1SePLJJ7n22msbC912eQ7X9c4brzJ63Lm8/fraW0oGncI7b7y6tL3bSFd5eTm9evU6dP2II46gvLyc6upqDjss5ahdqvtmNPBaqp2EGbq9gPudc9c0XOGP6RabWV/n3DZ/8XBgSTP724D3Rzeqe1H/AtJ4QvUdOIJJP1x96HpLxnS7F/Uv3V3e9p7u3k+3GN4BqyMbrnM1NQcN+yVwf1vbSVdBl+5fr6lJ3piXF+nacF1+Ybf9eys+OReoDKKWPTv+nh/J7/IMn43lHlJTkzwQye8SB34TRC0hqPccnj59Og8//DDbtm1r8gaNBS7APffcw8qVKxk1ahSvvVYvJ0qBdu3pDh0xZgiwdPjppXOBW4aOGPOtf2z+qL3fjaT1//3KK6/wk5/8hMLCQnr06MHAgQP54IMPuO666/j973+f6uap7pu0/qYwQ/dN4AYzG+Sce9/M+gFDnXOrnHO7zewxYK6Z3QhcDPTmsyPVn+N365t8lQniAMvUhRvXx6McbI99xRJcAySgXt3Vlpf3afGxw3/gnNveHu2kWcs7wD/jjScX1ll1IC8vcvOnZR+0e+8oRT03AP+FN+ZdqzIvL7Ll6JNKb3fONfeOKGfVnb3TvXt3SkpKuOWWW5rcvqnABXj66acBGDZsWMPQXe+ca5fncK058xcDUDLolA8BRo87d8Ozjy5P2SNsiXRnNu3cuZMFCxawevVqAGbOnElNTQ3nn38+F198caqbt8t9E9qBNOfcarze2vNmtht4ATi+ziZX4x1Q2wH8ALikvZ8M2Swe5Td4B7Bqx0qr8Xpwo+JRAgtcv5Z9eAc7H4RDLyp/A/41HuVnQdbi13M/8G3gfX9RJfArYGw82uwQVIdy66238vrrrze6bsCAASxbtqzRwAXYt28fP/7xj/nzn/8cRKlZ5cEHH6S0tJTS0lKeeeYZevfuzf79+5s9CNmezDkXSENh83u6BzLcTNf26unWFUtQiPd5vcn23ncraskDCjLxd7aG/7hWxaPUhF1Lpvm9ubSfw2effTZr1qxp6bSwrhno6Y7CG2f/FrAUGD1v9rRM9HSbvG9WrVrF+PHjG13XtWtXevToQXl5eapm2uW+0WnAOSAeDWa8NB1+uGVF4AJkS/hno+effz7sErJGU4ELcODAAQ4cyHR/7DNhz9MVEelUFLoiIgFS6IqIBEihKyISIIWuiEiAFLoiIgHqTKGbhIyeVLDdb0MkU/QcblrO3DedZp5uPEp1LEF/MvcV08l4lM714aQSKOdctZml9RyePvvHI3sc3nNtVWXl1ILCwgf27t5Veu/8f1uf4mZJ51xOPodbct+0UrvdN50mdMELXlAwSu7y//FTPofnzF9cBZCfX1ANUNSruKqjn0af7n0Tts40vCAiEjqFrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiASow3/gjf/JQ7cDg51z48KuRyRT5sxffA1wgn+1n3/5L/7l9XPmL/4HcNu82dN21r2dmXUH7gZ2AVudc7cHUW9n1eF7us65Lc65qUDKL7UXyXFvAjcAs4DJ/rKv+peTgUjDwPVdDDzqnLseOMnMjsh0oZ1Zhw9dkc5i3uxpa4CfNLH6HeDfmlh3LPCx//tmoH87lyZ1dMrQnTN/sYVdg0iGzMXr8dZVBUyeN3vagSZuswkY6P8+ANiSodqELA1dM8szs5+a2U4z+8TMrmzDvrqY2b3AaWZ2rx+4j8+Zv/im9qtYJDvMmz2tEm8oobLO4rnzZk/7YzM3ewS40MwWAu865z5tz5o2vPVa8R9ffp5Hlt5z/tYtf2/PXeckc86FXcPnmNl1eE+cicAxwHPABOdcw1fwFpszf/EE4Fnge/NmT7u7rfsTyUZz5i+eBdwJrAXOmjd7WijffWZmNwE/LigozK+urqrKy4sUFHbp+uL+fXvO6+jfZNGU0EPXzE4EfgaMBQ4APwCuAu50zq3wt/kZsNs59/2m9jNn/uIewJA0mlyM9xbqG9TvDYh0JAYs8H82h1HA7x66/0t/eev1O8EV1ivMrKpnr+IV02++/a4w6sqgDfNmT9ubaqNQp4yZWT7wKLAUuAjoDhwHDAXeqrPpn/jsKGxThgDrWtD8mhZsK5KrQpsmuW/PbuDznTrnXMHe3Tsvr66uujw/vyD4wjJnNPBaqo3Cnqc7Fihwzt3hXz8IVJhZD7w5g7V2AYel2NcGvD+6OerligTk7x+9/xhwZGPrqquqkn9567WvDR/5pW0Bl5VJG9LZKOwDaccAGxtZvhfoWed6T2BPG9saBXwRWIICVyTjCgoKmxnWsOSA4wY1Nme4wwu7p/t3oKSR5e8ApwLv+deH+8uak+7wws3+j4hk0IWXfYffPXQ/1dVV9ZZH8vM59fTSwl7FfTvaEF9ODC+8AlSZ2c3AQrwx3YHAQ8DNZrYKrzd8OfDlFPtqbnhhFHAP3sTxh9uhbhFJYdDQ0yjq3eeq8q1bpgBJIAKWzM8vePv00vHX4w0ndiRpDS9kw+yFQcAivMDchzd74UG8EP6mv+zfnXM/b20bc+Yv/jZe7/b0ZiaIi0gGmFkJcAnQDW+65ssu7OAJUeihG5Q58xfnzZs9rSbsOkSkc+s0oSsikg3Cnr0gItKpKHRFpN2Z2Wgzm2tmtzZY/gUzW2lmp4RVW9gUuiKSCRcBtwLlZvZ/zewbZnaJc+4tYGWolYUs7CljItKx1eCd5v8fzrmzwi4mG+hAmoi0OzMbi/cpgV38n5V4J0I9D9yBdybqLZ3xk8YUuiIiAdKYrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiARIoSsiEiCFrohIgBS6IiIBUuiKiARIoSsiEqD/ARs3D+pOoFJgAAAAAElFTkSuQmCC\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -241,7 +241,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 4, @@ -282,9 +282,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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iwGXY9IhAHCxpKQkjR49Wv369VPPnj3VqlUrxcbGKjs7W9u2bdPGjRv117/+VV9++WWZQWDevHkaMmSIBg8erJ07d4bk3wD3IwwAgAs1a9ZMTz75pO6++27Vr1//op83adLEueIfMWKEfv3rX2v9+vWaOnWq3n333VJBYOzYsSosLFRqaiphAOUiDLjM8Wxp9S5p497zr733pXRtJ6lFcihLhrNO5Upr/iGt23N+n7y9Xvp+J6lNk/DbTx6v9M1hadXX51+b/jfpmo5S7w5S3TqhLJ2dxowZo1deecU54Rtff/21/vznPzu1ALt27VJRUZEaN26sq666SgMGDNAtt9yiPn366J133tGSJUv04IMPatq0aeeCgKlZWLp0aaj/WXAxwoCLbNonzV8tFXtLv25OPOZx69XSoM6hKh2MnQeluaukwuLS+8OEN/O4vqs0rLsUFRUe+6uoWJr3hfT3/VLJIn93Snp3o/TRdun+66SUhiEspGWef/55TZ482VnesmWLJk2apI8//rjMdT///HPNmDFDl1xyiR566CE99thjuu2223TDDTcoISHhXBD4y1/+EuR/BcINHQhdYtd30rzVvqu08ry3SdpQ4moUwbU/U5qz0ncCLc/ybaWvsN3urfW+IGCU/OidXc46I/3nx1J2XihKZ59nn332XBB4+umnnav98oJASUePHtWUKVPUt29fZWVlOUHA6/Vq/PjxBIEgGzFihL766it98803zh0QDVPDs3DhQrmZNWHAVJF16tRJHTt21OzZs+U2f9vq+wauIAs43t9acWBwq5fvjtX8x3vozV921f+8PFqF+bkKNx9tk4o8lR+jD9Ivrjlwo6NZ0vpKwqX3/wKBaboKpcOZ+/TAf/Qq9drURfdo7Y7qVX0PfSxW437fQ//6Ylf9dt5oTZo5WKu3nb96XrZ+jp5beJeCadiwYfrVr37lLN9///1OfwFzZe8v00fA1CI0aNDACQJRUVG65557FB1tzdd8yLRr187pt2GOwe9//3unw6Zpwnn00Ued5pzjx487HT9NSHMrK5oJTPvahAkT9Omnn6phw4ZOj9yRI0eea4+rjPnDys0N3Mnr6Oko7T7i34fkRI60dW+eOjYP7tnG46lrvm6q/f74hGTd+ewWZ/lv/3mn0j+eqauHT6hmWYqVkxPcS9XTeVHaur+eSlemly23QNqw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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -299,9 +299,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -310,7 +310,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 5, @@ -346,9 +346,9 @@ "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -357,7 +357,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 6, @@ -404,7 +404,7 @@ "* `self.noise_simulation`: if true, the optimization is performed with noise. Otherwise, the optimization is noiseless.\n", "* `n_hof`: the number of different circuits we maintain in the hall of fame\n", "* `self.noise_model_mapping`: noise models (as previously discussed)\n", - "* `self.save_openqasm`: whether we want to save the circuit population, hof, or both to openQASM strings" + "* `setting.save_openqasm`: which circuits the solver writes out as openQASM after each generation: `\"hof\"`, `\"pop\"` or `\"both\"` (the default, `\"none\"`, saves nothing). They are saved only when the solver was given an `io`." ] }, { @@ -448,9 +448,7 @@ "source": [ "\"\"\" Example where we mess around with settings to 'tune' our solver\"\"\"\n", "\n", - "solver.n_stop = 20\n", - "solver.n_pop = 100\n", - "solver.tournament_k = 3\n", + "solver.setting = EvolutionarySolverSetting(n_stop=20, n_pop=100, tournament_k=3)\n", "\n", "print(f\"default transformation probs: {solver.trans_probs}\") # we will keep these as is\n", "\n", @@ -527,9 +525,9 @@ } }, "source": [ - "## `DeterministicSolver` [Public API]\n", + "## `TimeReversedSolver` [Public API]\n", "\n", - "Deterministic solver which follows the paper by Li et al [1]. \n", + "Time-reversed solver which follows the paper by Li et al [1]. \n", "[1] Bikun Li, Sophia E. Economou and Edwin Barnes, npj. Quantum Information 8, 11 (2022) " ] }, @@ -542,14 +540,6 @@ } }, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "D:\\Jobs\\Ki3\\graph-compiler\\src\\state.py:238: UserWarning: Stabilizer representation being set is not compared to previously existing representations. Make sure the newrepresentation is consistent with other object representations\n", - " warnings.warn(\n" - ] - }, { "name": "stdout", "output_type": "stream", @@ -559,9 +549,9 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAV0AAACsCAYAAADYMX+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/YYfK9AAAACXBIWXMAAAsTAAALEwEAmpwYAAAXP0lEQVR4nO3df3xU9Z3v8dcnIRMIGGAj3Ar4owqmUvGiQCpLt9eVVbF65Uepwo7Yh25vev1Rdq67fcA2SrEtXtJt7bT3Vrooplbn4bZmXcRuZZGqZUGttqu3VFgsq2gBpRiQYEISk/neP84EQshMSDLzPZPwfj4ePIacc+Z7Pt+ZyXtOvufMfM05h4iI+FEQdgEiIqcSha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhHCl0REY8UuiIiHil0RUQ8GhR2AQNZLIEBg4HmeJRk2PWISPhMU7BnVyxBAXAVEANmAoWAA34D3Af8UzxKS2gFikioFLpZFEvwMWAD8AmCoZvCDqsd0AIcAK6IR3ndf4UiEjaFbpbEEpQRHM2eAUTalxtQUgyNzUHqAm1AIzAtHmWH/0pFJEyhn0gzs4Vm9lLq38Kw6+mD/0unwIUgcFfMD25TCoES4HGv1YlIXgg1dM1sOHA3wdjnTODu1LJ+JZZgNDCfToGbQSHwyViCabmrSkTykbfQNbNnzazWzJ4xs61mNgG4FNjsnGtwzjUAW4BP+aopi74AtPbwPm3AHTmoRUTymM9Lxi4CZjvntpjZHcBNwHaCE0vtDgBl6Rows4JM68Ny64OHp0YGDxsMx8Zw25VEjr+Fo+O7RW2tH00xi4zyWKqI5Fadcy7j5aFeQtfMzgNedc5t6bD4Q6AOGNlh2cjUsnTKgD9mv8K+2fXav3D+pTcAx8ZwO6u6rsP/a6GhGQ7t+/0nycP+iEivjQb2Z9rA15HuFFIn71NjtjcSjIEeBr5jZiWp7WYAX8nQTh1Bp/LK2Rdd9XXn3BfNbFBjcxCq7UoiQeCuWAeNqatzG5vBOUfp6HM3A/NCKVpEciHTQSPgN3TfMLMNwDBgmXNuN4CZrQB+QfCX+Tedc4fSNZI6bM/4LhKGWIIaoBKCd5aG5hO3aWw5frmZNQ8qGrzaOZd3/RGR3PEZulHn3L7OK5xzjwGPeaojV14hGJ/+ZA/u04IuGxM55fi6emFcV4E7UMSjOGAxJ38FQyvwt/EoTbmrSkTykT6RlkWxBNcDCYKhkkI44RNpSYIRiGXxKPeGV6mIhCX0T6QNJPEoPwWmA08SBOwRB0cagsB1wL8B1yhwRU5dOtLNkdSX38wExgLVwKfiUV4OtyoRCZtCN8diCUYRXIs7Oh7NvysvRMQvDS+IiHik0BUR8UihKyLikUJXRMQjha6IiEcKXRERjxS6IiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRz4kpRXoklmAbMCZLze2NR5mYpbZEek2hK/lsDDA87CJEskmhK70WS2BABXArMJHg9fQOsAb4eTxKW4jlieQljelKr8QSXAT8DtgC/CUwDbgY+O9ALbAnlmBOaAWK5CmFrvRYLME04CWgnGBaoqIOqwuACPBfgNpYgi/4r1Akf4Ueumb2hJm9a2aPhl2LdC+WoBTYABSTmgcOgrnghhYHtx0UAg/GElzisUSRvBZ66AJ/DSwMuwg5aYuAwXR67ZQUw4r5wW0nDrjTT2ki+c9b6JrZs2ZWa2bPmNlWM5sA4Jz7g68apG9SJ87uJAjdk1UE3BBLUJbNWnZve55X1q08+vPa6lnZbF4kZ3xevXARMNs5t8XM7gBuAu7uSQNmVgDZ/eXNtemf/2ZZxewqXn5yRZndeFfY5fRJxdxlI6d/7p5z4djU8u1KIsfftmtshqRzbQf2vH6F2aRf9GR/ix9pSz3lfedc0swKR2WlMZH06pxzyUwbeAldMzsPeNU5t6XD4g970VQZwSSP/cZr6+NUzK7itfXx7WHX0lfbN9Uw/XP3AMeGEzqruq7Tz7XwQX1j8UtP3PNYT/fX0lhP8dARade/+Zu11L+/C4CD773RXVul9LPXjvRLoyHzBLS+jnSnEIztYWbDgRuBLn5lu1VH0Kl+Y/KsWBmwffKs2AUvPn5XXdj19MX50xcOB34PwRFsVe2xdSWRIHBXrIPGlmPLG5thUKSk5VNzly3a+XLtcz3ZX6SkdCdQmm79uVPmMO26pUD3wwuRktJ6YHxP9i/SC93+jvsM3TfMbAMwDFjmnNsNYGbfBT4DjDGzjcAXnXO7umokddjer6YxjyWC24rZVXUv/LSqX9Xehf2xBL8DLnRAQ/OJGzS2nLjczJKnnzlpvXOuvic7iyWCN+psMCtwzrn+/vjLAOAzdKPOuX2dVzjn/penGiQ77gPu5+RPprUAD8ej9ChwuzNu4mWMm3jZ0Z/nLFmfzeZFcsbX1Qvjugpc6Zf+ETgAPfqIbzw3pYj0P15C1zn3CR/7kdyLRzkCXA4cAlrbl7eP8TYeG1pwwEfAvHiU//Bdp0i+yocPR0g/E4+yA7gEeI4gXJvax3hdELRtBN/LcHk8yr+EV6lI/tG3jEmvxKO8DVwZS/Bx4GZgMsGX3fwIWB2P8uvwqhPJXwpd6ZN4lLeAZbEEowiug62KR/vXFSYiPml4QUTEIx3pSj7bm6dtifSaQlfyluY0k4FIwwsiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhHCl0REY/04QiRkMUSbAPGZKm5vfn8oZLIM09nta8tV1ydt31NR6ErEr4xwPCwi/DkVOprlxS6IicplqAcuAYYATQCLwPPxaPZm8tNBj6Frkg3YgkuA74BzACagEIgSfD7szuWYCXBdwgrfKVbOpEmkkEswc3ARoLANWAIECGYmHMQcA7wfeDhWEK/T9I9vUhE0ogluAp4gODI1tqXGzC0uMOCIISvB+71W6H0R6GHrpmdZWbPmtkmM3vZzK4LuyaRlG8RBO5xSophxfzgtoNi4G9iCU73VJv0U6GHLnAYWOCc+wxwLcGfaiKhiiWYAlzYw7u1EswXJ73gdv4nrd+JA5B89jna/umfwy0oR7yGbuqIttbMnjGzrWY2wTl30Dn3x9QmR0AnIyQvzKPDFPMnaTCwKJtF7N72PK+sW3n057XVs7LZfF6x8edh551L28OPkHzxVxTMmxN2STnh++qFi4DZzrktZnYHcBNwN4CZGbCKDONiZlYAlPkoNFumf/6bZRWzq3j5yRVlduNdYZeTMwOtn7c+UD82MuS0CARjtx2HEkoix98CNAbTz5NMtp1uNmhUT/a1+JG21Eu775xLmllhj/bv06D1PzMrSN/Xgr+4nNZFN1P4jeUEkZCeSybNzPKtr3XOuWSmDbyFrpmdB7zqnNvSYfGHHf7/PWC3c+6BDM2UEcw422+8tj5OxewqXlsf3x52Lbk00Pr5+vMP8l+vWkxBQeHRMdzOqjqcfaiqhYZmqN//1hn08DXa0lhP8dARade/+Zu11L+/C4CD773RXVulPd2/V42NMGxY2tVtP1hF4de/RvKntdj552ORokxt5WNfR0Pm2bB9HulOITV0YGbDgRuB+amfVwLmnFvaTRt1BJ3qNybPipUB2yfPil3w4uN31YVdT64MtH6eN3XuDWYF3waKG5uDUG1XEgkCd8U6aGwJljU2g3OudeiIM54CvtSTfUVKSncCpenWnztlDtOuC341uhteiJSU1gPje7J/r0pK0va1bd3PKLjwQgomBUPpyTU1FN5amamtfOxrt69936H7hpltAIYBy5xzu83sUuArwGYzez617bXOuQ87N5A6bM/4LpJvYongtmJ2Vd0LP63qV7X3xEDrZyzBGuB/A8WO4Ci2s8aW45ebmRUVD/1751yP+h9LZO88hlmB6+n+fYo883TavhZed+3R/xdMuhAmZT6PaQX53dd0fIdu1Dm3r+NC59xLdHFZjkiY4lGOxBLcD/w1weVg3WkFtgMvZbOOcRMvY9zEy47+PGfJ+mw2LyHwefXCuM6BK5Lnvga8CnRxnHucVoLzE3P0UWDpjrfQdc59wte+RLIhHqUJ+AtgPcH5iBYIxm+raqGxmTbgI2AXMC0e5c2wapX+Ix8+HCGSt+JRGuJR5gCTgAeBfQ6aGoJLxJ4n+Nax8niUnSGWKf2IvmVM5CTEo7wO3A7cHkswiuBSpYXxaP86sSvh05GuiIhHOtIVCd/ePG0rF9LX55xF2lzpR4V2uKjNndZSaPWYZToxme997ZJCVyRk+TynWbZlmtNsafXq9mGbCoLL78avXFI54IZvNLwgIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFowHw4IpZgGzAmS83tzdcL1k+VfsKp09ew+mlmWd2vcy4vH9986+eACV2CB3V42EV4cKr0E06dvobVTz2+IdDwgoiIRwpdERGPFLoiIh4NpDFdEcmSiooKysvLKSoqYt++fWzcuJHm5mNTxc2ePZtf//rX7NmzJ8Qq+yeFbg7FEowDPhN2HbkWS1AIfCr145Awa8m1WIKPAZeHXUcuFBQUcMstt3D77bdTXl7Otm3baG1t5ZxzzqGgoIA1a9Zw3333MXPmTGpqarj++usHZOhecsklDBs2jE2bNuWk/VCHF8xsoZm9lPq3MMxasimWwFLTd28FVqUW/ySWOKmpvPuVWIJygokZH0kt+n+xBNeGV1FupJ7TbxF8z2v7c/pkLMHQEMvKmkgkQm1tLcuXL+fHP/4xY8eOZerUqVx66aWMHTuWyspKPv3pT/P666/zox/9iEWLFvHUU0+FXXaf1NTUUFxczEMPPcT3v/99li9fDsCOHTuYP39+zvYbWuia2XDgbmBm6t/dqWVZt3vb87yybuXRn9dWz8rFbjr6HBAFRgClqWXTgapc7tR3P2MJDHgKGEfQV1K3j8QSub1EJ4Tn9AqgkuOf0ynAynR3yAZf/XzooYcoLy9n2rRpfPe73+XgwYNH17W1tbF27VpWrVrFyJEjOXToEL/85S9zUodv8+bNY+PGjSxevJixY8cyZswYGhoaGDo0d++l3oYXzOy/AXcBEaAO+Adgs3OuIbV+C8GfqBsytFEAlHW1bvEjbanVfedc0swKR/X2/rfXHPnyoKLBpZ0WD04m2/7KbND/6Utt+dTPq+947JwJFdefbgXH1+Ncsqh+/1s3mI3/577Ul099vW1Nwx1FxSWd30giLplcaFb49b7UFmI/DWDq1KnMnz+f8vJy3n333S43XLBgAWvWrCEajbJ48WJisRhVVccdQ5iZ9frxBfjMlbPLpl/+WX6/7bWREyZO5sVnf15mS7/UlyaP1pZuxdlnn83mzZsBePvttznzzDPZu3cvW7duZdKkSWzduvWEtrrpZ51zLpmpGC+ha2ZnAvcCn3XOHTKzvwdOBw502OwAaQK1gzKC6TxO0NJYT/HQEWnv+OZv1lL//i4ADr73RsadtDTWl6bbz8nY8x+bOHvSlScs//DA7jF9aRfyq59bfvJ3nHXhFQwedvzT9lFTw9AtP/nqPxC8sfZaPvX1rVef4vxLbzhh+ZHD+9O+Jk9W2P287bbbePzxx3n77be7XL9gwQJqampYtGgRtbW1tLa2smrVKu655x5aWlraN+vT4wvwypZfMP3yz/L800+8MGHiZF7Z8ovtfWnvZLzzzjuceeaZAJx11lns3r0bgAkTJvDDH/6wq7t018/RkHmGaF9HujcSFPOkmUFQ+GZgZIdtRhIcAWdSl2rnBJGS0p0c+7PvBOdOmcO065YC3f+JFikprQfGd1NLWqeVnTXLueQPzApOa1/mXPJIUaTke8B9vW03VVve9LN+/y4iQ4Zvds5NsNQTC1BUPPSDyJDSi4GG3radqi9v+jriYxNmOJd82KzgaD3OuabCQcU1wNd6226qtrD6udPMShcsWMDVV1/d5QadAxdg3bp1rFq1ipkzZ/L000+3b9qnxxdg2oyZZcD2y66e96fAC9NmzLxg04Ynu8uEk5H28X3iiSe4//77qaio4L333mPPnj2YGZFIhKampq7u0l0/u63XV+hGgGrn3IMAZhYhOMu9wsxKUtvMAL6SqZHUYXuX7yKxBJlmDe0RswLnnOv1hHixBI8Ck4D/ATiCkZFnh5SOWuac+6gvteVTP1P1zAKe4dgbaNIKCq7/3XMP7IIH+lRfnvV1bSzBt4E7gSTBn5kvFQ8dcadzrsvfzpMVVj/NzA0ZMoQ333yTHTt2nLB+7ty5JwQuQGtrK5s2baKwsLDj5n1+LS2tXg3AhImTDwJMv/yzdb/817V9npjSMswo3NTUxC233HLcshkzZvDiiy+mu0uf+2nOZe35Tr8TszOARwlCvhXY4JyrTl2xsJhgzOV7zrnHeruPWIIPyN7nqw/Fo0dPDPVaLMEo4ELgP+NR3ulzVeRtPw24GCgBfhWP0qc3lg7tfkD+9XUkMBnYFY/yVl/bS7X5ASH008wy7vfss8/moosuOtmrFA45505qv+l0mA34AoKrREZnYzbgdP2sqanh5ptv7mlzfe6nlyNd59y7BFcodF7+GNDroM138Sj7gefCriPX4lEc8O9h1+FDPMpBToHnFIITS+nGeQeCXgRuVuhjwCIiHil0RUQ8UuiKiHik0BUR8WggfeHN3jxtK9tOlX7CqdPXsPqZaVsrHjyktKW56XCkePBpzU1H6iHjpW16fE+Sl0vGRKR/ydXlW/m2zzBoeEFExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEI4WuiIhH/Tp0zexiM/u5meVuknoRScvMxpvZj81sjZkNpO9yyZl+/SA55141s28RzCwsIn20tHp1BTAVGJZatDB1e/PS6tVvr1xS+ZNOd7mJYMqty4E/4xSZVaMv+nXoikjWvQP8HChL/bwsdVsNfL6L7Uc45z4ws73AuR7q6/f69fCCiGTXyiWV7wGVXax6dOWSytoulh8ys+HAGcB7OS1ugAg9dM3sz83sRTN7wczu7OF9P04wJXalmU1aWr26fGn16juWVq8enJtqRQa+lUsqnwAe7rDoD8CX02z+CPA94Brg3/q6b+cc+/b+oXggf+VsqN+na2aFwFbgz4H3gS3ATc65N3rT3tLq1Y8Cc4FzBuL3cIr4srR69XDgt8BZwMyVSyqfzfU+h5UO/7tkMnlvU2PD4WGlI04bVFR004H9+x7J9X598x66ZrYcuBpoBNYC1zjnrkytqwL2O+dWd3XfpdWrCzg21tTZeGAz8APgG9mtWuSUNB24Clie6x39sPqrcw99cOA7ONd+Ag8zOzzunPFz//JLf/vbXO8/i+pWLqlMZtrA64k0M5sFXAxMd84lzWwGwRPb7gDpQ5XUuj92s5svk/5PIRHpudtyvYNBRRHodADonDutZFjpxlzvO8tGAxn/yvZ99cIkYKNzrv2d4H1gZIf1I1PL0jlAMJVHZx8HfgbUAN/OQp0i4tGB9/dtposDrjd3bP13IOq/ol470N0GvkP3t8D/NLMfpIJ3JzDOzEYDdcC1wBcy3P9PCOZOSuevUv9EpB+ZOLmCba/+io7DnYMGFfFnV86+hMy/8/kmv450nXP/amZ/CrxkZg1ALXAH8CTBTKO1zrnfZ2iijqBTHbWP5d4PfD37VYtIrg0detooYAMwAhgKHE4m23aN+JPTrwGawqyth+q626Dfzwa8tHr1IiAOXLBySWV3470ikqfMrAS4nmAYcjPwlHOuNdyqsq/fhy7A0urVJSuXVDaGXYeISHcGROiKiPQXoX8iTURObWY2z8y+amZTOyxbkLqmf8BR6IpI2MY55+4FZqTCdoZz7h+BD0KuKycUuiKST77onNsSdhG5pDFdEQmVmc0DPgEUAS+kFhcCtwJ/45zbGVZtuaDQFRHxSMMLIiIeKXRFRDxS6IqIeKTQFRHxSKErIuKRQldExCOFroiIRwpdERGPFLoiIh4pdEVEPFLoioh4pNAVEfFIoSsi4pFCV0TEo/8Pc9Mx++Hh4sYAAAAASUVORK5CYII=\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -576,9 +566,9 @@ }, { "data": { - "image/png": "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\n", + "image/png": "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/mlRw10GoLqPOS8f9x06dDi1DG5lvDHVPz/+8AfuLLdd1TLYqKp1H+wy4NwIAxextG91rwsAhJvXj3uzFG5V5qaf8+1656d57+nbCH3do3rQTQAAgOUIAwAAWI4wAACA5QgDAABYjjAAAIDlCAMAAFiOMAAAgOUIAwAAWI4wAACA5QgDAABYjjAAAIDlCAMAAFiOMAAAgOUIAwAAWI4wAACA5QgDlTBo0CA1aNBAQ4YMCd1vBEDEmTNnjtq3b6927drprbfeCndxgLMQBirhiSee0HvvvVeZtwCwXHFxsUaOHKkFCxZo9erVGjdunA4cOBDuYgHlEAYqISMjQ4mJiZV5CwDLZWZmKjU1VS1atFBCQoL69u2refPmhbtYQDmx5e8CgN18Pp9+2J6rkpLSU4+Vlvq3N2zeUW67TOuWzVS7Vtw597d7924nCJQx27t27Qrh/wCoPMIAAJwmKipKu37Zr6+/XXlWvUz+eN5Z221btVBKu1bUITyNbgIAOENG905KbnZJhfVSK66mhvS7WdFRUed9TfPmzcu1BJht8xjgJoQBADhDTHS07uvfUzVqXLjx9K5br1f9ugkXfE16errWr1/vhID8/HzNnTtXffr0oc7hKoSBSujdu7fuueceffHFF0pKStKyZctC95sBEFaNG9bTHT2vO+/zV13ZWp06tK1wP7GxsRo/frx69uypTp06adSoUWrUqFGQSwtcHMYMVML8+fPP+9yWHTsln9T2shZOnyMA7+vWKUWbtuY4AwpPl5gQr4G33RDw3/qAAQOcG+BWtAwEgRldPHv+Ur098wutWPdDMHYJwAXMyX5w35sVf8aVAveYx2rXktudPHlSG1Zn6v1JrziTHr014X9r2aIvdfDgwXAXDS4TkWFg//79TpOcme3r7rvvVkFBQUj/vezvt2vvgcPOYCLTdAggctRNiNegPjeeun/dNR10Retkud3evXud8QofTp6kH7ds0ubNm7U790d9/dlMXX755Vq6dGm4iwgXicgwMGbMGA0ePFhbtmxR69atQzr9p2kV+GZplrN9Y9ernEAAILKkXdla16S2VeMG9dQvo5u8MFfCnXfe6QxcLLt/OjOQ8fbbb1dubvnuD9grynfmUeJB77zzjiZOnOg06d1666367LPPtGLFCtWrV09r1qzRc889p6+++iqgfb381/dVeLIo4H/bTExSXFLibNesEct4ASBCmQ9K83F5ocsI3cK0BEz+27gLvsZ8Xvbo1U+33DG42sqF0IurWUPPP/6QfS0D2dnZmjBhghYvXqy1a9fq+eef15EjR5wgEOrZvswHQ8m/g0BMTDRBAIhgJgJ4IQgY2auWKTo6usLPr7Urvq22MsHdPH81wcKFC3Xfffepfv36zv2GDRte1P4qk6jWbtyqabMXOF0Do//jAboIALjCmkWfaG0Ajb5FhQX681OPVEuZ4G6eDwPnYloFyloHKjvbV6DdBCZVFxUXO9vm53+9PuWiygwAwZK755DTUllRL3DNuNp6YcK7VHwEsbaboFevXpoxY4Zz8jfMJTP9+/fX5MmTnfvvv/++M5Am2EpLfSr7OzOzlQGAW6R17n5qQaXzMWHh6vTrq61McLeIGEBorhYwAwjNTF+33XabnnnmGQ0ZMsRpFejYsaOmTZum2rVrB+3fM39kE9/5UHsPHNKtN3TRLddfG7R9A8DFMh/r3bt318qVK0+NazqdGU9Qp04dbdiwQcnJ7r9MEqEXEWGguq3dtE3TPvuGsQIAXGvfvn3OGgirV69WTEyMEwrKug5MF6pZI8EEBiBixwyEWubaTc5P5hUA4FZNmjTRd999p08//VRvv/22duzY4Qy0NgOuf/vb36pBgwbhLiJchJaBKigqKlbm2u/VOe0KriAAAHgeYQAAAMsxDB4AAMsRBgAAsBxhAAAixJw5c5ylis2KrRdaoM0sVHTLLbcoISFBTz/9dLWWEe7EmAEAiADFxcXq0KGDM0W7uXSwc+fOzjLFjRo1Ouu1hYWFzpUGZp6Bbdu26ZVXXglLmeEetAwAQATIzMxUamqqszib+cbft29fTZo0SSkpKcrLy9OJEyecSdjM4m5xcXG66aabgjoZG7yNeQYAIALs3r3bCQJlzLaZZOjxxx/XqFGjVLduXWeOgbS0tLCWE+5EGACACGbCwM0336xjx445XQPAudBNAAARwKzOatZjKVO2YuvRo0e1f/9+HT9+XAUFBWEtI9yLMAAAESA9PV3r1693QoC5WsCsPWDWJjBXC5jWgccee0zPPvtsuIsJl6KbAAAigFm1dfz48erZs6ezsqo58ZtFin744Qe98cYbzgJFN954oxYtWqSMjAznEkSzmFFRUZGmT5+u5cuXKykpKdz/DYQJlxYCAGA5ugkAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAAMsRBgAAsBxhAAAAyxEGAACwHGEAAADLEQYAALAcYQAAANnt/wO4za7iqCSyngAAAABJRU5ErkJggg==", "text/plain": [ - "
" + "
" ] }, "metadata": {}, @@ -587,7 +577,7 @@ { "data": { "text/plain": [ - "(
, )" + "(
, )" ] }, "execution_count": 8, @@ -597,17 +587,24 @@ ], "source": [ "\"\"\" Solver circuit initialization examples \"\"\"\n", - "from benchmarks.circuits import *\n", - "from src.backends.stabilizer.compiler import StabilizerCompiler\n", - "from src.backends.density_matrix.compiler import DensityMatrixCompiler\n", - "from src.backends.density_matrix.functions import fidelity\n", - "from src.solvers.deterministic_solver import DeterministicSolver\n", + "from graphiq.benchmarks.circuits import ghz3_state_circuit\n", + "from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau\n", + "from graphiq.backends.stabilizer.compiler import StabilizerCompiler\n", + "from graphiq.backends.stabilizer.tableau import StabilizerTableau\n", + "from graphiq.solvers.time_reversed_solver import TimeReversedSolver\n", "\n", "compiler = StabilizerCompiler()\n", - "target_circuit, target = ghz3_state_circuit()\n", + "target_circuit, _ = ghz3_state_circuit()\n", + "\n", + "# TimeReversedSolver works on the stabilizer representation. GHZ3 is not a graph\n", + "# state, so its density matrix cannot be converted to one; give the target\n", + "# directly by its stabilizer generators instead.\n", + "ghz3_stabilizer = StabilizerTableau(3)\n", + "ghz3_stabilizer.from_labels([\"XXX\", \"ZZI\", \"IZZ\"])\n", + "target = QuantumState(CliffordTableau(ghz3_stabilizer), rep_type=\"s\")\n", "metric = Infidelity(target)\n", "\n", - "solver = DeterministicSolver(\n", + "solver = TimeReversedSolver(\n", " target=target,\n", " metric=metric,\n", " compiler=compiler,\n", diff --git a/examples/startup_guide/8_variational_circuits.ipynb b/examples/startup_guide/8_variational_circuits.ipynb index d1d74104..aefb6039 100644 --- a/examples/startup_guide/8_variational_circuits.ipynb +++ b/examples/startup_guide/8_variational_circuits.ipynb @@ -3,7 +3,9 @@ { "cell_type": "markdown", "source": [ - "# Parameterized quantum circuits [Experimental]" + "# Parameterized quantum circuits [Experimental]\n", + "\n", + "**Not supported in this version.** This notebook does not run as written: setting `graphiq.DENSITY_MATRIX_ARRAY_LIBRARY` after `import graphiq` does not switch the density-matrix backend to `jax`; it keeps numpy. With `jax` selected (by editing `DENSITY_MATRIX_ARRAY_LIBRARY` in `graphiq/__init__.py`), no density-matrix `QuantumState` can be built from a matrix (`QuantumState` rejects jax arrays and `DensityMatrix` rejects numpy arrays), so the target below cannot be built, and the density-matrix arm of `Infidelity` cannot be differentiated. Its saved outputs come from an older version." ], "metadata": { "collapsed": false, @@ -268,34 +270,8 @@ }, { "cell_type": "code", - "execution_count": 9, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - " 0%| | 0/100 [00:00 4\u001B[0m grads \u001B[38;5;241m=\u001B[39m \u001B[43mjax\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mgrad\u001B[49m\u001B[43m(\u001B[49m\u001B[43mcompute_loss\u001B[49m\u001B[43m)\u001B[49m\u001B[43m(\u001B[49m\u001B[43mparams\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mcircuit\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 5\u001B[0m updates, opt_state \u001B[38;5;241m=\u001B[39m optimizer\u001B[38;5;241m.\u001B[39mupdate(grads, opt_state)\n\u001B[1;32m 6\u001B[0m params \u001B[38;5;241m=\u001B[39m optax\u001B[38;5;241m.\u001B[39mapply_updates(params, updates)\n", - " \u001B[0;31m[... skipping hidden 10 frame]\u001B[0m\n", - "Cell \u001B[0;32mIn[7], line 3\u001B[0m, in \u001B[0;36mcompute_loss\u001B[0;34m(params, circuit)\u001B[0m\n\u001B[1;32m 1\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21mcompute_loss\u001B[39m(params: \u001B[38;5;28mdict\u001B[39m, circuit: CircuitDAG):\n\u001B[1;32m 2\u001B[0m circuit\u001B[38;5;241m.\u001B[39mparameters \u001B[38;5;241m=\u001B[39m params\n\u001B[0;32m----> 3\u001B[0m output_state \u001B[38;5;241m=\u001B[39m \u001B[43mcompiler\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcompile\u001B[49m\u001B[43m(\u001B[49m\u001B[43mcircuit\u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 4\u001B[0m cost \u001B[38;5;241m=\u001B[39m metric\u001B[38;5;241m.\u001B[39mevaluate(output_state, circuit)\n\u001B[1;32m 5\u001B[0m \u001B[38;5;28;01mreturn\u001B[39;00m cost\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/compiler_base.py:121\u001B[0m, in \u001B[0;36mCompilerBase.compile\u001B[0;34m(self, circuit, initial_state)\u001B[0m\n\u001B[1;32m 118\u001B[0m no_noise \u001B[38;5;241m=\u001B[39m no_noise \u001B[38;5;129;01mor\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op\u001B[38;5;241m.\u001B[39mnoise, nm\u001B[38;5;241m.\u001B[39mNoNoise)\n\u001B[1;32m 120\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m no_noise:\n\u001B[0;32m--> 121\u001B[0m \u001B[38;5;28;43mself\u001B[39;49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcompile_one_gate\u001B[49m\u001B[43m(\u001B[49m\n\u001B[1;32m 122\u001B[0m \u001B[43m \u001B[49m\u001B[43mstate\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mop\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mcircuit\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mn_quantum\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mq_index\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mclassical_registers\u001B[49m\n\u001B[1;32m 123\u001B[0m \u001B[43m \u001B[49m\u001B[43m)\u001B[49m\n\u001B[1;32m 125\u001B[0m \u001B[38;5;28;01melse\u001B[39;00m:\n\u001B[1;32m 126\u001B[0m \u001B[38;5;28;01mif\u001B[39;00m is_controlled_op:\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:86\u001B[0m, in \u001B[0;36mDensityMatrixCompiler.compile_one_gate\u001B[0;34m(self, state, op, n_quantum, q_index, classical_registers)\u001B[0m\n\u001B[1;32m 80\u001B[0m \u001B[38;5;28;01mpass\u001B[39;00m \u001B[38;5;66;03m# TODO: should think about best way to handle inputs/outputs\u001B[39;00m\n\u001B[1;32m 82\u001B[0m \u001B[38;5;28;01melif\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op, ops\u001B[38;5;241m.\u001B[39mOneQubitOperationBase):\n\u001B[1;32m 83\u001B[0m unitary \u001B[38;5;241m=\u001B[39m dm\u001B[38;5;241m.\u001B[39mget_one_qubit_gate(\n\u001B[1;32m 84\u001B[0m n_quantum,\n\u001B[1;32m 85\u001B[0m q_index(op\u001B[38;5;241m.\u001B[39mregister, op\u001B[38;5;241m.\u001B[39mreg_type),\n\u001B[0;32m---> 86\u001B[0m \u001B[38;5;28;43mself\u001B[39;49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mops\u001B[49m\u001B[43m[\u001B[49m\u001B[43mop\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[38;5;18;43m__class__\u001B[39;49m\u001B[43m]\u001B[49m\u001B[43m(\u001B[49m\u001B[38;5;241;43m*\u001B[39;49m\u001B[43mparams\u001B[49m\u001B[43m)\u001B[49m,\n\u001B[1;32m 87\u001B[0m )\n\u001B[1;32m 88\u001B[0m state\u001B[38;5;241m.\u001B[39mapply_unitary(unitary)\n\u001B[1;32m 90\u001B[0m \u001B[38;5;28;01melif\u001B[39;00m \u001B[38;5;28misinstance\u001B[39m(op, ops\u001B[38;5;241m.\u001B[39mControlledPairOperationBase):\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:31\u001B[0m, in \u001B[0;36mDensityMatrixCompiler.\u001B[0;34m(theta, phi, lam)\u001B[0m\n\u001B[1;32m 19\u001B[0m \u001B[38;5;66;03m# TODO: [longer term] refactor to take a QuantumState object input instead of creating its own initial state?\u001B[39;00m\n\u001B[1;32m 21\u001B[0m name \u001B[38;5;241m=\u001B[39m \u001B[38;5;124m\"\u001B[39m\u001B[38;5;124mdm\u001B[39m\u001B[38;5;124m\"\u001B[39m\n\u001B[1;32m 22\u001B[0m ops \u001B[38;5;241m=\u001B[39m { \u001B[38;5;66;03m# the accepted operations and the single-qubit action needed for each gate\u001B[39;00m\n\u001B[1;32m 23\u001B[0m ops\u001B[38;5;241m.\u001B[39mInput: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 24\u001B[0m ops\u001B[38;5;241m.\u001B[39mIdentity: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 25\u001B[0m ops\u001B[38;5;241m.\u001B[39mPhase: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mphase(),\n\u001B[1;32m 26\u001B[0m ops\u001B[38;5;241m.\u001B[39mPhaseDagger: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mphase_dag(),\n\u001B[1;32m 27\u001B[0m ops\u001B[38;5;241m.\u001B[39mHadamard: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39mhadamard(),\n\u001B[1;32m 28\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaX: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 29\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaY: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmay(),\n\u001B[1;32m 30\u001B[0m ops\u001B[38;5;241m.\u001B[39mSigmaZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[0;32m---> 31\u001B[0m ops\u001B[38;5;241m.\u001B[39mParameterizedOneQubitRotation: \u001B[38;5;28;01mlambda\u001B[39;00m theta, phi, lam: \u001B[43mdm\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mparameterized_one_qubit_unitary\u001B[49m\u001B[43m(\u001B[49m\n\u001B[1;32m 32\u001B[0m \u001B[43m \u001B[49m\u001B[43mtheta\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mphi\u001B[49m\u001B[43m,\u001B[49m\u001B[43m \u001B[49m\u001B[43mlam\u001B[49m\n\u001B[1;32m 33\u001B[0m \u001B[43m \u001B[49m\u001B[43m)\u001B[49m,\n\u001B[1;32m 34\u001B[0m ops\u001B[38;5;241m.\u001B[39mParameterizedControlledRotationQubit: \u001B[38;5;28;01mlambda\u001B[39;00m theta, phi, lam: dm\u001B[38;5;241m.\u001B[39mparameterized_one_qubit_unitary(\n\u001B[1;32m 35\u001B[0m theta, phi, lam\n\u001B[1;32m 36\u001B[0m ),\n\u001B[1;32m 37\u001B[0m ops\u001B[38;5;241m.\u001B[39mCNOT: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 38\u001B[0m ops\u001B[38;5;241m.\u001B[39mCZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 39\u001B[0m ops\u001B[38;5;241m.\u001B[39mClassicalCNOT: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 40\u001B[0m ops\u001B[38;5;241m.\u001B[39mClassicalCZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 41\u001B[0m ops\u001B[38;5;241m.\u001B[39mMeasurementZ: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmaz(),\n\u001B[1;32m 42\u001B[0m ops\u001B[38;5;241m.\u001B[39mMeasurementCNOTandReset: \u001B[38;5;28;01mlambda\u001B[39;00m: dm\u001B[38;5;241m.\u001B[39msigmax(),\n\u001B[1;32m 43\u001B[0m ops\u001B[38;5;241m.\u001B[39mOutput: \u001B[38;5;28;01mlambda\u001B[39;00m: \u001B[38;5;28;01mNone\u001B[39;00m,\n\u001B[1;32m 44\u001B[0m }\n\u001B[1;32m 46\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21m__init__\u001B[39m(\u001B[38;5;28mself\u001B[39m, \u001B[38;5;241m*\u001B[39margs, \u001B[38;5;241m*\u001B[39m\u001B[38;5;241m*\u001B[39mkwargs):\n\u001B[1;32m 47\u001B[0m \u001B[38;5;250m \u001B[39m\u001B[38;5;124;03m\"\"\"\u001B[39;00m\n\u001B[1;32m 48\u001B[0m \u001B[38;5;124;03m Create a compiler which acts on a DensityMatrix state representation\u001B[39;00m\n\u001B[1;32m 49\u001B[0m \n\u001B[1;32m 50\u001B[0m \u001B[38;5;124;03m :return: nothing\u001B[39;00m\n\u001B[1;32m 51\u001B[0m \u001B[38;5;124;03m :rtype: None\u001B[39;00m\n\u001B[1;32m 52\u001B[0m \u001B[38;5;124;03m \"\"\"\u001B[39;00m\n", - "File \u001B[0;32m~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/functions.py:794\u001B[0m, in \u001B[0;36mparameterized_one_qubit_unitary\u001B[0;34m(theta, phi, lam)\u001B[0m\n\u001B[1;32m 775\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21mparameterized_one_qubit_unitary\u001B[39m(theta, phi, lam):\n\u001B[1;32m 776\u001B[0m \u001B[38;5;250m \u001B[39m\u001B[38;5;124;03m\"\"\"\u001B[39;00m\n\u001B[1;32m 777\u001B[0m \u001B[38;5;124;03m Define a generic 3-parameter one-qubit unitary gate.\u001B[39;00m\n\u001B[1;32m 778\u001B[0m \n\u001B[0;32m (...)\u001B[0m\n\u001B[1;32m 790\u001B[0m \u001B[38;5;124;03m :rtype: numpy.ndarray\u001B[39;00m\n\u001B[1;32m 791\u001B[0m \u001B[38;5;124;03m \"\"\"\u001B[39;00m\n\u001B[1;32m 792\u001B[0m gate \u001B[38;5;241m=\u001B[39m np\u001B[38;5;241m.\u001B[39marray(\n\u001B[1;32m 793\u001B[0m [\n\u001B[0;32m--> 794\u001B[0m [\u001B[43mnp\u001B[49m\u001B[38;5;241;43m.\u001B[39;49m\u001B[43mcos\u001B[49m\u001B[43m(\u001B[49m\u001B[43mtheta\u001B[49m\u001B[43m \u001B[49m\u001B[38;5;241;43m/\u001B[39;49m\u001B[43m \u001B[49m\u001B[38;5;241;43m2\u001B[39;49m\u001B[43m)\u001B[49m, \u001B[38;5;241m-\u001B[39mnp\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m lam) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39msin(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m)],\n\u001B[1;32m 795\u001B[0m [\n\u001B[1;32m 796\u001B[0m np\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m phi) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39msin(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m),\n\u001B[1;32m 797\u001B[0m np\u001B[38;5;241m.\u001B[39mexp(\u001B[38;5;241m1\u001B[39mj \u001B[38;5;241m*\u001B[39m (phi \u001B[38;5;241m+\u001B[39m lam)) \u001B[38;5;241m*\u001B[39m np\u001B[38;5;241m.\u001B[39mcos(theta \u001B[38;5;241m/\u001B[39m \u001B[38;5;241m2\u001B[39m),\n\u001B[1;32m 798\u001B[0m ],\n\u001B[1;32m 799\u001B[0m ]\n\u001B[1;32m 800\u001B[0m )\n\u001B[1;32m 801\u001B[0m \u001B[38;5;28;01mreturn\u001B[39;00m gate\n", - "File \u001B[0;32m~/.virtualenvs/graph-compiler/lib/python3.9/site-packages/jax/_src/core.py:640\u001B[0m, in \u001B[0;36mTracer.__array__\u001B[0;34m(self, *args, **kw)\u001B[0m\n\u001B[1;32m 639\u001B[0m \u001B[38;5;28;01mdef\u001B[39;00m \u001B[38;5;21m__array__\u001B[39m(\u001B[38;5;28mself\u001B[39m, \u001B[38;5;241m*\u001B[39margs, \u001B[38;5;241m*\u001B[39m\u001B[38;5;241m*\u001B[39mkw):\n\u001B[0;32m--> 640\u001B[0m \u001B[38;5;28;01mraise\u001B[39;00m TracerArrayConversionError(\u001B[38;5;28mself\u001B[39m)\n", - "\u001B[0;31mTracerArrayConversionError\u001B[0m: The numpy.ndarray conversion method __array__() was called on traced array with shape float32[].\nSee https://jax.readthedocs.io/en/latest/errors.html#jax.errors.TracerArrayConversionError" - ] - } - ], + "execution_count": null, + "outputs": [], "source": [ "cost_curve = []\n", "for step in (pbar := tqdm.tqdm(range(n_step), disable=(not progress))):\n", @@ -391,7 +367,8 @@ "execution_count": 10, "outputs": [], "source": [ - "from src.solvers.gradient_descent_solver import GradientDescentSolver, adagrad" + "from graphiq.solvers.gradient_descent_solver import GradientDescentSolver\n", + "from optax import adagrad" ], "metadata": { "collapsed": false, diff --git a/examples/startup_guide/9_html_visualization_tutorial.ipynb b/examples/startup_guide/9_html_visualization_tutorial.ipynb index d664e416..227b8d64 100644 --- a/examples/startup_guide/9_html_visualization_tutorial.ipynb +++ b/examples/startup_guide/9_html_visualization_tutorial.ipynb @@ -16,7 +16,7 @@ "- `Painter` class that helps in building positions, coordinates mapping, and visualization info to send to the app.\n", "- `OpenQASMParser` class, a parser that helps extract useful information from the OpenQASM 2.0 string\n", "\n", - "\"Drawing\"" + "![Visualization system design: OpenQASMParser, Painter, Flask and d3.js web app, HTML pages](../img/html_visualization/visualization_system_design.png)" ] }, { @@ -35,14 +35,14 @@ "1. Install `Flask`: If you haven't install `Flask`, run:\n", "`pip install Flask` or `pip install -r requirements.txt` since the `Flask` package is included in the `requirements.txt` file\n", "\n", - "2. In the `html_visualization` directory, you can see a file called `app.py`. \n", + "2. In the `visualization` directory, you can see a file called `app.py`. \n", "Simply run `python app.py` or run the `app.py` file on PyCharm IDE.\n", "\n", "3. Open a new tab on Google Chrome, and type in `127.0.0.1:5000` and you will see the app open\n", "\n", "Here is how the app looks like at first. Don't worry if you see the page is empty, that's how the web app is initialized. We will get to the part where we send visualization info to the app and it will draw on the svg element.\n", "\n", - "\"Drawing\"/" + "![The Painter web app at start: an empty circuit canvas and an empty Gate Info panel](../img/html_visualization/app_init.png)" ] }, { @@ -72,13 +72,13 @@ "- The Painter class use the Columns class to keep track of the position of operations, registers, and the width of each column.\n", "- The Columns class is a 2d array with an array columns, and each column is an array has a size equal to the number of registers.\n", "- When an operation is added, the Columns class will fill with value 1 where the operation is on. \n", - "\"Drawing\"/\n", + "![Four columns of a circuit and the Columns array marking each operation's cell with 1](../img/html_visualization/columns_class_0.png)\n", "\n", "- For special case like barriers, every columns before the barrier is filled with -1. This will help to add next operation after the barrier when the Columns find the empty space to add new operation\n", - "\"Drawing\"/\n", + "![A barrier column marked with 1s, and -1 filling the empty qubit cells of the columns before it](../img/html_visualization/columns_class_1.png)\n", "\n", "- Columns width: Each column will have a width of the column. This is helpful because sometimes we will want to draw gates that has bigger width than normal gate. The Columns will update the width accordingly when encounter a new width.\n", - "\"Drawing\"/" + "![A wider gate widening its column: default width beside the new width](../img/html_visualization/columns_class_2.png)" ] }, { @@ -152,7 +152,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding the registers p[0] to p[3] and c[4]](../img/html_visualization/draw_reg.png)" ] }, { @@ -222,7 +222,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding the gates: H, RX, RZ and a CNOT from e[0]](../img/html_visualization/draw_gates.png)" ] }, { @@ -281,7 +281,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding Z measurements on p[0] to p[3] into c[4]](../img/html_visualization/draw_measure.png)" ] }, { @@ -334,7 +334,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding a barrier across the five qubit registers](../img/html_visualization/draw_barriers.png)" ] }, { @@ -388,7 +388,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter output after adding a reset on each of the five qubit registers](../img/html_visualization/draw_reset.png)" ] }, { @@ -404,7 +404,7 @@ "\n", "To be able to draw from openqasm string, the `Painter` has a function called `load_openqasm_str`. The function takes an `OpenQASM 2.0` string as an input, and will add all the drawing information that need to be draw on the web app. \n", "\n", - "Here an example, that we draw `ghz3` circuit from our `benchmarks.circuits`" + "Here an example, that we draw `ghz3` circuit from our `graphiq.benchmarks.circuits`" ] }, { @@ -437,7 +437,7 @@ } ], "source": [ - "from benchmarks.circuits import *\n", + "from graphiq.benchmarks.circuits import ghz3_state_circuit\n", "\n", "circuit, state = ghz3_state_circuit()\n", "openqasm = circuit.to_openqasm()\n", @@ -456,7 +456,7 @@ } }, "source": [ - "\"Drawing\"/" + "![Painter drawing of the ghz3 benchmark circuit](../img/html_visualization/ghz3.png)" ] } ], diff --git a/examples/startup_guide/simulate_circuit.ipynb b/examples/startup_guide/simulate_circuit.ipynb index a9f401de..da4c360b 100644 --- a/examples/startup_guide/simulate_circuit.ipynb +++ b/examples/startup_guide/simulate_circuit.ipynb @@ -9,7 +9,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -37,7 +37,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -67,30 +67,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "(
, )" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# plot the circuit as a directed, acyclic graph\n", "circuit.draw_dag()" @@ -105,7 +84,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -114,37 +93,24 @@ "compiler = DensityMatrixCompiler()\n", "state = compiler.compile(\n", " circuit\n", - ") # this returns a DensityMatrix object, and you can get underlying complex array as state.data" + ") # this returns a QuantumState object, and you can get underlying complex array as state.rep_data.data" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# trace out the ancilla qubit\n", - "state_a = partial_trace(state.data, keep=(0, 1, 2, 3), dims=5 * [2])" + "state_a = partial_trace(state.rep_data.data, keep=(0, 1, 2, 3), dims=5 * [2])" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "fig, ax = density_matrix_bars(state_a)\n", "fig.suptitle(\"Simulated circuit density matrix\")\n", @@ -153,17 +119,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fidelity with ideal state: 1.0000000125303306\n" - ] - } - ], + "outputs": [], "source": [ "# compare to the ideal density matrix\n", "ideal_state = ket2dm(\n", @@ -175,20 +133,12 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "30/06/2022, 10:07:25 | Saved to C:\\Users\\julie\\Documents\\_graduate\\git_repositories\\graph-compiler\\data\\2022-06-30 example-folder - 69eF\\partial_density_matrix.np successfully.\n" - ] - } - ], + "outputs": [], "source": [ "# save the resulting quantum state\n", - "from src.io import IO\n", + "from graphiq.io import IO\n", "\n", "io = IO.new_directory(folder=\"example-folder\", include_date=True, include_id=True)\n", "io.save_np_array(state_a, \"partial_density_matrix.np\")" @@ -203,106 +153,24 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from graphiq import metrics\n", - "from graphiq.solvers.evolutionary_solver import EvolutionarySolver" + "from graphiq.state import QuantumState\n", + "from graphiq.solvers.evolutionary_solver import EvolutionarySolver, EvolutionarySolverSetting" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iteration 0 | Best score: 0.9844\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "C:\\Users\\julie\\Documents\\_graduate\\git_repositories\\graph-compiler\\src\\solvers\\solver_base.py:33: UserWarning: Initial circuit for EvolutionarySolver is 'None'. \n", - " warnings.warn(f\"Initial circuit for {self.__class__.__name__} is 'None'. \")\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iteration 1 | Best score: 0.9844\n", - "Iteration 2 | Best score: 0.9844\n", - "Iteration 3 | Best score: 0.9375\n", - "Iteration 4 | Best score: 0.9375\n", - "Iteration 5 | Best score: 0.9375\n", - "Iteration 6 | Best score: 0.9375\n", - "Iteration 7 | Best score: 0.9375\n", - "Iteration 8 | Best score: 0.9375\n", - "Iteration 9 | Best score: 0.9375\n", - "Iteration 10 | Best score: 0.9375\n", - "Iteration 11 | Best score: 0.9375\n", - "Iteration 12 | Best score: 0.9375\n", - "Iteration 13 | Best score: 0.9375\n", - "Iteration 14 | Best score: 0.7500\n", - "Iteration 15 | Best score: 0.7500\n", - "Iteration 16 | Best score: 0.7500\n", - "Iteration 17 | Best score: 0.7500\n", - "Iteration 18 | Best score: 0.7500\n", - "Iteration 19 | Best score: 0.0000\n", - "Iteration 20 | Best score: 0.0000\n", - "Iteration 21 | Best score: 0.0000\n", - "Iteration 22 | Best score: 0.0000\n", - "Iteration 23 | Best score: 0.0000\n", - "Iteration 24 | Best score: 0.0000\n", - "Iteration 25 | Best score: 0.0000\n", - "Iteration 26 | Best score: 0.0000\n", - "Iteration 27 | Best score: 0.0000\n", - "Iteration 28 | Best score: 0.0000\n", - "Iteration 29 | Best score: 0.0000\n", - "Iteration 30 | Best score: 0.0000\n", - "Iteration 31 | Best score: 0.0000\n", - "Iteration 32 | Best score: 0.0000\n", - "Iteration 33 | Best score: 0.0000\n", - "Iteration 34 | Best score: 0.0000\n", - "Iteration 35 | Best score: 0.0000\n", - "Iteration 36 | Best score: 0.0000\n", - "Iteration 37 | Best score: 0.0000\n", - "Iteration 38 | Best score: 0.0000\n", - "Iteration 39 | Best score: 0.0000\n", - "Iteration 40 | Best score: 0.0000\n", - "Iteration 41 | Best score: 0.0000\n", - "Iteration 42 | Best score: 0.0000\n", - "Iteration 43 | Best score: 0.0000\n", - "Iteration 44 | Best score: 0.0000\n", - "Iteration 45 | Best score: 0.0000\n", - "Iteration 46 | Best score: 0.0000\n", - "Iteration 47 | Best score: 0.0000\n", - "Iteration 48 | Best score: 0.0000\n", - "Iteration 49 | Best score: 0.0000\n", - "[0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0039062499999999965, 0.00390625, 0.015625, 0.015625, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015625, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999965, 0.015625, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015625, 0.00390625, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015625, 0.015625, 0.0039062499999999983, 0.015625, 0.015625, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.015624999999999993, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.0039062499999999983, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015625, 0.00390625, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999965, 0.015625, 0.0039062499999999965, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.00390625, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.0, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.0, 0.0039062499999999983, 0.00390625, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.0, 0.015625, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.9999999999999996, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999965, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999965, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999965, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.0, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999965, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.2499999999999999, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.2499999999999999, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.00390625, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.2499999999999999, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999965, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.2499999999999999, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.0039062499999999983, 0.015625, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015625, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.00390625, 0.06249999999999997, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.0, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.9999999999999996, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.2499999999999999, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.0, 0.015625, 0.0, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.2499999999999999, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.01562499999999999, 0.015624999999999993, 0.0039062499999999983, 0.015625, 0.06249999999999997, 0.015625, 0.0, 0.0039062499999999983, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.2499999999999999, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.015624999999999993, 0.015624999999999993, 0.00390625, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.01562499999999999, 0.015624999999999993, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.01562499999999999, 0.015624999999999993, 0.00390625, 0.015625, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.06249999999999997, 0.00390625, 0.0039062499999999965, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.00390625, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0039062499999999983, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.00390625, 0.00390625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0, 0.2499999999999999, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.00390625, 0.015624999999999993, 0.06249999999999997, 0.015624999999999993, 0.015624999999999993, 0.0, 0.2499999999999999, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.06249999999999997, 0.0, 0.0, 0.00390625, 0.00390625, 0.0, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.06249999999999997, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999965, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0, 0.00390625, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0, 0.015624999999999993, 0.00390625, 0.0, 0.06249999999999997, 0.06249999999999997, 0.06249999999999997, 0.0, 0.0, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0, 0.06249999999999997, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.00390625, 0.015624999999999993, 0.2499999999999999, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.0, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0, 0.06249999999999997, 0.015624999999999993, 0.0, 0.015624999999999993, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.00390625, 0.0, 0.06249999999999997, 0.015624999999999993, 0.06249999999999997, 0.0, 0.015624999999999993, 0.0039062499999999983, 0.015624999999999993, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.06249999999999997, 0.0039062499999999983, 0.0, 0.0039062499999999983, 0.0039062499999999983, 0.015624999999999993, 0.015625, 0.015624999999999993, 0.0039062499999999983, 0.0039062499999999983, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.015624999999999993, 0.0, 0.00390625, 0.0039062499999999983, 0.06249999999999997, 0.06249999999999997, 0.2499999999999999]\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ - "target = ket2dm(\n", - " (tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()])) / np.sqrt(2)\n", + "target = QuantumState(\n", + " ket2dm((tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()])) / np.sqrt(2)),\n", + " rep_type=\"dm\",\n", ")\n", "\n", "# we pass one metric to use as the cost function. we can also pass more to be evaluated but not used as the cost\n", @@ -316,8 +184,8 @@ " circuit=None,\n", " n_emitter=1,\n", " n_photon=4,\n", + " solver_setting=EvolutionarySolverSetting(tournament_k=0),\n", ")\n", - "solver.tournament_k = 0\n", "\n", "# call .solve to implement the solver algorithm\n", "solver.solve()\n", @@ -325,6 +193,7 @@ "# we now have access to the metrics (don't need to pass anything back, as we are logging it in the Metric instance)\n", "print(metric.log)\n", "\n", + "plt.figure()\n", "plt.plot(metric.log)\n", "plt.show()" ] diff --git a/graphiq/__init__.py b/graphiq/__init__.py index b0d565ba..7610c39b 100644 --- a/graphiq/__init__.py +++ b/graphiq/__init__.py @@ -12,6 +12,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +GraphiQ: a design framework for photonic graph state generation circuits. + +The names re-exported here make up the minimal pipeline: describe a target `QuantumState`, +search for a `CircuitDAG` that generates it with `TimeReversedSolver`, simulate that circuit +with `StabilizerCompiler`, and score the result with `Infidelity`. Everything else is reached +through the subpackages. +""" DENSITY_MATRIX_ARRAY_LIBRARY = "numpy" from graphiq.backends.stabilizer.compiler import StabilizerCompiler diff --git a/graphiq/backends/compiler_base.py b/graphiq/backends/compiler_base.py index 48af6f03..c3f9c78e 100644 --- a/graphiq/backends/compiler_base.py +++ b/graphiq/backends/compiler_base.py @@ -19,6 +19,7 @@ The Base class defines an API which all compiler implementations should follow """ +import copy import logging from abc import ABC, abstractmethod @@ -66,7 +67,10 @@ def noise_simulation(self, value): Set the flag for running noise. :param value: True or False - :return: + :type value: bool + :raises ValueError: if value is not a bool + :return: nothing + :rtype: None """ if isinstance(value, bool): self._noise_simulation = value @@ -80,7 +84,18 @@ def compile(self, circuit: CircuitBase, initial_state=None): :param circuit: the circuit to compile :type circuit: CircuitBase - :raises ValueError: if there is a circuit Operation which is incompatible with this compiler + :param initial_state: the state to start from; if None (or otherwise falsy), every register + starts in |0> + :type initial_state: QuantumState or None + :raises AssertionError: if initial_state is not a QuantumState, or its qubit count does + not match the circuit's + :raises RuntimeError: if the circuit contains an Operation class this compiler does not + support + :raises ValueError: if a controlled gate's two legs are not both an AdditionNoiseBase, if + an operation's noise is neither an AdditionNoiseBase nor a ReplacementNoiseBase, or if a + MeasurementZ carries a (non-NoNoise) AdditionNoiseBase + :raises NotImplementedError: if a MeasurementZ carries a ReplacementNoiseBase, for which + the compiler has no noisy arm :return: the state produced by the circuit :rtype: QuantumState """ @@ -93,7 +108,10 @@ def compile(self, circuit: CircuitBase, initial_state=None): assert ( initial_state.n_qubits == circuit.n_quantum ), "the number of qubits in initial state must match the circuit" - state_data = initial_state.rep_data.data + # copy the data so evolving the compiled state does not change the + # caller's initial_state; deep, since a mixed stabilizer's data is a + # list of (probability, tableau) pairs + state_data = copy.deepcopy(initial_state.rep_data.data) else: state_data = circuit.n_quantum @@ -142,59 +160,43 @@ def compile(self, circuit: CircuitBase, initial_state=None): if isinstance(op.noise[0], nm.AdditionNoiseBase) and isinstance( op.noise[1], nm.AdditionNoiseBase ): - after_control = op.noise[0].noise_parameters["After gate"] - after_target = op.noise[1].noise_parameters["After gate"] - if after_control and after_target: - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, + # Each leg of a controlled pair carries its own "After + # gate" flag and the two may disagree, so split the + # noise list into a pre-gate and a post-gate half and + # apply each half on its own side. Every leg appears in + # exactly one half; the other half holds NoNoise for it. + # Reading the flag is safe only under the isinstance + # guard above, which is what guarantees both legs carry + # one. + noise_copy = op.noise + pre_noise = [ + ( + nm.NoNoise() + if leg.noise_parameters["After gate"] + else leg ) - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index + for leg in noise_copy + ] + post_noise = [ + ( + leg + if leg.noise_parameters["After gate"] + else nm.NoNoise() ) - elif (not after_control) and (not after_target): - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, - ) - elif after_control and (not after_target): - noise_copy = op.noise - tmp_noise = [nm.NoNoise, op.noise[1]] - op.noise = tmp_noise - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) - - self.compile_one_gate( - state, - op, - circuit.n_quantum, - q_index, - classical_registers, - ) - tmp_noise = [op.noise[0], nm.NoNoise] - - op.noise = tmp_noise + for leg in noise_copy + ] + # `op` belongs to the caller's circuit, so the rebinding + # below has to be undone on every exit path. A half list + # left behind would pass the isinstance guard on the next + # compile (NoNoise is an AdditionNoiseBase), or read as no + # noise at all, and the legs it left out would be + # silently dropped. + try: + op.noise = pre_noise self._apply_additional_noise( state, op, circuit.n_quantum, q_index ) op.noise = noise_copy - else: - noise_copy = op.noise - tmp_noise = [op.noise[0], nm.NoNoise] - op.noise = tmp_noise - self._apply_additional_noise( - state, op, circuit.n_quantum, q_index - ) self.compile_one_gate( state, op, @@ -202,16 +204,16 @@ def compile(self, circuit: CircuitBase, initial_state=None): q_index, classical_registers, ) - - tmp_noise = [nm.NoNoise, op.noise[1]] - op.noise = tmp_noise + op.noise = post_noise self._apply_additional_noise( state, op, circuit.n_quantum, q_index ) + finally: op.noise = noise_copy else: raise ValueError( - "We currently do not support different noise positions for one controlled gate." + "Both legs of a controlled gate must carry an AdditionNoiseBase; " + f"got {type(op.noise[0]).__name__} and {type(op.noise[1]).__name__}." ) else: @@ -334,5 +336,5 @@ def compile_one_noisy_gate( raise NotImplementedError("Please select a valid compiler") @abstractmethod - def _apply_additional_noise(self, op, n_quantum, q_index): + def _apply_additional_noise(self, state, op, n_quantum, q_index): raise NotImplementedError("Please select a valid compiler") diff --git a/graphiq/backends/density_matrix/__init__.py b/graphiq/backends/density_matrix/__init__.py index 8446c64b..67fceae2 100644 --- a/graphiq/backends/density_matrix/__init__.py +++ b/graphiq/backends/density_matrix/__init__.py @@ -12,13 +12,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Density-matrix state representation and the array library it is built on. +""" from graphiq import DENSITY_MATRIX_ARRAY_LIBRARY # todo, reload numpy/jax if needed? possible package conflicts if DENSITY_MATRIX_ARRAY_LIBRARY == "jax": - from jax.config import config + import jax - config.update( + jax.config.update( "jax_enable_x64", True ) # 32-bit precision can lead to issues when comparing matrices diff --git a/graphiq/backends/density_matrix/compiler.py b/graphiq/backends/density_matrix/compiler.py index 70dd6d53..91dcb582 100644 --- a/graphiq/backends/density_matrix/compiler.py +++ b/graphiq/backends/density_matrix/compiler.py @@ -110,7 +110,12 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) state.apply_unitary(unitary) - elif isinstance(op, ops.ClassicalControlledPairOperationBase): + elif isinstance( + op, ops.ClassicalControlledPairOperationBase + ) and not isinstance(op, ops.MeasurementCNOTandReset): + # MeasurementCNOTandReset is itself a ClassicalControlledPairOperationBase, + # so it is excluded here to reach its own branch below, which resets the + # control qubit projectors = dm.projectors_zbasis( n_quantum, q_index(op.control, op.control_type) ) @@ -175,8 +180,9 @@ def compile_one_noisy_gate( """ Compile one noisy gate - :param state: the density matrix representation of the state to be evolved - :type state: DensityMatrix + :param state: the QuantumState representation of the state to be evolved, where a density matrix representation + can be accessed + :type state: QuantumState :param op: the operation to be applied :type op: OperationBase :param n_quantum: the number of qubits @@ -226,7 +232,7 @@ def compile_one_noisy_gate( self.ops[op.__class__], ) - outcome = state.dm.apply_measurement_controlled_gate( + outcome = state.rep_data.apply_measurement_controlled_gate( projectors, unitary, measurement_determinism=self.measurement_determinism, @@ -249,7 +255,7 @@ def compile_one_noisy_gate( n_quantum, q_index(op.target, op.target_type), dm.sigmax() ) - outcome = state.dm.apply_measurement_controlled_gate( + outcome = state.rep_data.apply_measurement_controlled_gate( projectors, unitary, measurement_determinism=self.measurement_determinism, @@ -261,18 +267,16 @@ def compile_one_noisy_gate( ) classical_registers[op.c_register] = outcome - state.dm.apply_channel(reset_kraus_ops) + state.rep_data.apply_channel(reset_kraus_ops) elif type(op) is ops.MeasurementZ: - # TODO: implement measurement-related error model - - projectors = dm.projectors_zbasis( - n_quantum, q_index(op.register, op.reg_type) + # this arm is only reached when op.noise is a replacement noise + # model, and no measurement-related error model is implemented, + # so refuse rather than measure while silently ignoring the noise + raise NotImplementedError( + f"{type(op).__name__} carrying {type(op.noise).__name__} is not " + f"implemented for {self.__class__.__name__}." ) - outcome = state.dm.apply_measurement( - projectors, measurement_determinism=self.measurement_determinism - ) - classical_registers[op.c_register] = outcome else: raise ValueError( @@ -305,6 +309,14 @@ def _apply_additional_noise(self, state, op, n_quantum, q_index): state, n_quantum, [q_index(op.control, op.control_type)] ) target_noise.apply(state, n_quantum, [q_index(op.target, op.target_type)]) + elif isinstance(op, ops.ClassicalControlledPairOperationBase): + # one leg per register, in q_registers order (control first, then + # target), each on its own qubit -- the same reading as a + # ControlledPairOperationBase's legs + for noise, register, reg_type in zip( + op.noise, op.q_registers, op.q_registers_type + ): + noise.apply(state, n_quantum, [q_index(register, reg_type)]) else: raise ValueError( f"Noise model not implemented for operation type {type(op)}" diff --git a/graphiq/backends/density_matrix/functions.py b/graphiq/backends/density_matrix/functions.py index 6024160c..ad5e5c4e 100644 --- a/graphiq/backends/density_matrix/functions.py +++ b/graphiq/backends/density_matrix/functions.py @@ -31,7 +31,8 @@ def dagger(matrix): Returns the hermitian conjugate of an input matrix. :param matrix: input matrix :type matrix: np.ndarray - :return: + :return: the conjugate transpose of matrix + :rtype: np.ndarray """ return matrix.conjugate().T @@ -257,7 +258,7 @@ def _get_multi_qubit_gate(n_qubits, target_gates_dict): :type n_qubits: int :param target_gates_dict: a dictionary where the key is the qubit position and the value is a single-qubit gate that is non-identity components of the final gate - :type target_gates_dct: dict + :type target_gates_dict: dict :return: the resulting matrix that acts on the whole state :rtype: numpy.ndarray """ @@ -521,9 +522,15 @@ def partial_trace(rho, keep, dims, optimize=False): ndim = dims.size nkeep = np.prod(dims[keep]) - # string for initial array dimensions of form "abc...ABC...", where upper/lowercase = local Hilbert space + # string for initial array dimensions: lowercase letters index the rows, and the + # columns reuse the row letter for a traced qubit and take its uppercase letter + # for a kept one. A shared letter makes einsum sum the diagonal of that qubit, + # which is the trace; distinct letters would sum rows and columns independently. ssleft = "".join([string.ascii_lowercase[i] for i in range(ndim)]) + "".join( - [string.ascii_uppercase[i] for i in range(ndim)] + [ + string.ascii_uppercase[i] if i in keep else string.ascii_lowercase[i] + for i in range(ndim) + ] ) # string for final array dimensions is the same as initial, with upper/lowercase of dimensions to trace over omitted @@ -531,7 +538,7 @@ def partial_trace(rho, keep, dims, optimize=False): [string.ascii_lowercase[i] for i in range(ndim) if i in keep] ) + "".join([string.ascii_uppercase[i] for i in range(ndim) if i in keep]) - # e.g., "abcABC -> abAB" is partial trace over third qubit in three-qubit state + # e.g., "abcABc -> abAB" is partial trace over third qubit in three-qubit state superscript = ssleft + "->" + ssright rho_a = rho.reshape(np.tile(dims, 2)) @@ -605,7 +612,7 @@ def sqrtm_psd(input_matrix): assert is_psd(input_matrix) eig_vals, eig_vecs = eigh(input_matrix) eig_vals = np.maximum(eig_vals, 0) - return (eig_vecs * np.sqrt(eig_vals)) @ eig_vecs.T + return (eig_vecs * np.sqrt(eig_vals)) @ np.conjugate(eig_vecs).T def hermitianize(input_matrix): @@ -652,14 +659,15 @@ def fidelity(rho, sigma): rho_final = sqrtm_psd(rho_sigma) f = np.real(np.trace(rho_final)) ** 2 - if not np.isclose(f, 1.0): + # f is a square, so only the upper bound can fail + if f > 1 and not np.isclose(f, 1.0): raise Warning(f"Fidelity should be between 0 and 1. Value if {f}.") f = np.maximum(np.minimum(f, 1.0), 0.0) return f def trace_distance(rho, sigma): - """ + r""" Return the trace distance between two hermitian matrices, $\rho$ and $\sigma$ The trace distance is computed as: $$ @@ -697,7 +705,7 @@ def bipartite_partial_transpose(rho, dim1, dim2, subsys): :type subsys: int :raises AssertionError: if the dimension of the subsystems and the matrix dimension do not match - :raises ValueError: if the input matrix is not bipartite + :raises ValueError: if subsys is neither 0 nor 1 :return: the partial transpose matrix of rho :rtype: numpy.ndarray """ @@ -720,7 +728,7 @@ def bipartite_partial_transpose(rho, dim1, dim2, subsys): ) rho_pt = ( - rho.reshape(np.array([dim1, dim1, dim2, dim2])) + rho.reshape(np.array([dim1, dim2, dim1, dim2])) .transpose(pt_index) .reshape(rho.shape) ) @@ -754,7 +762,7 @@ def project_and_remove(rho, locations): Return the density matrix after applying Z measurements on qubits specified by locations mask. It removes all these qubits under measurements. - :param rho: the density matrix to evaluate the negativity + :param rho: the density matrix to measure :type rho: numpy.ndarray :param locations: a list of zeros/ones :type locations: list or numpy.ndarray @@ -904,6 +912,8 @@ def check_equivalent_unitaries(unitary_op1, unitary_op2): """ Check if two input matrices are equivalent unitaries up to a global phase + The global phase is estimated from the largest-magnitude entry of unitary_op2. + :param unitary_op1: the first input matrix :type unitary_op1: numpy.ndarray :param unitary_op2: the second input matrix @@ -914,9 +924,11 @@ def check_equivalent_unitaries(unitary_op1, unitary_op2): if not (is_unitary(unitary_op1) and is_unitary(unitary_op2)): return False - nonzero = np.nonzero(unitary_op2) - row = nonzero[0][0] - column = nonzero[1][0] + # Read the phase from the largest entry of unitary_op2: an exact zero may + # carry round-off, and a ratio against a round-off value is not a phase. + # Each column of a unitary has unit norm, so its largest entry has magnitude + # at least 1/sqrt(n), well above round-off. + row, column = np.unravel_index(np.argmax(np.abs(unitary_op2)), unitary_op2.shape) # differ by a global phase global_phase = unitary_op1[row, column] / unitary_op2[row, column] @@ -932,6 +944,9 @@ def amplitude_damping_operators(damping_prob): """ Return two channel operators corresponding to the single-qubit amplitude damping channel. + :param damping_prob: the damping probability, in [0, 1] + :type damping_prob: float + :raises AssertionError: if damping_prob is outside [0, 1] :return: channel operator :rtype: (numpy.ndarray, numpy.ndarray) """ diff --git a/graphiq/backends/density_matrix/state.py b/graphiq/backends/density_matrix/state.py index d3e79d05..f8d51512 100644 --- a/graphiq/backends/density_matrix/state.py +++ b/graphiq/backends/density_matrix/state.py @@ -30,6 +30,24 @@ density_matrix_bars, ) +# the backend is double precision; the tolerance below assumes it +_PROB_EPS = numpy.finfo(numpy.float64).eps + + +def _forced_measurement_tolerance(shape0, raw_probs): + """ + The resolution floor below which a measured probability is rounding error left by the + arithmetic that built the state, rather than a reachable outcome + + :param shape0: the density matrix's first dimension + :type shape0: int + :param raw_probs: the unclamped probability of each outcome, Tr(rho m) + :type raw_probs: numpy.ndarray + :return: the tolerance + :rtype: float + """ + return shape0 * _PROB_EPS * np.sum(np.abs(raw_probs)) + class DensityMatrix(StateRepresentationBase): """ @@ -82,8 +100,27 @@ def from_graph(cls, graph): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + :param data: the candidate data + :type data: any + :return: True if data is an int or a numpy.ndarray + :rtype: bool + """ return isinstance(data, (int, np.ndarray)) + @property + def n_qubits(self): + """ + Number of qubits represented by this density matrix, derived from + the matrix dimension, assuming qubits (dimension 2 per subsystem) + + :return: the number of qubits + :rtype: int + """ + return int(np.log2(self.data.shape[0])) + @property def trace(self): """ @@ -152,37 +189,42 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") """ Apply a measurement, either deterministically (with a certain outcome) or probabilistically + The post-measurement state keeps the pre-measurement trace, so a subnormalised state (photon loss) + stays subnormalised. A trace within 1e-12 of 1 counts as 1, so a loss below 1e-12 is not kept. + :param projectors: a list of projective measurements in the computational basis :type projectors: list[numpy.ndarray] :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + if 1, measurement results default to 1 unless p(1) is at or below the + numerical noise floor + if 0, measurement results default to 0 unless p(0) is at or below the + numerical noise floor + In modes 0 and 1, a forced outcome the state does not allow returns + the other outcome :type measurement_determinism: str/int + :raises ValueError: if the density matrix of the state has a different size from the POVM elements, + or if measurement_determinism is not "probabilistic", 0, or 1 :return: the measurement outcome :rtype: int """ if self._data.shape == projectors[0].shape: - probs = [] - for m in projectors: - prob = np.real(np.trace(self._data @ m)) - if prob < 0: - prob = 0 - probs.append(prob) - probs = np.array(probs) + trace = np.real(np.trace(self._data)) + raw_probs = np.array( + [np.real(np.trace(self._data @ m)) for m in projectors] + ) + # the error on a diagonal entry is O(eps * trace), accumulated across the + # matrix width, so the floor scales with the total, not with probs[i]; at + # or below it a probability counts as 0, and so does any negative value, + # as before + tol = _forced_measurement_tolerance(self._data.shape[0], raw_probs) + probs = np.array([prob if prob > tol else 0.0 for prob in raw_probs]) if measurement_determinism == "probabilistic": outcome = numpy.random.choice([0, 1], p=probs / np.sum(probs)) elif measurement_determinism == 1: - if probs[1] > 0: - outcome = 1 - else: - outcome = 0 - + outcome = 1 if probs[1] > tol else 0 elif measurement_determinism == 0: - if probs[1] < 1: - outcome = 0 - else: - outcome = 1 + outcome = 0 if probs[0] > tol else 1 else: raise ValueError( f'measurement_determinism parameter must be "probabilistic", 0, or 1' @@ -192,6 +234,11 @@ def apply_measurement(self, projectors, measurement_determinism="probabilistic") # this assumes that the projector, m, has the properties: m = sqrt(m) and m = m.dag() self._data = (m @ self._data @ np.transpose(np.conjugate(m))) / norm + # divide by probs[outcome] / trace instead, the outcome's probability + # relative to the state's trace, so photon loss is not renormalised + # away; a trace of 1 up to rounding drift keeps the old result exactly + if abs(trace - 1.0) > 1e-12: + self._data = self._data * trace else: raise ValueError( @@ -211,9 +258,15 @@ def apply_measurement_controlled_gate( :param target_gate: the gate to be applied if the measurement outcome is 1 :type target_gate: numpy.ndarray :param measurement_determinism: if "probabilistic", measurement results are probabilistically selected - if 1, measurement results default to 1 unless the probability of measuring p(1) = 0 - if 0, measurement results default to 0 unless the probability of measuring p(0) = 0 + if 1, measurement results default to 1 unless p(1) is at or below the + numerical noise floor + if 0, measurement results default to 0 unless p(0) is at or below the + numerical noise floor + In modes 0 and 1, a forced outcome the state does not allow returns + the other outcome :type measurement_determinism: str/int + :raises ValueError: if the density matrix of the state has a different size from the POVM elements, + or if measurement_determinism is not "probabilistic", 0, or 1 :raises AssertionError: if target_gate has different dimensions from the density matrix of the state :return: the measurement outcome :rtype: int @@ -235,8 +288,8 @@ def partial_trace(self, keep, dims): if the space is :math:`A \\times B \\times C \\times D`, dims = [dim_A, dim_B, dim_C, dim_D] :type dims: list OR numpy.ndarray - :return: - :rtype: + :return: nothing + :rtype: None """ self.data = dmf.partial_trace(self.data, keep, dims) @@ -270,7 +323,13 @@ def __eq__(self, other): :param other: another DensityMatrix object :type other: DensityMatrix - :return: True if they are equal; False otherwise - :rtype: bool + :return: True if they are equal; False otherwise (including when their + sizes differ); NotImplemented if other is not a DensityMatrix + :rtype: bool or NotImplementedType """ + if not isinstance(other, DensityMatrix): + return NotImplemented + # np.allclose would broadcast e.g. a (1, 1) matrix against a (2, 2) one + if self._data.shape != other.data.shape: + return False return np.allclose(self._data, other.data) diff --git a/graphiq/backends/graph/state.py b/graphiq/backends/graph/state.py index 8241dcd3..61cf5b07 100644 --- a/graphiq/backends/graph/state.py +++ b/graphiq/backends/graph/state.py @@ -33,8 +33,8 @@ def _compile_lc_gates(lc_gate): :param lc_gate: a list of local Clifford gates :type lc_gate: list[str] - :return: a one-qubit gate - :rtype: ops.OneQubitOperationBase + :return: a list of one-qubit gate classes representing the same local Clifford + :rtype: list """ op_list = [] for op in lc_gate: @@ -57,7 +57,7 @@ def __init__(self, data, clifford_dict=None, *args, **kwargs): :param data: data used to construct the representation :type data: networkX.Graph :param clifford_dict: a dictionary that stores local Clifford for each node - :type clifford_dict: dict + :type clifford_dict: dict or None :return: nothing :rtype: None """ @@ -96,6 +96,12 @@ def valid_datatype(cls, data): @property def n_qubits(self): + """ + The number of qubits in the state, which is the number of nodes of the graph + + :return: the number of qubits + :rtype: int + """ return self.n_nodes def find_lc(self, node_id): @@ -104,8 +110,9 @@ def find_lc(self, node_id): :param node_id: the node index :type node_id: int - :return: local Clifford gates - :rtype: ops.OneQubitOperationBase + :raises ValueError: if no node with node_id exists + :return: local Clifford gates, as a list of one-qubit gate classes + :rtype: list """ if self._data.has_node(node_id): return self._data.nodes[node_id]["LC"] @@ -114,14 +121,15 @@ def find_lc(self, node_id): def update_lc(self, node_id, lc_gate): """ - Find the local Clifford gates corresponding to a node + Replace the local Clifford gates recorded on a node :param node_id: the node index :type node_id: int - :param lc_gate: a list of local Clifford gates - :type lc_gate: list(str) - :return: local Clifford gates - :rtype: ops.OneQubitOperationBase + :param lc_gate: a list of local Clifford gates; None or an empty list records the identity + :type lc_gate: list(str) or None + :raises ValueError: if no node with node_id exists + :return: nothing + :rtype: None """ if not self._data.has_node(node_id): raise ValueError(f"Node with node ID {node_id} does not exist.") @@ -138,7 +146,8 @@ def add_node(self, node_to_add, lc_gate=None): :type node_to_add: int :param lc_gate: a list of local Clifford gates :type lc_gate: list(str) - :raises ValueError: if node_to_add is of an invalid datatype + :raises ValueError: if node_to_add is None, which networkx rejects as a node + :raises TypeError: if node_to_add is not hashable :return: nothing :rtype: None """ @@ -152,7 +161,7 @@ def add_node(self, node_to_add, lc_gate=None): def add_edge(self, first_node, second_node): """ - Add an edge between two nodes. If any of these two nodes does not exist, no edge is added. + Add an edge between two nodes. Any of these two nodes that does not exist is added first. :param first_node: the first node on which to add an edge :type first_node: int @@ -187,19 +196,23 @@ def get_nodes(self): def lc_equivalent(self, other_graph, mode="deterministic"): r""" - Determines whether two graph states are local-Clifford equivalent or not, given the adjacency matrices of the two. - It takes two adjacency matrices as input and returns a numpy.ndarray containing $n (2 \times 2 array)s$ - = clifford operations on each qubit. + Determines whether this graph state and other_graph are local-Clifford equivalent or not. + It returns whether a local-Clifford solution was found, together with an array of $n (2 \times 2 array)s$ + = clifford operations on each qubit (None if none was found). :param other_graph: the other graph against which to check LC equivalence :type other_graph: Graph :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + 'deterministic' is exact: a returned False is conclusive. 'random' is a bounded random search that can + return False even for graphs that are in fact LC equivalent. :type mode: str - :raises AssertionError: if the number of rows in the row reduced matrix is less than the rank of coefficient - matrix or if the number of linearly dependent columns is not equal to $4n - rank$ - (for $n$ being the number of nodes in the graph) + :raises AssertionError: if the two adjacency matrices differ in shape, if the number of rows in the row + reduced matrix is less than the rank of coefficient matrix, or if the number of linearly dependent + columns is not equal to $4n - rank$ (for $n$ being the number of nodes in the graph) + :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` :return: If a solution is found, returns True and an array of single-qubit Clifford $2 \times 2$ matrices - in the symplectic formalism. If not, graphs are not LC equivalent and returns False, None. + in the symplectic formalism. If not, returns False, None: in 'deterministic' mode the graphs are not LC + equivalent; in 'random' mode the search may only have failed to find a solution. :rtype: bool, numpy.ndarray or None """ g1 = nx.to_numpy_array(self.data).astype(int) @@ -238,12 +251,12 @@ def draw(self, show=True, ax=None, with_labels=True): :type show: bool :param ax: axis on which to draw the plot (optional) :type ax: matplotlib.Axis - :param with_labels: - :type with_labels: - :return: nothing - :rtype: None + :param with_labels: if True, node labels are drawn + :type with_labels: bool + :return: fig, axes on which the state is drawn + :rtype: matplotlib.Figure, matplotlib.Axes """ - draw_graph(self, show=show, ax=ax, with_labels=with_labels) + return draw_graph(self, show=show, ax=ax, with_labels=with_labels) def local_complementation(self, node_id, copy=False): """ @@ -258,6 +271,11 @@ def local_complementation(self, node_id, copy=False): :param node_id: the ID of the node around which local complementation should take place :type node_id: int + :param copy: if True, the local complementation is applied to a copy and this Graph is left unchanged; + if False, this Graph is modified in place + :type copy: bool + :raises NotImplementedError: if this Graph is not a graph state (i.e. carries non-identity local + Clifford gates) :return: the graph after the local complementation :rtype: Graph """ @@ -313,6 +331,20 @@ class MixedGraph(StateRepresentationBase): """ def __init__(self, data, *args, **kwargs): + """ + Create a MixedGraph representation object + + A single Graph becomes a one-element mixture of probability 1.0. A bare + ``networkx.Graph`` is not accepted here; wrap it in a ``Graph`` first. + + :param data: a Graph to hold as a one-element mixture, or a ready mixture as a list of + (probability_i, Graph_i) + :type data: Graph or list[tuple] + :raises TypeError: if data is neither a Graph nor a list + :raises AssertionError: if a list entry is not a (float, Graph) pair + :return: nothing + :rtype: None + """ if isinstance(data, Graph): self._mixture = [ (1.0, data), @@ -329,6 +361,16 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + A list is accepted only if every entry is a (float, Graph) pair. + + :param data: the candidate data + :type data: any + :return: True if data is a Graph or such a list + :rtype: bool + """ valid = isinstance(data, (Graph, list)) if isinstance(data, list): valid = valid and all( @@ -361,10 +403,13 @@ def mixture(self, value): """ Sets the mixture of pure states, represented as a list of Graphs and associated probabilities. - :param value: a new mixture list, pure Graph - :type value: list[Graph] - :return: the mixture as a list of (probability_i, Graph_i) - :rtype: list + :param value: a new mixture list of (probability_i, Graph_i), or a pure Graph + :type value: list or Graph + :raises AssertionError: if a list entry is not a (float, Graph) pair, or if the graphs do not all have + the same number of qubits + :raises TypeError: if value is neither a list nor a Graph + :return: nothing + :rtype: None """ if isinstance(value, list): assert all( diff --git a/graphiq/backends/lc_equivalence_check.py b/graphiq/backends/lc_equivalence_check.py index 02d2d95e..db9fb5d1 100644 --- a/graphiq/backends/lc_equivalence_check.py +++ b/graphiq/backends/lc_equivalence_check.py @@ -97,16 +97,17 @@ Remember that if the system of equations is overdetermined after removing linearly dependent constraints, then there exists no solution (other than :math:`Q=0` which is not a valid Clifford operation) -and graph states are not equivalent for sure. It is proved that a solution for a valid -$Q$ exists, if and only if the set +and graph states are not equivalent for sure. For a **connected** graph, Bouchet (4.3) and (4.4) +[Combinatorica 11(4):315, 1991] show that when the solution space has dimension greater than 4, a valid +$Q$ exists iff one of the sums of **at most two** basis vectors $$ - \left\{b+b^{\prime} \mid b, b^{\prime} \in B\right\} + \{b, b + b' \mid b, b' \in B,\ b \neq b'\} $$ -has a member that satisfies the constraint. Note that this is only true for the cases where $B$ is at least of size 4. -However, this is rarely the case and has not practical significance. For smaller $B$, one should test all possible -solutions (at most 16) to see if a valid $Q$ can be found. +satisfies the constraint (the basis vectors themselves and their pairwise sums); at dimension at most 4, all +(at most 16) elements of $B$'s span are tested instead; and the check is run per connected component, because +local complementation preserves components and the linear system decouples over them. In order to find a basis for set B, one needs to use the $n^2 \times 4n$ coefficient matrix of the system of linear equations, find the linearly dependent columns (this is done in the code by a row-reduction process), and choose any @@ -168,7 +169,7 @@ """ -from itertools import combinations, permutations +from itertools import chain, combinations, permutations import networkx as nx import numpy as np @@ -179,8 +180,15 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): r""" Determines whether two graph states are local-Clifford equivalent or not, given the adjacency matrices of the two. - It takes two adjacency matrices as input and returns a numpy.ndarray containing $n$ ($2 \times 2$ arrays - = clifford operations on each qubit. + It takes two adjacency matrices as input and returns whether a local-Clifford solution was found, together with + an array of $n$ ($2 \times 2$) arrays = clifford operations on each qubit (None if none was found). + + The check is run once per connected component of the two graphs: Bouchet (4.3) bounds the codimension of + the solution space's affine subspace of valid vectors by 2 only for a connected graph, so on a graph with + :math:`c` components the whole-graph candidate set of sums of at most two basis vectors is incomplete (up + to :math:`2c` basis vectors may be needed), and the linear system decouples over components. For adjacency + matrices of shape ``(0, 0)`` (no nodes), returns ``(True, numpy.ndarray of shape (0, 2, 2))`` without + further computation. :param adj_matrix1: the adjacency matrix of the first graph. This is equal to the binary matrix for representing Pauli Z part of the symplectic binary representation of the stabilizer generators @@ -188,14 +196,83 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): :param adj_matrix2: the adjacency matrix of the second graph :type adj_matrix2: numpy.ndarray :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + 'deterministic' is exact: a returned False is conclusive. 'random' is a bounded random search that can + return False even for graphs that are in fact LC equivalent. + :type mode: str + :param seed: an optional input to set the random seed for the random search approach + :type seed: int + :raises AssertionError: if the two adjacency matrices differ in shape, if the number of rows in the row + reduced matrix is less than the rank of coefficient matrix, or if the number of linearly dependent + columns is not equal to :math:`4n - rank` (for :math:`n` being the number of nodes in the graph) + :raises ValueError: if ``mode`` is neither ``"deterministic"`` nor ``"random"`` + :return: ``(True, solution)`` if a solution is found, where ``solution`` is an array of single-qubit + Clifford :math:`2 \\times 2` matrices in the symplectic formalism, or ``(False, None)`` if none is found. + In ``'deterministic'`` mode ``False`` means the graphs are not LC equivalent; in ``'random'`` mode it + can also mean only that the sampling did not find a solution + :rtype: bool, numpy.ndarray or None + """ + if mode not in ("deterministic", "random"): + raise ValueError( + 'The mode should be either "random" or "deterministic" (default)' + ) + assert np.shape(adj_matrix1) == np.shape(adj_matrix2), "graphs must be of same size" + n_nodes = np.shape(adj_matrix1)[0] + if n_nodes == 0: + return True, np.zeros((0, 2, 2), dtype=int) + + # local complementation preserves connected components (Bouchet, Combinatorica 11(4):315, 1991, + # Section 6), so the two graphs can only be LC equivalent if they partition into the same + # components (as vertex sets). + comps1 = [ + sorted(c) for c in nx.connected_components(nx.from_numpy_array(adj_matrix1)) + ] + comps2 = [ + sorted(c) for c in nx.connected_components(nx.from_numpy_array(adj_matrix2)) + ] + if {frozenset(c) for c in comps1} != {frozenset(c) for c in comps2}: + return False, None + + solution = np.zeros((n_nodes, 2, 2), dtype=int) + for idx in comps1: + equivalent, sub_solution = _is_lc_equivalent_connected( + adj_matrix1[np.ix_(idx, idx)], adj_matrix2[np.ix_(idx, idx)], mode, seed + ) + if not equivalent: + return False, None + solution[idx] = sub_solution + return True, solution + + +def _is_lc_equivalent_connected(adj_matrix1, adj_matrix2, mode, seed): + r""" + Determines whether two adjacency matrices that are each a single connected graph are local-Clifford + equivalent, and if so, finds the Clifford operation needed to convert one to the other. + + Callers must pass connected graphs: :py:func:`is_lc_equivalent` enforces this by calling this function once + per connected component. When the solution space has dimension greater than 4, Bouchet (4.3) and (4.4) + [Combinatorica 11(4):315, 1991] show that a valid Clifford operation, when one exists, is a sum of at most + two vectors of any basis of that space, so only the basis vectors and their pairwise sums are tried; at + dimension at most 4, all (at most 16) elements of the solution space are tried instead. Section 6 (p. 324) + is why running this per connected component, rather than once on the whole graph, is not a restriction: local + complementation preserves connected components. In ``"random"`` mode, ``_random_checker`` reseeds the global + NumPy random stream with ``seed``; harmless on its own, but note that :py:func:`is_lc_equivalent` calls this + function once per component, so the stream is reseeded to the same value at the start of every component. + + :param adj_matrix1: the adjacency matrix of the first graph's component. This is equal to the binary matrix + for representing Pauli Z part of the symplectic binary representation of the stabilizer generators + :type adj_matrix1: numpy.ndarray + :param adj_matrix2: the adjacency matrix of the second graph's component + :type adj_matrix2: numpy.ndarray + :param mode: the chosen mode for finding solutions. It can be either 'deterministic' (default) or 'random'. + Assumed already validated by :py:func:`is_lc_equivalent`; this function does not check it again. :type mode: str :param seed: an optional input to set the random seed for the random search approach :type seed: int :raises AssertionError: if the number of rows in the row reduced matrix is less than the rank of coefficient matrix or if the number of linearly dependent columns is not equal to :math:`4n - rank` (for :math:`n` being the number of nodes in the graph) - :return: If a solution is found, returns an array of single-qubit Clifford :math:`2 \\times 2` matrices - in the symplectic formalism. If not, graphs are not LC equivalent and returns None. + :return: If a solution is found, returns True and an array of single-qubit Clifford :math:`2 \\times 2` + matrices in the symplectic formalism. If not, components are not LC equivalent and returns False, None. :rtype: bool, numpy.ndarray or None """ @@ -257,24 +334,27 @@ def is_lc_equivalent(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): else: return False, None - elif mode == "deterministic": - basis = _solution_basis_finder(reduced_coeff_matrix, col_list) - possible_combinations = combinations(basis, 2) - solution_set = [] - for basis_combination in possible_combinations: - possible_solution = basis_combination[0] + basis_combination[1] - solution_set.append(possible_solution % 2) - for solution in solution_set: + else: + # mode == "deterministic" (mode is validated by the wrapper, so this is the only + # remaining branch). Bouchet (4.4): a valid Q, when one exists, is a sum of at most two + # vectors of basis B, so the candidates are the k(k-1)/2 pairwise sums of the basis + # vectors followed by the k basis vectors themselves. The pairwise sums go first so that + # a graph a pairwise sum solves gets the first such sum as its solution; the single + # vectors only decide the cases no pairwise sum solves. The empty sum (the zero vector) + # is also "a sum of <= 2 vectors of B", but it fails the per-vertex constraint (4.2) at + # every vertex (a_i d_i + b_i c_i = 0 mod 2, never 1), so it is skipped rather than tried. + basis = solution_basis + candidates = chain( + (((b1 + b2) % 2) for b1, b2 in combinations(basis, 2)), + (b % 2 for b in basis), + ) + for solution in candidates: if _is_valid_clifford(solution): # convert the solution to an array of n * (2 X 2) matrices return True, solution.reshape(n_nodes, 2, 2) # states are NOT LC equivalent return False, None - else: - raise ValueError( - 'The mode should be either "random" or "deterministic" (default)' - ) def _solution_basis_finder(reduced_coeff_matrix, col_list): @@ -286,7 +366,8 @@ def _solution_basis_finder(reduced_coeff_matrix, col_list): :param col_list: list of the columns' indices in the coefficient matrix that are linearly dependent. :type col_list: list[int] or numpy.ndarray :raises AssertionError: if the solution basis found is wrong - :return: an array containing the elements of the total Clifford operation needed to convert one graph to another. + :return: the basis vectors of the solution space, as an array of shape (len(col_list), number of columns of + reduced_coeff_matrix, 1) :rtype: numpy.ndarray """ @@ -467,7 +548,7 @@ def _col_finder(row_reduced_coeff_matrix): :type row_reduced_coeff_matrix: numpy.ndarray :raises ValueError: if the input row_reduced_coeff_matrix is not a binary matrix :return: a list of the indices of the columns which are not linearly independent - :rtype: numpy.ndarray + :rtype: list[int] """ dependent_columns = [] pivot = [0, 0] @@ -532,8 +613,8 @@ def lc_graph_operations(adj_matrix, solution): :type adj_matrix: numpy.ndarray :param solution: an array of single-qubit Clifford :math:`2 \\times 2` matrices in the symplectic formalism :type solution: numpy.ndarray - :return: a list of the names of the operations that need to be applied on each qubit in the correct order. - :rtype: list[str] + :return: the ordered list of node indices on which to apply local complementations + :rtype: list[int] """ # takes an adjacency matrix and the solution (Clifford operation) and returns the list of local complementations # needed for graph transformation. @@ -558,9 +639,35 @@ def lc_graph_operations(adj_matrix, solution): def find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0): + r""" + Finds the sequence of local complementations that transforms the first graph into the second one, + if the two graph states are local-Clifford equivalent. + + The direction is fixed by the argument order: the returned operations are applied to the graph of + ``adj_matrix1`` (in the given order) to obtain the graph of ``adj_matrix2``. To obtain the reverse + transformation (``adj_matrix2`` into ``adj_matrix1``), call this function with the arguments swapped. + + Internally, :py:func:`is_lc_equivalent` solves for the Clifford operator :math:`Q` that maps the first + graph state to the second, and :py:func:`lc_graph_operations` is called with ``adj_matrix1`` (the graph + that :math:`Q` acts on, since :math:`R = C\theta + D` must use that same :math:`\theta`). + + :param adj_matrix1: the adjacency matrix of the first (initial) graph, i.e. the graph the returned + operations are applied to + :type adj_matrix1: numpy.ndarray + :param adj_matrix2: the adjacency matrix of the second (target) graph + :type adj_matrix2: numpy.ndarray + :param mode: the chosen mode for finding solutions, either 'deterministic' (default) or 'random'. + :type mode: str + :param seed: an optional input to set the random seed for the random search approach + :type seed: int + :raises ValueError: if the two graphs are not local-Clifford equivalent + :return: the ordered list of node indices on which to apply local complementations to transform the + first graph into the second + :rtype: list[int] + """ equivalency, solution = is_lc_equivalent(adj_matrix1, adj_matrix2, mode, seed) if equivalency: - op_list = lc_graph_operations(adj_matrix2, solution) + op_list = lc_graph_operations(adj_matrix1, solution) return op_list else: raise ValueError("These two graphs are not local-Clifford equivalent.") @@ -595,12 +702,16 @@ def iso_graph_finder(input_graph): :param input_graph: input graph :type input_graph: networkx.Graph - :return: the list of graphs that are isomorphic to G + :return: the list of graphs that are isomorphic to G, one per permutation of its sorted labels: entry k is + G relabelled by the k-th permutation of its sorted labels, with each label then replaced by its rank in + sorted order :rtype: list[networkx.Graph] """ iso_graphs = [] list_nodes = sorted(input_graph) n_nodes = len(list_nodes) + # adjacency-matrix row/column of each label, so labels need not be 0..n-1 + position = {node: i for i, node in enumerate(list_nodes)} all_permu = list(permutations(list_nodes, len(list_nodes))) for each_permu in all_permu: @@ -609,7 +720,7 @@ def iso_graph_finder(input_graph): g_copy = nx.relabel_nodes(input_graph, map_dict, copy=True) for node_id1 in list_nodes: for node_id2 in list(g_copy.neighbors(node_id1)): - adj_matrix[node_id1, node_id2] = 1 + adj_matrix[position[node_id1], position[node_id2]] = 1 g_copy = nx.to_networkx_graph(adj_matrix) iso_graphs.append(g_copy) @@ -619,21 +730,26 @@ def iso_graph_finder(input_graph): def local_comp_graph(input_graph, node_id): """ - Applies a local complementation on the node (specified by node_id) of the input graph input_graph - and returns the resulting graph. + Applies a local complementation on one node of the input graph input_graph and returns the resulting graph. - :param input_graph: input graph + The node is chosen by position, not by label: node_id indexes the rows of ``nx.to_numpy_array(input_graph)``, + i.e. the node iteration order of input_graph, and the returned graph is labelled 0..n-1 by that same position. + Positions and labels coincide when input_graph's nodes are 0..n-1 in ascending iteration order, as for a graph + built with ``nx.from_numpy_array`` or returned by this function. + + :param input_graph: input graph; edge weights are ignored, so every edge counts once :type input_graph: networkx.Graph - :param node_id: the index of the node to apply tho local complementation on + :param node_id: the position, in input_graph's node iteration order, of the node to apply the local + complementation on :type node_id: int - :raises AssertionError: if the node index node_id is not in the graph - :return: a new transformed graph + :raises AssertionError: if node_id is not a position in the graph (not in 0..n-1) + :return: a new transformed graph, labelled 0..n-1 by position in input_graph's node iteration order :rtype: networkx.Graph """ # TODO: integrate this with the state representation functions n_nodes = input_graph.number_of_nodes() assert n_nodes > node_id >= 0, "node index is not in the graph" - adj_matrix = nx.to_numpy_array(input_graph).astype(int) + adj_matrix = nx.to_numpy_array(input_graph, weight=None).astype(int) identity = np.eye(n_nodes, n_nodes) gamma_matrix = np.zeros((n_nodes, n_nodes)) diff --git a/graphiq/backends/stabilizer/__init__.py b/graphiq/backends/stabilizer/__init__.py index 075a2598..6605def9 100644 --- a/graphiq/backends/stabilizer/__init__.py +++ b/graphiq/backends/stabilizer/__init__.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Stabilizer-formalism state representations, tableaux and their Clifford operations. +""" from graphiq.backends.stabilizer.functions.rep_conversion import ( clifford_from_stabilizer, ) diff --git a/graphiq/backends/stabilizer/clifford_tableau.py b/graphiq/backends/stabilizer/clifford_tableau.py index 43167698..749c7b10 100644 --- a/graphiq/backends/stabilizer/clifford_tableau.py +++ b/graphiq/backends/stabilizer/clifford_tableau.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +The Clifford tableau: destabilizer and stabilizer generators with their phase exponents. +""" import copy import numpy as np @@ -22,14 +25,24 @@ class CliffordTableau(TableauBase): + """ + A full Clifford tableau: destabilizer generators stacked above stabilizer generators, + with a phase vector (exponents of :math:`-1`) and a separate i-phase exponent vector + that tracks residual factors of :math:`i`. + """ + def __init__(self, data, phase=None, *args, **kwargs): """ Constructor for CliffordTableau :param data: input data used to initialize the tableau :type data: numpy.ndarray or int or CliffordTableau - :param phase: a phase vector - :type phase: numpy.ndarray + :param phase: a phase vector, or None for an all-zero default; must + be omitted when data is an existing tableau, since the phase is + copied from it instead + :type phase: numpy.ndarray or list[int] or None + :raises ValueError: if phase is not None and its shape is wrong, or + if phase is passed alongside an existing tableau to copy """ if isinstance(data, int): self._table = np.eye(2 * data).astype(int) @@ -41,6 +54,11 @@ def __init__(self, data, phase=None, *args, **kwargs): self.n_qubits = int(data.shape[1] / 2) self._initialize_phase(phase) elif isinstance(data, CliffordTableau) or isinstance(data, StabilizerTableau): + if phase is not None: + raise ValueError( + "phase cannot be set when constructing from an existing " + "tableau; the phase is copied from data instead" + ) if isinstance(data, StabilizerTableau): data = stab.clifford_from_stabilizer(data) self._table = np.copy(data.table) @@ -56,18 +74,30 @@ def _initialize_phase(self, phase): """ Helper function to initialize the phase vectors - :param phase: - :type phase: numpy.ndarray + :param phase: the phase vector, or None for an all-zero default + :type phase: numpy.ndarray or list[int] or None :return: nothing :rtype: None + :raises ValueError: if phase is not None and its shape is not (2 * n_qubits,) """ - if isinstance(phase, np.ndarray) and phase.shape[0] == 2 * self.n_qubits: - self._phase = phase.astype(int) - else: + if phase is None: self._phase = np.zeros(2 * self.n_qubits).astype(int) + else: + phase = np.asarray(phase) + if phase.shape != (2 * self.n_qubits,): + raise ValueError( + f"phase must have shape ({2 * self.n_qubits},), got {phase.shape}" + ) + self._phase = phase.astype(int) self._iphase = np.zeros(2 * self.n_qubits).astype(int) def copy(self): + """ + A deep copy of the tableau, sharing no array with the original + + :return: an independent copy + :rtype: CliffordTableau + """ return copy.deepcopy(self) @property @@ -281,8 +311,12 @@ def iphase(self, value): :type value: numpy.ndarray :return: nothing :rtype: None + :raises ValueError: if value's shape is not (2 * n_qubits,) """ - assert value.shape[0] == 2 * self.n_qubits + if value.shape != (2 * self.n_qubits,): + raise ValueError( + f"iphase must have shape ({2 * self.n_qubits},), got {value.shape}" + ) self._iphase = value.astype(int) def __str__(self): @@ -295,8 +329,11 @@ def stabilizer_to_labels(self): """ Convert the stabilizers to generator strings - :return: - :rtype: + Each string carries only the letters I, X, Y and Z: the phase vector is not consulted, + so the sign of each generator is not included. + + :return: one string per stabilizer generator + :rtype: list[str] """ return sfu.symplectic_to_string(self.stabilizer_x, self.stabilizer_z) @@ -304,8 +341,10 @@ def destabilizer_to_labels(self): """ Convert the destabilizers to the generator strings - :return: - :rtype: + As with stabilizer_to_labels, the sign of each generator is not included. + + :return: one string per destabilizer generator + :rtype: list[str] """ return sfu.symplectic_to_string(self.destabilizer_x, self.destabilizer_z) @@ -335,8 +374,8 @@ def __eq__(self, other): # check without converting to canonical form if isinstance(other, CliffordTableau): return ( - np.all(self.phase == other.phase) - and np.all(self.iphase == other.iphase) + np.array_equal(self.phase, other.phase) + and np.array_equal(self.iphase, other.iphase) and np.array_equal(self.table.astype(int), other.table.astype(int)) ) @@ -352,8 +391,31 @@ def to_stabilizer(self): return StabilizerTableau(self.stabilizer, self.phase[self.n_qubits :]) def _reset(self, new_table, new_phase, new_iphase): + """ + Replace the table, phase and i-phase together, re-deriving the qubit count and shape + + The table holds destabilizers and stabilizers stacked, so the qubit count is half its + row count. + + :param new_table: the replacement table, whose row count is twice the new qubit count + :type new_table: numpy.ndarray + :param new_phase: the replacement phase vector, of length 2 * n_qubits + :type new_phase: numpy.ndarray + :param new_iphase: the replacement i-phase exponent vector, of length 2 * n_qubits + :type new_iphase: numpy.ndarray + :raises ValueError: if new_phase or new_iphase does not match the new qubit count + :return: nothing + :rtype: None + """ new_n_qubits = int(new_table.shape[0] / 2) - assert len(new_phase) == 2 * new_n_qubits + if new_phase.shape != (2 * new_n_qubits,): + raise ValueError( + f"new_phase must have shape ({2 * new_n_qubits},), got {new_phase.shape}" + ) + if new_iphase.shape != (2 * new_n_qubits,): + raise ValueError( + f"new_iphase must have shape ({2 * new_n_qubits},), got {new_iphase.shape}" + ) self._table = new_table.astype(int) self._phase = new_phase.astype(int) self._iphase = new_iphase.astype(int) diff --git a/graphiq/backends/stabilizer/compiler.py b/graphiq/backends/stabilizer/compiler.py index a177f3f5..07156b6c 100644 --- a/graphiq/backends/stabilizer/compiler.py +++ b/graphiq/backends/stabilizer/compiler.py @@ -123,10 +123,15 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): q_index(op.control, op.control_type), measurement_determinism=self.measurement_determinism, ) - print(outcome) state.apply_conditioned_gate( q_index(op.target, op.target_type), outcome, gate="x" ) + # outcome has one entry per mixture branch, and branches can + # disagree; classical_registers only has room for one value + # per bit, so we report branch 0's outcome. The gate itself + # is still applied correctly per-branch above. See the note + # in MixedStabilizer.apply_measurement. + classical_registers[op.c_register] = outcome[0] else: outcome = state.apply_measurement( @@ -135,8 +140,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) if outcome == 1: state.apply_sigmax(q_index(op.target, op.target_type)) - - classical_registers[op.c_register] = outcome + classical_registers[op.c_register] = outcome elif type(op) is ops.ClassicalCZ: # apply an Z gate on the target qubit conditioned on the measurement outcome = 1 @@ -148,6 +152,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): state.apply_conditioned_gate( q_index(op.target, op.target_type), outcome, gate="z" ) + # see the matching comment in the ClassicalCNOT branch above classical_registers[op.c_register] = outcome[0] else: @@ -157,8 +162,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): ) if outcome == 1: state.apply_sigmaz(q_index(op.target, op.target_type)) - - classical_registers[op.c_register] = outcome + classical_registers[op.c_register] = outcome elif type(op) is ops.MeasurementCNOTandReset: if isinstance(state, MixedStabilizer): @@ -169,6 +173,9 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): state.apply_conditioned_gate( q_index(op.target, op.target_type), outcomes, gate="x" ) + # see the ClassicalCNOT branch above for why only outcomes[0] + # is recorded here + classical_registers[op.c_register] = outcomes[0] else: outcome = state.apply_measurement( @@ -178,6 +185,7 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): if outcome == 1: state.apply_sigmax(q_index(op.target, op.target_type)) + classical_registers[op.c_register] = outcome # reset the control qubit state.reset_qubit( @@ -191,6 +199,8 @@ def compile_one_gate(self, state, op, n_quantum, q_index, classical_registers): q_index(op.register, op.reg_type), measurement_determinism=self.measurement_determinism, ) + # see the ClassicalCNOT branch above for why only outcomes[0] + # is recorded here classical_registers[op.c_register] = outcomes[0] else: @@ -254,7 +264,12 @@ def compile_one_noisy_gate( pass elif type(op) is ops.MeasurementZ: - pass + # refuse rather than silently dropping the measurement, the + # classical-register write and the noise + raise NotImplementedError( + f"{type(op).__name__} carrying {type(op.noise).__name__} is not " + f"implemented for {self.__class__.__name__}." + ) else: raise ValueError( @@ -287,5 +302,15 @@ def _apply_additional_noise(self, state, op, n_quantum, q_index): state, n_quantum, [q_index(op.control, op.control_type)] ) target_noise.apply(state, n_quantum, [q_index(op.target, op.target_type)]) + elif isinstance(op, ops.ClassicalControlledPairOperationBase): + # one leg per register, in q_registers order (control first, then + # target), each on its own qubit -- the same reading as a + # ControlledPairOperationBase's legs + for noise, register, reg_type in zip( + op.noise, op.q_registers, op.q_registers_type + ): + noise.apply(state, n_quantum, [q_index(register, reg_type)]) else: - pass + raise ValueError( + f"Noise model not implemented for operation type {type(op)}" + ) diff --git a/graphiq/backends/stabilizer/functions/clifford.py b/graphiq/backends/stabilizer/functions/clifford.py index c0f161d9..5ee9045b 100644 --- a/graphiq/backends/stabilizer/functions/clifford.py +++ b/graphiq/backends/stabilizer/functions/clifford.py @@ -45,8 +45,10 @@ def z_measurement_gate( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int - :return: the resulting state after gate action, the measurement outcome, whether the measurement outcome is - probabilistic (zero means deterministic) + :raises ValueError: if the outcome is random and measurement_determinism is not "probabilistic", 0 or 1 + :return: the resulting state after gate action, the measurement outcome, and x_p, which says whether the + measurement outcome is probabilistic: zero means deterministic; otherwise the outcome is random and x_p is + the index (at least n_qubits) of the stabilizer row that anticommutes with the measurement :rtype: CliffordTableau, int, int """ n_qubits = tableau.n_qubits # number of qubits @@ -63,6 +65,20 @@ def z_measurement_gate( break if x_p != 0: # probabilistic outcome + # Pick the outcome before touching the tableau, so a rejected + # measurement_determinism leaves it as it was. + # We have the option to remove randomness and pick a specific outcome + # measurement_determinism is only used when we know random measurement outcome is possible + if measurement_determinism == "probabilistic": + outcome = np.random.randint(0, 2) + elif measurement_determinism == 1: + outcome = 1 + elif measurement_determinism == 0: + outcome = 0 + else: + raise ValueError( + f'measurement_determinism parameter must be "probabilistic", 0, or 1' + ) x_matrix = tableau.table_x z_matrix = tableau.table_z r_vector = tableau.phase @@ -78,23 +94,21 @@ def z_measurement_gate( tableau.iphase = iphase_vector table = tableau.table - # set x_p - n row equal to x_p row + # set x_p - n row equal to x_p row, phase and i phase included: the + # displaced generator becomes the new destabilizer partner for this + # qubit, so its whole row -- not just its symplectic bits -- moves. table[x_p - n_qubits] = table[x_p] + tableau.phase[x_p - n_qubits] = tableau.phase[x_p] + tableau.iphase[x_p - n_qubits] = tableau.iphase[x_p] # set x_p row equal to 0 except for z element of measured qubit which is 1. table[x_p] = np.zeros(2 * n_qubits) table[x_p, qubit_position + n_qubits] = 1 + # this row is now a fresh +/-Z_a generator, which carries a bare sign + # and no factor of i. + tableau.iphase[x_p] = 0 - # set that row of the phase vector to random measurement outcome 0 or 1 with an equal probability. - # We have the option to remove randomness and pick a specific outcome - # measurement_determinism is only used when we know random measurement outcome is possible - if measurement_determinism == "probabilistic": - outcome = np.random.randint(0, 2) - elif measurement_determinism == 1: - outcome = 1 - else: - outcome = 0 - + # set that row of the phase vector to the measurement outcome tableau.phase[x_p] = outcome tableau.table = table return tableau, outcome, x_p @@ -141,6 +155,10 @@ def measure_x(tableau, qubit_position, measurement_determinism="probabilistic"): _, outcome, _ = z_measurement_gate( stabilizer_state_new, qubit_position, measurement_determinism ) + # Rotate back out of the Z frame the measurement was taken in. The gates + # mutate the caller's tableau in place, so this leaves the caller holding + # the X-basis state the qubit was measured into. H is its own inverse. + hadamard_gate(tableau, qubit_position) return outcome @@ -168,6 +186,11 @@ def measure_y(tableau, qubit_position, measurement_determinism="probabilistic"): _, outcome, _ = z_measurement_gate( new_tableau, qubit_position, measurement_determinism ) + # Rotate back out of the Z frame, leaving the caller holding the Y-basis + # state the qubit was measured into. The pre-rotation is P dagger then H, + # so its inverse is H then P: H first, then the phase gate. + hadamard_gate(tableau, qubit_position) + phase_gate(tableau, qubit_position) return outcome @@ -212,19 +235,17 @@ def reset_z( n_qubits = tableau.n_qubits # number of qubits assert qubit_position < n_qubits assert intended_state == 0 or intended_state == 1 - tableau, outcome, probabilistic = z_measurement_gate( + tableau, outcome, _ = z_measurement_gate( tableau, qubit_position, measurement_determinism ) - if probabilistic: - tableau.phase[probabilistic] = intended_state - tableau.iphase[probabilistic] = 0 + # The measurement above has projected the qubit onto its outcome, so the + # only unitary left that moves it to the intended state is X, applied + # exactly when the two disagree. This holds on both branches: the sign of + # the measured row cannot be overwritten instead, because the surviving + # qubits' generators still reference it. + if outcome == intended_state: return tableau - - else: - if outcome == intended_state: - return tableau - else: - return x_gate(tableau, qubit_position) + return x_gate(tableau, qubit_position) def reset_x( @@ -242,7 +263,7 @@ def reset_x( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int - :return: + :return: the tableau after the reset :rtype: CliffordTableau """ new_tableau = reset_z( @@ -270,7 +291,7 @@ def reset_y( :param measurement_determinism: if the outcome is probabilistic from the simulation, we have the option to select a specific outcome :type measurement_determinism: str or int - :return: + :return: the tableau after the reset :rtype: CliffordTableau """ new_tableau = reset_z( @@ -329,10 +350,8 @@ def insert_qubit(tableau, new_position): tmp_sz = np.insert(tmp_sz, new_position, new_row, axis=0) # phase vector part - new_phase = np.insert(tableau.phase, [new_position, n_qubits + 1 + new_position], 0) - new_iphase = np.insert( - tableau.iphase, [new_position, n_qubits + 1 + new_position], 0 - ) + new_phase = np.insert(tableau.phase, [new_position, n_qubits + new_position], 0) + new_iphase = np.insert(tableau.iphase, [new_position, n_qubits + new_position], 0) new_table = np.block([[tmp_dex, tmp_dez], [tmp_sx, tmp_sz]]) tableau.expand(new_table, new_phase, new_iphase) @@ -365,6 +384,14 @@ def remove_qubit(tableau, qubit_position, measurement_determinism="probabilistic tableau, outcome, probabilistic = z_measurement_gate( tableau, qubit_position, measurement_determinism ) + if outcome == 1: + # After z_measurement_gate every stabilizer row has x_q = 0, so +Z_q + # is already in the group but may carry the wrong sign. x_gate flips + # phase exactly where z_q = 1, which fixes that sign without touching + # the x/z bits -- so it is safe to apply before non_zero is computed + # below, and it is what makes deleting column q transfer the outcome's + # sign to the remaining generators in both branches. + tableau = x_gate(tableau, qubit_position) new_table = np.delete( tableau.table, [qubit_position, qubit_position + n_qubits], axis=1 ) @@ -437,16 +464,13 @@ def swap_gate(tableau, qubit1, qubit2): """ n_qubits = tableau.n_qubits # number of qubits assert qubit1 < n_qubits and qubit2 < n_qubits + # Conjugating by SWAP permutes qubit labels within each row, so only the + # two symplectic columns move. A sign belongs to the row it is on, and + # rows -- and the destabilizer/stabilizer pairing between them -- are + # untouched by a swap, so the phase and i-phase vectors stay exactly + # where they are. tableau.table = column_swap(tableau.table, qubit1, qubit2) tableau.table = column_swap(tableau.table, qubit1 + n_qubits, qubit2 + n_qubits) - tableau.phase[[qubit1, qubit2]] = tableau.phase[[qubit2, qubit1]] - tableau.phase[[qubit1 + n_qubits, qubit2 + n_qubits]] = tableau.phase[ - [qubit2 + n_qubits, qubit1 + n_qubits] - ] - tableau.iphase[[qubit1, qubit2]] = tableau.iphase[[qubit2, qubit1]] - tableau.iphase[[qubit1 + n_qubits, qubit2 + n_qubits]] = tableau.iphase[ - [qubit2 + n_qubits, qubit1 + n_qubits] - ] return tableau @@ -468,7 +492,7 @@ def create_n_ket1_state(n_qubits): :param n_qubits: number of qubits :type n_qubits: int - :return: the state :math:`|0\\rangle^{\\otimes n}` + :return: the state :math:`|1\\rangle^{\\otimes n}` :rtype: CliffordTableau """ tableau = CliffordTableau(n_qubits) @@ -505,29 +529,42 @@ def tensor(list_of_tables): :return: The resulting tableau :rtype: CliffordTableau """ - tableau = list_of_tables[0] - list_of_tables = list_of_tables[1:] - for tab in list_of_tables: - tableau.n_qubits = tableau.n_qubits + tab.n_qubits - tableau.destabilizer_x = block_diag(tableau.destabilizer_x, tab.destabilizer_x) - tableau.destabilizer_z = block_diag(tableau.destabilizer_z, tab.destabilizer_z) - tableau.stabilizer_x = block_diag(tableau.stabilizer_x, tab.stabilizer_x) - tableau.stabilizer_z = block_diag(tableau.stabilizer_z, tab.stabilizer_z) - phase_list1 = np.split(tableau.phase, 2) - phase_list2 = np.split(tab.phase, 2) - phase_vector = np.hstack( - (phase_list1[0], phase_list2[0], phase_list1[1], phase_list2[1]) - ).astype(int) - - tableau.phase = phase_vector - - iphase_list1 = np.split(tableau.iphase, 2) - iphase_list2 = np.split(tab.iphase, 2) - iphase_vector = np.hstack( - (iphase_list1[0], iphase_list2[0], iphase_list1[1], iphase_list2[1]) - ).astype(int) + # The result is a fresh tableau, sized up front, filled once at the end. + # n_qubits and the table blocks live behind separate attributes with no + # shared resize path, so a tableau can only be built at its final size -- + # widening n_qubits on an existing one leaves the block getters slicing a + # table that has not grown. Assembling off to the side also keeps every + # argument read-only. + n_qubits = sum(tab.n_qubits for tab in list_of_tables) + tableau = CliffordTableau(n_qubits) - tableau.iphase = iphase_vector + destabilizer_x = list_of_tables[0].destabilizer_x + destabilizer_z = list_of_tables[0].destabilizer_z + stabilizer_x = list_of_tables[0].stabilizer_x + stabilizer_z = list_of_tables[0].stabilizer_z + destabilizer_phase, stabilizer_phase = np.split(list_of_tables[0].phase, 2) + destabilizer_iphase, stabilizer_iphase = np.split(list_of_tables[0].iphase, 2) + + for tab in list_of_tables[1:]: + destabilizer_x = block_diag(destabilizer_x, tab.destabilizer_x) + destabilizer_z = block_diag(destabilizer_z, tab.destabilizer_z) + stabilizer_x = block_diag(stabilizer_x, tab.stabilizer_x) + stabilizer_z = block_diag(stabilizer_z, tab.stabilizer_z) + + tab_destabilizer_phase, tab_stabilizer_phase = np.split(tab.phase, 2) + destabilizer_phase = np.hstack((destabilizer_phase, tab_destabilizer_phase)) + stabilizer_phase = np.hstack((stabilizer_phase, tab_stabilizer_phase)) + + tab_destabilizer_iphase, tab_stabilizer_iphase = np.split(tab.iphase, 2) + destabilizer_iphase = np.hstack((destabilizer_iphase, tab_destabilizer_iphase)) + stabilizer_iphase = np.hstack((stabilizer_iphase, tab_stabilizer_iphase)) + + tableau.destabilizer_x = destabilizer_x + tableau.destabilizer_z = destabilizer_z + tableau.stabilizer_x = stabilizer_x + tableau.stabilizer_z = stabilizer_z + tableau.phase = np.hstack((destabilizer_phase, stabilizer_phase)).astype(int) + tableau.iphase = np.hstack((destabilizer_iphase, stabilizer_iphase)).astype(int) return tableau diff --git a/graphiq/backends/stabilizer/functions/height.py b/graphiq/backends/stabilizer/functions/height.py index ec6cfb2a..aa562c32 100644 --- a/graphiq/backends/stabilizer/functions/height.py +++ b/graphiq/backends/stabilizer/functions/height.py @@ -101,6 +101,11 @@ def height_dict(x_matrix=None, z_matrix=None, graph=None): Generates the height_function dictionary for all qubits, given the x and z matrices or the graph the state corresponds to. + When called with ``graph=``, nodes are ordered by label (not by insertion order) to build + the adjacency matrix. The height function depends on this ordering, so a caller who wants + the minimum emitter count (rather than the value for this particular ordering) must minimize + over orderings itself. + :param x_matrix: x matrix in the symplectic representation of the stabilizers :type x_matrix: np.ndarray :param z_matrix: z matrix in the symplectic representation of the stabilizers @@ -113,7 +118,7 @@ def height_dict(x_matrix=None, z_matrix=None, graph=None): if x_matrix is None or z_matrix is None: if isinstance(graph, nx.classes.graph.Graph): n_qubits = len(graph) - node_list = list(graph.nodes()).sort() + node_list = sorted(graph.nodes()) # nodelist is an essential kwarg in converting graph to adjacency matrix. z_matrix = nx.to_numpy_array(graph, nodelist=node_list) x_matrix = np.eye(n_qubits) @@ -159,8 +164,8 @@ def height_plotter(h_dict): :param h_dict: the height function dict which is the output of the ``height_dict``. :type h_dict: dict - :return: maximum of height function over all qubits. - :rtype: int + :return: nothing + :rtype: None """ h_max = h_dict[max(h_dict, key=h_dict.get)] positions = list(h_dict.keys()) diff --git a/graphiq/backends/stabilizer/functions/linalg.py b/graphiq/backends/stabilizer/functions/linalg.py index 2e9db46c..8b2f04e5 100644 --- a/graphiq/backends/stabilizer/functions/linalg.py +++ b/graphiq/backends/stabilizer/functions/linalg.py @@ -73,7 +73,7 @@ def column_swap(input_matrix, first_col, second_col): def add_columns(input_matrix, col_to_add, target_col): """ - Add two rows together modulo 2 and put it in the row of the second input + Add two columns together modulo 2 and put it in the column of the second input :param input_matrix: a binary matrix :type input_matrix: numpy.ndarray @@ -227,7 +227,10 @@ def g_function(x1, z1, x2, z2): """ A helper function to use in rowsum function. Takes 4 bits (2 Pauli matrices in the binary representation) as input and returns the phase factor needed when the two Pauli matrices are multiplied: Pauli_1 * Pauli_2 - As a convention for the phase, each Y operator introduces -i phase factor. + The bit pair (x, z) = (1, 1) denotes the Hermitian Y, not XZ = -iY, matching the + encoding of arXiv:quant-ph/0406196v5 Sec. III ("11 means Y") in which a row carries + only a +1/-1 sign. The returned exponent k is fixed by that reading: for example + X * Z = -iY gives k = -1, whereas under an XZ reading the same product would give 0. Refer to section III of arXiv:quant-ph/0406196v5 @@ -271,8 +274,8 @@ def row_sum(x_matrix, z_matrix, r_vector, iphase_vector, row_to_add, target_row) :type row_to_add: int :param target_row: the stabilizer to be multiplied by the "to_add" stabilizer :type target_row: int - :return: updated stabilizer tableau - :rtype: numpy.ndarray, numpy.ndarray, numpy.ndarray + :return: the updated x_matrix, z_matrix, r_vector, and iphase_vector + :rtype: numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray """ n_qubits = np.shape(x_matrix)[1] # determining the phase factor diff --git a/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py b/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py index 0c680ae5..5214ccc5 100644 --- a/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py +++ b/graphiq/backends/stabilizer/functions/local_cliff_equi_check.py @@ -12,6 +12,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Local-Clifford equivalence between two stabilizer states, and the gate sequences and circuits +that convert one into the other. + +``lc_check`` and ``state_converter_circuit`` take either state as a StabilizerTableau, a +CliffordTableau, a networkx graph or an adjacency matrix, and put both through +``state_to_graph`` first. ``converter_gate_list`` works on two graphs, and ``str_to_op`` turns +the resulting gate tuples into operation objects. +""" import networkx as nx import numpy as np diff --git a/graphiq/backends/stabilizer/functions/rep_conversion.py b/graphiq/backends/stabilizer/functions/rep_conversion.py index 8c0dd927..619b7ee3 100644 --- a/graphiq/backends/stabilizer/functions/rep_conversion.py +++ b/graphiq/backends/stabilizer/functions/rep_conversion.py @@ -35,7 +35,9 @@ def clifford_from_stabilizer(stabilizer_tableau): :rtype: CliffordTableau """ n_qubits = stabilizer_tableau.n_qubits - _, circuit = inverse_circuit(stabilizer_tableau) + # The reduction runs in place, so it works on a copy: the caller's tableau is an + # input here and still holds its state afterwards. + _, circuit = inverse_circuit(stabilizer_tableau.copy()) clifford_tableau = sfc.create_n_ket0_state(n_qubits) return transform.run_circuit(clifford_tableau, circuit, reverse=True) diff --git a/graphiq/backends/stabilizer/functions/stabilizer.py b/graphiq/backends/stabilizer/functions/stabilizer.py index 720569bc..1445b1f6 100644 --- a/graphiq/backends/stabilizer/functions/stabilizer.py +++ b/graphiq/backends/stabilizer/functions/stabilizer.py @@ -37,24 +37,30 @@ def one_pauli_type_finder(x_matrix, z_matrix, pivot, pauli_type): :type z_matrix: np.ndarray :param pivot: the location of the pivot element [i,j] on the i-th row and j-th column of the stabilizer tableau :type pivot: list - :param pauli_type: A string to specify the type of Pauli to be found + :param pauli_type: which Pauli to look for: "x", "y" or "z", case-insensitively :type pauli_type: str + :raises ValueError: if pauli_type is not one of "x", "y" or "z" :return: a list containing the positions (row indices) of the Pauli operators of type pauli_type in and below the row specified by pivot[0] in the column given by pivot[1] :rtype: list """ - if pauli_type.lower() == "x": + pauli = pauli_type.lower() + if pauli == "x": match = (1, 0) - elif pauli_type.lower() == "y": + elif pauli == "y": match = (1, 1) - elif pauli_type.lower() == "z": + elif pauli == "z": match = (0, 1) else: - match = (0, 0) - n_qubits = np.shape(x_matrix)[1] + # silently matching the identity rows here would, e.g., let + # canonical_form return a tableau that is not in canonical form + raise ValueError( + f"pauli_type must be one of 'x', 'y' or 'z' (case-insensitive), got {pauli_type!r}" + ) + n_row = np.shape(x_matrix)[0] pauli_list = [] - for row_i in range(pivot[0], n_qubits): + for row_i in range(pivot[0], n_row): if (x_matrix[row_i, pivot[1]], z_matrix[row_i, pivot[1]]) == match: pauli_list.append(row_i) @@ -77,7 +83,7 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): it means there are Pauli X operators in rows 3 and 4 in the pivot column. :rtype: list, list, list """ - n_qubits = np.shape(x_matrix)[1] + n_row = np.shape(x_matrix)[0] # list of the rows (generators) with a pauli X operator in the pivot column pauli_x_list = [] # list of the rows (generators) with a pauli Y operator in the pivot column @@ -85,7 +91,7 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): # list of the rows (generators) with a pauli Z operator in the pivot column pauli_z_list = [] - for row_i in range(pivot[0], n_qubits): + for row_i in range(pivot[0], n_row): if x_matrix[row_i, pivot[1]] == 1 and z_matrix[row_i, pivot[1]] == 0: pauli_x_list.append(row_i) elif x_matrix[row_i, pivot[1]] == 1 and z_matrix[row_i, pivot[1]] == 1: @@ -96,6 +102,29 @@ def pauli_type_finder(x_matrix, z_matrix, pivot): return pauli_x_list, pauli_y_list, pauli_z_list +def _bare_z_row(tableau, qubit_position): + """ + Find the generator that is a bare Pauli Z on the given qubit and the identity + everywhere else. Such a generator is unique when it exists: two of them would be + the same generator. + + :param tableau: the tableau to search + :type tableau: StabilizerTableau + :param qubit_position: the qubit the generator acts on + :type qubit_position: int + :return: the row index of that generator, or None if the tableau has none + :rtype: int or None + """ + for row_i in range(tableau.n_qubits): + if ( + not tableau.x_matrix[row_i].any() + and np.count_nonzero(tableau.z_matrix[row_i]) == 1 + and tableau.z_matrix[row_i, qubit_position] == 1 + ): + return row_i + return None + + def inverse_circuit(tableau): """ Find the inverse circuit that transforms the input stabilizer tableau back to the stabilizer tableau @@ -103,32 +132,131 @@ def inverse_circuit(tableau): :param tableau: the input tableau :type tableau: StabilizerTableau + :raises ValueError: if the tableau is not a valid stabilizer state :return: the tableau that is converted to the basis state, list of gate instructions :rtype: StabilizerTableau, list[tuple] """ + n_qubits = tableau.n_qubits + zero_state = StabilizerTableau(n_qubits) + backup = tableau.copy() + reduced, circuit_list = _reduce_to_zero_state(tableau, pivot_every_column=False) + if reduced == zero_state: + return reduced, circuit_list + + # The first Hadamard block can leave the later blocks without a pivot, or end on + # |0...0> written with generators other than the bare Z on each qubit, which the + # stabilizer inner product reads row by row. Start again from the input, in the + # same object, with the block that places a pivot on every column. + tableau.table = backup.table + tableau.phase = backup.phase + reduced, circuit_list = _reduce_to_zero_state(tableau, pivot_every_column=True) + if reduced != zero_state: + raise ValueError( + "the tableau did not reduce to the all-zero state; it is not a valid " + "stabilizer state" + ) + return reduced, circuit_list + + +def _reduce_to_zero_state(tableau, pivot_every_column): + """ + Reduce the tableau towards the tableau of :math:`|0\\rangle^{\\otimes n}`, + with one of two versions of the first Hadamard block + + :param tableau: the input tableau, reduced in place + :type tableau: StabilizerTableau + :param pivot_every_column: False to apply H only to a Z-type column with support to + its right; True to apply H to every column that has no X pivot and is not a + bare-Z qubit, and then place a pivot on every diagonal entry + :type pivot_every_column: bool + :return: the reduced tableau, list of gate instructions + :rtype: StabilizerTableau, list[tuple] + """ circuit_list = [] - pivot = [0, 0] n_qubits = tableau.n_qubits tableau = canonical_form(tableau) - # Hadamard block - for j in range(n_qubits): - pivot[1] = j - x_list, y_list, z_list = pauli_type_finder( - tableau.x_matrix, tableau.z_matrix, pivot - ) - if x_list: - tableau = tab_row_swap(tableau, pivot[0], x_list[0]) - elif y_list: - tableau = tab_row_swap(tableau, pivot[0], y_list[0]) - elif z_list: - tableau = tab_row_swap(tableau, pivot[0], z_list[-1]) - if np.any(tableau.x_matrix[pivot[0], j + 1 : n_qubits]) or np.any( - tableau.z_matrix[pivot[0], j + 1 : n_qubits] - ): + if pivot_every_column: + # Hadamard block + # The blocks below clear the X matrix by column operations, keying off the + # diagonal entry of each column, and they leave a column alone when its qubit is + # already reduced. So this block has to put a generator on every diagonal entry: + # an X pivot on the qubits that still need work, the bare Z on the ones that do + # not. + # + # The canonical form leaves the X matrix in reduced row echelon form, with the + # generators that have no X support gathered in the rows underneath and their Z + # parts reduced in turn. The leading entry of each row therefore names the + # columns that carry an X pivot, and a generator that is a bare Z on a single + # qubit says that qubit is already reduced -- unentangled, and in one of the two + # computational basis states -- so no gate but the sign fix belongs on it. Every + # other column without an X pivot gets a Hadamard, which moves that column's Z + # entries into the X matrix. That makes the X matrix invertible on the columns + # that need a pivot: a generator with no X support is orthogonal to the X row + # space, because the group is abelian, and such an element supported only on the + # pivot columns is the identity -- in reduced row echelon form the product + # against pivot row i collapses to that element's entry at pivot column i, so + # commutation kills the pivot coordinates one at a time. Their Z entries on the + # other columns are therefore independent. + x_pivot_columns = set() + reduced_columns = set() + for row_i in range(n_qubits): + x_support = np.nonzero(tableau.x_matrix[row_i])[0] + if x_support.size: + x_pivot_columns.add(int(x_support[0])) + elif np.count_nonzero(tableau.z_matrix[row_i]) == 1: + reduced_columns.add(int(np.nonzero(tableau.z_matrix[row_i])[0][0])) + + for j in range(n_qubits): + if j not in x_pivot_columns and j not in reduced_columns: circuit_list.append(("H", j)) tableau = transform.hadamard_gate(tableau, j) - pivot[0] = pivot[0] + 1 + + # Move the generator for each column onto the diagonal and clear the column + # below it. These are row operations: they change which generators are written + # down, not the state, so they cost no gates. An already-reduced qubit takes its + # own bare Z generator, which the eliminations cannot have touched -- they only + # add into rows that have X support in the column being cleared. + for j in range(n_qubits): + if j in reduced_columns: + pivot_row = _bare_z_row(tableau, j) + else: + rows_below = np.nonzero(tableau.x_matrix[j:n_qubits, j])[0] + pivot_row = j + int(rows_below[0]) if rows_below.size else None + if pivot_row is None or pivot_row < j: + # Unreachable on a valid stabilizer state. It must raise rather than + # assert: with assertions off, a pivot_row below j would swap a row + # upward over an already-placed column and return a wrong tableau with a + # circuit that does not zero the state, with no error. + raise ValueError( + f"column {j} has no generator left to pivot on; the tableau is " + f"not a valid stabilizer state" + ) + tableau = tab_row_swap(tableau, j, pivot_row) + for row_i in range(j + 1, n_qubits): + if tableau.x_matrix[row_i, j] == 1: + tableau = tab_row_sum(tableau, j, row_i) + else: + # Hadamard block + pivot = [0, 0] + for j in range(n_qubits): + pivot[1] = j + x_list, y_list, z_list = pauli_type_finder( + tableau.x_matrix, tableau.z_matrix, pivot + ) + if x_list: + tableau = tab_row_swap(tableau, pivot[0], x_list[0]) + elif y_list: + tableau = tab_row_swap(tableau, pivot[0], y_list[0]) + elif z_list: + tableau = tab_row_swap(tableau, pivot[0], z_list[-1]) + if np.any(tableau.x_matrix[pivot[0], j + 1 : n_qubits]) or np.any( + tableau.z_matrix[pivot[0], j + 1 : n_qubits] + ): + circuit_list.append(("H", j)) + tableau = transform.hadamard_gate(tableau, j) + pivot[0] = pivot[0] + 1 + # CNOT block for j in range(n_qubits): for k in range(j + 1, n_qubits): @@ -385,9 +513,9 @@ def rref(tableau): n_qubits = tableau.n_qubits while pivot[0] <= n_qubits - 1 and pivot[1] <= n_qubits - 1: tableau, pivot = one_step_rref(tableau, pivot) - # rank check + # rank check: every generator must have received a pivot assert ( - pivot[0] >= n_qubits - 1 + pivot[0] == n_qubits ), "Invalid input. One of the stabilizers is identity on all qubits!" return tableau diff --git a/graphiq/backends/stabilizer/functions/transformation.py b/graphiq/backends/stabilizer/functions/transformation.py index a5449a6e..0616a9df 100644 --- a/graphiq/backends/stabilizer/functions/transformation.py +++ b/graphiq/backends/stabilizer/functions/transformation.py @@ -251,7 +251,7 @@ def run_circuit(tableau, circuit_list, reverse=False): :rtype: CliffordTableau or StabilizerTableau """ if reverse: - circuit_list.reverse() + circuit_list = circuit_list[::-1] for ops in circuit_list: if ops[0] == "H": tableau = hadamard_gate(tableau, ops[1]) diff --git a/graphiq/backends/stabilizer/functions/utils.py b/graphiq/backends/stabilizer/functions/utils.py index adbfb3c1..a3cb8565 100644 --- a/graphiq/backends/stabilizer/functions/utils.py +++ b/graphiq/backends/stabilizer/functions/utils.py @@ -48,6 +48,27 @@ def symplectic_to_string(x_matrix, z_matrix): return generator_list +_PAULI_CHARACTERS = frozenset("ixyzIXYZ") + + +def _validate_pauli_label(label): + """ + Raise ValueError if label contains a character other than I, X, Y, or Z + (case-insensitive). + + :param label: a single stabilizer generator string + :type label: str + :return: nothing + :rtype: None + """ + invalid = set(label) - _PAULI_CHARACTERS + if invalid: + raise ValueError( + f"Invalid stabilizer generator {label!r}: unexpected character(s) " + f"{''.join(sorted(invalid))!r}; expected one of I, X, Y, Z" + ) + + def string_to_symplectic(generator_list): """ Convert a string list representation of stabilizer generators to a symplectic representation @@ -56,7 +77,36 @@ def string_to_symplectic(generator_list): :type generator_list: list[str] :return: two binary matrices, one for X part, the other for Z part :rtype: numpy.ndarray, numpy.ndarray + :raises ValueError: if generator_list is a bare string rather than a list of strings, + if it is empty, if any generator contains a character other than I, X, Y, Z + (case-insensitive), or if the generators are not all the same length """ + if isinstance(generator_list, str): + raise ValueError( + "string_to_symplectic expects a list of generator strings, not a single " + f"string ({generator_list!r}); wrap it in a list" + ) + + # Materialise first: the validation below iterates twice, and an exhausted + # iterator would report an empty, actively misleading length set. + generator_list = list(generator_list) + + if not generator_list: + raise ValueError( + "string_to_symplectic expects at least one generator; an empty list " + "gives no qubit count to build a tableau from" + ) + + for label in generator_list: + _validate_pauli_label(label) + + lengths = {len(label) for label in generator_list} + if len(lengths) != 1: + raise ValueError( + f"Stabilizer generators have inconsistent lengths {sorted(lengths)}: " + f"{generator_list!r}" + ) + n_row = len(generator_list) n_column = len(generator_list[0]) x_matrix = np.zeros((n_row, n_column)) @@ -82,7 +132,10 @@ def get_stabilizer_element_by_string(generator): :type generator: str :return: a matrix representation of the stabilizer element :rtype: numpy.ndarray + :raises ValueError: if generator contains a character other than I, X, Y, Z + (case-insensitive) """ + _validate_pauli_label(generator) stabilizer_elem = 1 for pauli in generator: if pauli.lower() == "x": @@ -125,29 +178,65 @@ def is_symplectic_self_orthogonal(input_matrix): :return: True if the input matrix is self-orthogonal; False otherwise :rtype: bool """ - dim = int(input_matrix.shape[1] / 2) - return np.array_equal( - binary_symplectic_product(input_matrix, input_matrix), np.zeros((dim, dim)) + binary_symplectic_product(input_matrix, input_matrix), + np.zeros((input_matrix.shape[0],) * 2), ) +def is_stabilizer(table): + """ + Check if a given n x 2n binary symplectic table is a valid set of stabilizer generators, + i.e. its rows pairwise commute and are linearly independent over GF(2). + + :param table: an n x 2n binary symplectic table + :type table: numpy.ndarray + :return: True if the rows pairwise commute and are linearly independent over GF(2); False + otherwise + :rtype: bool + """ + n_rows, n_columns = table.shape + assert n_columns == 2 * n_rows + if not is_symplectic_self_orthogonal(table): + return False + # GF(2) rank by Gaussian elimination; a real-valued rank would not do, e.g. + # ZZI, IZZ, ZIZ has GF(2) rank 2 but real rank 3 + reduced = np.array(table, dtype=int) % 2 + rank = 0 + for column in range(n_columns): + pivots = np.nonzero(reduced[rank:, column])[0] + if len(pivots) == 0: + continue + pivot = rank + pivots[0] + reduced[[rank, pivot]] = reduced[[pivot, rank]] + for row in range(n_rows): + if row != rank and reduced[row, column]: + reduced[row] = (reduced[row] + reduced[rank]) % 2 + rank += 1 + if rank == n_rows: + break + return rank == n_rows + + def binary_symplectic_product(matrix1, matrix2): """ Compute the binary symplectic product of two matrices matrix1 (:math:`M_1`) and matrix2 (:math:`M_2`) The symplectic inner product between :math:`M_1` and :math:`M_2` is :math:`M_1 P M_2^T`, - where :math:`P = \\begin{bmatrix} 0 & I \\\ I & 0 \\end{bmatrix}`. + where :math:`P = \\begin{bmatrix} 0 & I \\\\ I & 0 \\end{bmatrix}` is sized from the shared + number of columns (2n), not the row count -- the two matrices need not have the same number of + rows (e.g. a stabilizer's n rows against a Clifford tableau's 2n). :param matrix1: a binary symplectic matrix :type matrix1: numpy.ndarray :param matrix2: a binary symplectic matrix :type matrix2: numpy.ndarray - :return: the symplectic product of these two matrices - :rtype: int + :return: a (number of rows in matrix1, number of rows in matrix2) binary matrix, one + entry per row pair + :rtype: numpy.ndarray """ - assert matrix1.shape[0] == matrix2.shape[0] - dim = matrix1.shape[0] + assert matrix1.shape[1] == matrix2.shape[1] + dim = matrix1.shape[1] // 2 symplectic_p = np.block( [[np.zeros((dim, dim)), np.eye(dim)], [np.eye(dim), np.zeros((dim, dim))]] ).astype(int) diff --git a/graphiq/backends/stabilizer/state.py b/graphiq/backends/stabilizer/state.py index 44bc066f..a49557cc 100644 --- a/graphiq/backends/stabilizer/state.py +++ b/graphiq/backends/stabilizer/state.py @@ -26,7 +26,26 @@ class Stabilizer(StateRepresentationBase): + """ + A pure state representation using the stabilizer formalism, backed by a single + CliffordTableau. + + General unitaries are not supported; the state evolves by Clifford gates, Z and X + measurements, reset to |0>, and the qubit-removing operations (remove, trace out). For a + probability-weighted mixture of tableaus, see MixedStabilizer. + """ + def __init__(self, data, *args, **kwargs): + """ + Create a Stabilizer representation object + + :param data: the number of qubits to initialize in the all-zero state, or an existing + tableau to adopt (not copied) + :type data: int or CliffordTableau + :raises TypeError: if data is neither an int nor a CliffordTableau + :return: nothing + :rtype: None + """ super().__init__(data, *args, **kwargs) if isinstance(data, int): self._tableau = CliffordTableau(data) @@ -39,6 +58,14 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + :param data: the candidate data + :type data: any + :return: True if data is an int or a CliffordTableau + :rtype: bool + """ return isinstance(data, (int, CliffordTableau)) @property @@ -101,6 +128,17 @@ def data(self, value): self.tableau = value def apply_unitary(self, qubit_position, unitary): + """ + Not supported: the stabilizer formalism represents Clifford operations only. + + :param qubit_position: the qubit the unitary would act on + :type qubit_position: int + :param unitary: the unitary that would be applied + :type unitary: numpy.ndarray + :raises NotImplementedError: always + :return: nothing + :rtype: None + """ raise NotImplementedError( "Stabilizer backend does not support general unitary operation." ) @@ -146,7 +184,7 @@ def apply_x_measurement( self, qubit_position, measurement_determinism="probabilistic" ): """ - Apply the measurement in the computational basis to a given qubit + Apply the measurement in the X basis to a given qubit :param qubit_position: the qubit position where the measurement is applied :type qubit_position: int @@ -157,13 +195,7 @@ def apply_x_measurement( :return: the measurement outcome :rtype: int """ - ( - self._tableau, - outcome, - _, - ) = sfc.x_measurement_gate( - self._tableau, qubit_position, measurement_determinism - ) + outcome = sfc.measure_x(self._tableau, qubit_position, measurement_determinism) return outcome def apply_hadamard(self, qubit_position): @@ -307,9 +339,11 @@ def trace_out_qubits( :return: nothing :rtype: None """ + # partial_trace takes the qubits to keep, not the ones to trace out + keep = [q for q in range(self.n_qubits) if q not in set(qubit_positions)] self._tableau = sfc.partial_trace( self._tableau, - keep=qubit_positions, + keep=keep, dims=self.n_qubits * [2], measurement_determinism=measurement_determinism, ) @@ -318,10 +352,10 @@ def partial_trace(self, keep, dims): """ Trace out qubits after disentangling them from the rest - :param keep: + :param keep: the qubit positions to be kept :type keep: list[int] or numpy.ndarray - :param dims: - :type dims: + :param dims: dimension of each subsystem + :type dims: list[int] or numpy.ndarray :return: nothing :rtype: None """ @@ -342,9 +376,12 @@ def __eq__(self, other): :param other: the other Stabilizer to be compared :type other: Stabilizer - :return: True if the stabilizer tableaux of two Stabilizer objects are the same - :rtype: bool + :return: True if the stabilizer tableaux of two Stabilizer objects are + the same; NotImplemented if other is not a Stabilizer + :rtype: bool or NotImplementedType """ + if not isinstance(other, Stabilizer): + return NotImplemented tableau1 = canonical_form(self.data.to_stabilizer()) tableau2 = canonical_form(other.data.to_stabilizer()) return tableau1 == tableau2 @@ -357,6 +394,19 @@ class MixedStabilizer(StateRepresentationBase): """ def __init__(self, data, *args, **kwargs): + """ + Create a MixedStabilizer representation object + + An int or a single tableau becomes a one-element mixture of probability 1.0. + + :param data: the number of qubits to initialize in the all-zero state, a single tableau, + or a ready mixture as a list of (probability_i, tableau_i) + :type data: int or CliffordTableau or list[tuple] + :raises TypeError: if data is none of those + :raises AssertionError: if a list entry is not a (float, CliffordTableau) pair + :return: nothing + :rtype: None + """ if isinstance(data, int): self._mixture = [ (1.0, CliffordTableau(data)), @@ -378,6 +428,16 @@ def __init__(self, data, *args, **kwargs): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + A list is accepted only if every entry is a (float, CliffordTableau) pair. + + :param data: the candidate data + :type data: any + :return: True if data is an int, a CliffordTableau, or such a list + :rtype: bool + """ valid = isinstance(data, (int, CliffordTableau, list)) if isinstance(data, list): valid = valid and all( @@ -413,8 +473,11 @@ def mixture(self, value): :param value: a new mixture list, pure tableau, or a parameter to initialize a new tableau :type value: list or int or CliffordTableau - :return: the mixture as a list of (probability_i, tableau_i) - :rtype: list + :raises AssertionError: if a list entry is not a (float, CliffordTableau) pair, or if the tableaux + do not all have the same number of qubits + :raises TypeError: if value is not a list, a CliffordTableau or an int + :return: nothing + :rtype: None """ if isinstance(value, list): assert all( @@ -452,8 +515,8 @@ def data(self, value): """ Set the data that represents the state given by the MixedStabilizer representation - :param value: a new tableau or a parameter to initialize a new tableau - :type value: CliffordTableau or int + :param value: a new mixture list, pure tableau, or a parameter to initialize a new tableau + :type value: list or CliffordTableau or int :return: nothing :rtype: None """ @@ -472,33 +535,121 @@ def probability(self): @property def tableau(self): - return TypeError( + """ + A mixture has one tableau per branch, not one overall; read + :attr:`mixture` instead. + + :raises TypeError: always + """ + raise TypeError( "Simulating using a mixed state representation, no tableau defined." ) + @staticmethod + def _state_key(tableau): + """ + A hashable key identifying the STATE a branch's tableau represents, + as opposed to the raw tableau data. + + Two CliffordTableau that represent the same state can differ in their + destabilizer rows (a free choice of completion) and in the order/linear + combination of their stabilizer generators, so raw data equality + (``CliffordTableau.__eq__``) is too strict for grouping branches of a + mixture. The key is instead built from the stabilizer half only, put + into canonical form, together with its phase -- phase is included + because two branches whose stabilizer groups agree up to canonical + form but differ in sign (e.g. |0> vs |1>) are physically distinct + states and must not be folded together. + + ``canonical_form`` mutates the StabilizerTableau it is given in place, + and both halves of that tableau are views into the CliffordTableau + until ``StabilizerTableau.__init__`` copies them: + ``CliffordTableau.stabilizer`` is a view into the table, and + ``to_stabilizer`` slices the phase vector rather than copying it. Two + separate copies therefore stand between this and the branch being + reduced -- ``np.copy`` for the table, and ``phase.astype(int)`` for the + phase. The phase one is the easier to lose in a refactor and the more + damaging: ``tab_row_swap`` assigns ``tableau.phase = row_swap(...)``, + and ``row_swap`` mutates its argument in place before the setter runs, + so without that copy an ordinary canonical-form row swap would corrupt + the parent CliffordTableau's phase vector directly. + + Internally, the row operations ``canonical_form`` performs discard + the i-phase of each row (only the +-1 phase is tracked). This is + harmless specifically inside canonical form reduction of a + stabilizer's own generators: since stabilizer generators mutually + commute, the sum of any two rows' g-function terms is always even, so + no residual factor of i is ever dropped on the floor. + + Separately from that, the key ignores the CliffordTableau's whole + i-phase vector, destabilizer and stabilizer rows alike, because + ``to_stabilizer`` keeps only the +-1 phase and a StabilizerTableau has + no i-phase at all. ``reduce`` does not check the i-phase either, so a + branch with a non-zero i-phase is folded into the first branch of the + same state and disappears; the i-phase check made when converting to + a density matrix sees it only if it is the branch kept. No + correct gate produces such a tableau. + + :param tableau: a branch's tableau + :type tableau: CliffordTableau + :return: a hashable key identifying the state + :rtype: tuple[bytes, bytes] + """ + canonical = canonical_form(tableau.to_stabilizer()) + table_bytes = np.ascontiguousarray(canonical.table.astype(np.int8)).tobytes() + phase_bytes = np.ascontiguousarray(canonical.phase.astype(np.int8)).tobytes() + return table_bytes, phase_bytes + def reduce(self): """ - Reduce the number of tableaux store in the mixture by comparing the Hamming distance between them. - Probabilities are summed and one tableau removed if they are the same. + Fold branches that represent the same state into a single entry, + summing their probabilities. + + Branches are grouped by state identity (see ``_state_key``) rather + than by raw tableau equality, so two branches that reach the same + state by different routes -- e.g. different destabilizer completions, + or stabilizer generators produced in a different order -- fold + together even though ``CliffordTableau.__eq__`` would call them + unequal. + + ``reduce`` itself costs more per call than a raw-equality scan, since + ``canonical_form`` is O(n^3) Python-level row operations paid once per + branch. The simulation as a whole is faster, because it merges + branches that raw equality misses (on a noisy compile at + n_emitters=3, a mixture of 64 branches reduces to 8), and every later + operation on the mixture iterates all of it. :return: nothing :rtype: None """ - # TODO: explore other ways of reduction and further simplification using a standard form. - mixture_temp = self._mixture - mixture_reduce = [] - while len(mixture_temp) != 0: - p0, t0 = mixture_temp[0] - mixture_temp.pop(0) - for i, (p_i, t_i) in enumerate(mixture_temp): - if np.count_nonzero(t0 != t_i) == 0: - p0 += p_i - mixture_temp.pop(i) - - mixture_reduce.append((p0, t0)) - self._mixture = mixture_reduce + # Grouping builds a new list rather than mutating in place: the + # mixture setter stores the caller's list by reference, so rewriting + # it here would change it out from under the caller. `order` records + # the first-seen order of the groups, which the result follows. + groups = {} + order = [] + for p_i, t_i in self._mixture: + key = self._state_key(t_i) + if key in groups: + p0, t0 = groups[key] + groups[key] = (p0 + p_i, t0) + else: + groups[key] = (p_i, t_i) + order.append(key) + self._mixture = [groups[key] for key in order] def apply_unitary(self, qubit_position, unitary): + """ + Not supported: the stabilizer formalism represents Clifford operations only. + + :param qubit_position: the qubit the unitary would act on + :type qubit_position: int + :param unitary: the unitary that would be applied + :type unitary: numpy.ndarray + :raises NotImplementedError: always + :return: nothing + :rtype: None + """ raise NotImplementedError( "Stabilizer backend does not support general unitary operation." ) @@ -507,10 +658,17 @@ def apply_conditioned_gate(self, qubit_position, outcomes, gate=None): """ Apply a single-qubit gate, conditioned on a classical measurement outcome. - :param qubit_position: int - :param outcomes: list of measurement outcomes for each tableau in the mixture - :param gate: str, one of 'x', 'y', 'z', or 'h' - :return: + :param qubit_position: the qubit position where the gate is applied + :type qubit_position: int + :param outcomes: list of measurement outcomes for each tableau in the mixture; the gate is applied to + the tableaux whose outcome is 1 + :type outcomes: list[int] + :param gate: one of 'x', 'y', 'z', or 'h' + :type gate: str + :raises AssertionError: if outcomes is not a list with one entry per tableau in the mixture + :raises NotImplementedError: if gate is not one of 'x', 'y', 'z', or 'h' + :return: nothing + :rtype: None """ assert isinstance(outcomes, list) assert len(outcomes) == len(self._mixture) @@ -724,13 +882,17 @@ def trace_out_qubits( :return: nothing :rtype: None """ + # partial_trace takes the qubits to keep, not the ones to trace out. + # keep is computed once, before the comprehension, for every term. + n_qubits = self.n_qubits + keep = [q for q in range(n_qubits) if q not in set(qubit_positions)] self._mixture = [ ( p_i, sfc.partial_trace( t_i, - keep=qubit_positions, - dims=self.n_qubits * [2], + keep=keep, + dims=n_qubits * [2], measurement_determinism=measurement_determinism, ), ) @@ -785,21 +947,26 @@ def __eq__(self, other): :param other: the other MixedStabilizer to be compared :type other: MixedStabilizer - :return: True if the stabilizer tableaux of two MixedStabilizer objects are the same - :rtype: bool + :return: True if the two mixtures have the same length and, after each is sorted by + probability, every pair of entries has the same probability (up to np.isclose) and + the same stabilizer tableau; NotImplemented if other is not a MixedStabilizer + :rtype: bool or NotImplementedType """ + if not isinstance(other, MixedStabilizer): + return NotImplemented # Treat two objects the same if and only if they have the same probability distribution # and same set of tableaux with the same probability if len(self._mixture) != len(other.mixture): return False - # sort first - self.sort() - other.sort() - for i in range(len(self._mixture)): - if not np.isclose(self._mixture[i][0], other.mixture[i][0]): + # Compare sorted copies so the comparison does not reorder either + # object's mixture as a side effect. + mixture1 = sorted(self._mixture, key=lambda item: item[0], reverse=True) + mixture2 = sorted(other.mixture, key=lambda item: item[0], reverse=True) + for i in range(len(mixture1)): + if not np.isclose(mixture1[i][0], mixture2[i][0]): return False else: - if self._mixture[i][1] != other.mixture[i][1]: + if mixture1[i][1] != mixture2[i][1]: return False return True diff --git a/graphiq/backends/stabilizer/tableau.py b/graphiq/backends/stabilizer/tableau.py index 77ecf108..d9ac557c 100644 --- a/graphiq/backends/stabilizer/tableau.py +++ b/graphiq/backends/stabilizer/tableau.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +The tableau base class and the stabilizer tableau: a binary symplectic table plus its phase vector. +""" import copy from abc import ABC @@ -23,7 +26,6 @@ class TableauBase(ABC): """ The base class for Stabilizer and Clifford Tableau - """ def __init__(self, table, phase, n_qubits, shape): @@ -86,8 +88,17 @@ def phase(self, value): :type value: numpy.ndarray :return: nothing :rtype: None - """ - assert value.shape[0] == self._phase.shape[0] + :raises ValueError: if value's shape is not (self.shape[0],) + """ + # self.shape[0] is the canonical phase length for both subclasses: + # n for StabilizerTableau, whose shape is (n, 2n), and 2n for + # CliffordTableau, whose shape is (2n, 2n). Comparing against + # self._phase.shape instead would let a corrupted _phase relax this + # check to match the corruption rather than catch it. + if value.shape != (self.shape[0],): + raise ValueError( + f"phase must have shape ({self.shape[0]},), got {value.shape}" + ) self._phase = value.astype(int) @@ -102,8 +113,10 @@ def __init__(self, data, phase=None): :param data: the data used to initialize the stabilizer tableau :type data: int or numpy.ndarray or [numpy.ndarray, numpy.ndarray] - :param phase: the phase vector - :type phase: numpy.ndarray + :param phase: the phase vector, or None for an all-zero default + :type phase: numpy.ndarray or list[int] or None + :raises ValueError: if data is a list whose two matrices are not square 2-D arrays, or if phase is not None + and its shape is not (n_qubits,) """ if isinstance(data, int): self._table = np.hstack([np.zeros((data, data)), np.eye(data)]).astype(int) @@ -116,19 +129,37 @@ def __init__(self, data, phase=None): assert len(data) == 2 assert isinstance(data[0], np.ndarray) and isinstance(data[1], np.ndarray) assert data[0].shape == data[1].shape + # n generators on n qubits: a non-square pair is not a stabilizer + # state and would otherwise produce a wrongly sized tableau + if data[0].ndim != 2 or data[0].shape[0] != data[0].shape[1]: + raise ValueError( + "x_matrix and z_matrix must be square 2-D arrays, got shape " + f"{data[0].shape}" + ) self._table = np.hstack(data).astype(int) - self.n_qubits = data[0].shape[1] + self.n_qubits = data[0].shape[0] else: raise TypeError("Cannot support the input type") - if isinstance(phase, np.ndarray) and phase.shape[0] == self.n_qubits: - self._phase = np.copy(phase).astype(int) - else: + if phase is None: self._phase = np.zeros(self.n_qubits).astype(int) + else: + phase = np.asarray(phase) + if phase.shape != (self.n_qubits,): + raise ValueError( + f"phase must have shape ({self.n_qubits},), got {phase.shape}" + ) + self._phase = phase.astype(int) self.shape = (self.n_qubits, 2 * self.n_qubits) def copy(self): + """ + A deep copy of the tableau, sharing no array with the original + + :return: an independent copy + :rtype: StabilizerTableau + """ return copy.deepcopy(self) @property @@ -148,7 +179,9 @@ def x_matrix(self, value): :param value: the X matrix part of the stabilizer tableau :type value: numpy.ndarray - :return: + :raises AssertionError: if value is not of shape (n_qubits, n_qubits) + :return: nothing + :rtype: None """ assert value.shape == (self.n_qubits, self.n_qubits) self._table[:, 0 : self.n_qubits] = value.astype(int) @@ -170,6 +203,7 @@ def z_matrix(self, value): :param value: the Z matrix part of the stabilizer tableau :type value: numpy.ndarray + :raises AssertionError: if value is not of shape (n_qubits, n_qubits) :return: nothing :rtype: None """ @@ -197,7 +231,7 @@ def __eq__(self, other): :rtype: bool """ if isinstance(other, StabilizerTableau): - return np.all(self.phase == other.phase) and np.array_equal( + return np.array_equal(self.phase, other.phase) and np.array_equal( self.table.astype(int), other.table.astype(int) ) return False @@ -224,15 +258,31 @@ def from_labels(self, labels): def validate(self): """ - Validate that the tableau is a valid tableau + Validate that the tableau is a valid set of stabilizer generators, i.e. its rows + pairwise commute and are linearly independent over GF(2) - :return: True if the tableau is symplectic; False if it is not + :return: True if the tableau is valid; False if it is not :rtype: bool """ return sfu.is_stabilizer(self._table) def _reset(self, new_table, new_phase): + """ + Replace the table and phase together, re-deriving the qubit count and shape + + :param new_table: the replacement table, whose row count gives the new qubit count + :type new_table: numpy.ndarray + :param new_phase: the replacement phase vector, of length n_qubits + :type new_phase: numpy.ndarray + :raises ValueError: if new_phase's shape does not match the new qubit count + :return: nothing + :rtype: None + """ new_n_qubits = int(new_table.shape[0]) + if new_phase.shape != (new_n_qubits,): + raise ValueError( + f"new_phase must have shape ({new_n_qubits},), got {new_phase.shape}" + ) self._table = new_table.astype(int) self._phase = new_phase.astype(int) self.n_qubits = new_n_qubits diff --git a/graphiq/backends/state_base.py b/graphiq/backends/state_base.py index e916d0c2..cb9535d2 100644 --- a/graphiq/backends/state_base.py +++ b/graphiq/backends/state_base.py @@ -48,7 +48,7 @@ def __str__(self): :return: string representation of the state representation base class :rtype: str """ - return f"{self.__class__.__name__}\n{self._data}" + return f"{self.__class__.__name__}\n{self.data}" @property def data(self): @@ -74,4 +74,16 @@ def data(self, data): @classmethod def valid_datatype(cls, data): + """ + Whether data is an accepted input for this representation + + This base implementation raises, because the accepted datatypes are a property of + each concrete representation. Every subclass overrides it to return a bool. + + :param data: the candidate data + :type data: any + :raises ValueError: always, on the abstract base class + :return: nothing + :rtype: None + """ raise ValueError(f"Not implemented for abstract (base) class") diff --git a/graphiq/backends/state_rep_conversion.py b/graphiq/backends/state_rep_conversion.py index e8a0e6c7..b1b266e6 100644 --- a/graphiq/backends/state_rep_conversion.py +++ b/graphiq/backends/state_rep_conversion.py @@ -46,6 +46,8 @@ def density_to_graph(input_matrix, threshold=0.1, validate=True): Converts a density matrix state representation to a graph representation via an adjacency matrix. It assumes qubit systems. + A mixed input is decomposed spectrally, and each eigenvector is converted to a graph weighted by its eigenvalue. + :param input_matrix: a density matrix to be converted to a graph :type input_matrix: numpy.ndarray :param threshold: a minimum threshold value of negativity for the state to assume entangled @@ -53,7 +55,8 @@ def density_to_graph(input_matrix, threshold=0.1, validate=True): :param validate: validate the input density is a graph state if True :type validate: bool :raise AssertionError: if input_matrix is not a valid graph state when validate is True - :return: an adjacency matrix representation for a pure state or a list of adjacency matrices for a mixed state + :return: an adjacency matrix representation for a pure state or a list of (weight, adjacency matrix) pairs + for a mixed state :rtype: numpy.ndarray or list[(float, numpy.ndarray)] """ if dmf.is_pure(input_matrix): @@ -62,9 +65,11 @@ def density_to_graph(input_matrix, threshold=0.1, validate=True): # eigen-decomposition eigenvalues, eigenvectors = linalg.eigh(input_matrix) graph_adj = [] - for i in range(len(eigenvectors)): - rho_i = eigenvectors[i] @ np.conjugate(eigenvectors[i].T) - graph_adj.append((eigenvalues, _density_to_graph_pure(rho_i, threshold))) + for i in range(len(eigenvalues)): + # eigh returns the eigenvectors as columns + v = eigenvectors[:, i] + rho_i = np.outer(v, np.conjugate(v)) + graph_adj.append((eigenvalues[i], _density_to_graph_pure(rho_i, threshold))) if validate: assert np.allclose( @@ -116,8 +121,8 @@ def graph_to_density(input_graph): :param input_graph: the graph from which we will build a density matrix :type input_graph: networkx.Graph or numpy.ndarray - :raise TypeError: if input_graph is not of the type of networkx.Graph or a graphiq.backends.graph.state.Graph - or an adjacency matrix given by a numpy array + :raise TypeError: if input_graph is not of the type of networkx.Graph, an adjacency matrix given by a numpy array, + or a list of (probability, graph) pairs :return: a density matrix as a numpy array :rtype: numpy.ndarray """ @@ -144,6 +149,7 @@ def _graph_to_density_pure(graph_data): :param graph_data: the graph from which we will build a density matrix :type graph_data: networkX.Graph or numpy.ndarray + :raises TypeError: if graph_data is neither a networkx.Graph nor a numpy.ndarray :return: a density matrix as a numpy array :rtype: numpy.ndarray """ @@ -176,7 +182,7 @@ def graph_to_stabilizer(input_graph): :type input_graph: networkX.Graph or numpy.ndarray :raise TypeError: if input_graph is not of the type of networkx.Graph or an adjacency matrix given by a numpy array or a list - :return: two binary matrices representing the stabilizer generators + :return: the stabilizer representation, as (probability, StabilizerTableau) pairs :rtype: list[(float, StabilizerTableau)] """ if isinstance(input_graph, list): @@ -197,8 +203,8 @@ def _graph_to_stabilizer_pure(input_graph): :param input_graph: the input graph to be converted to the stabilizer :type input_graph: networkX.Graph or numpy.ndarray :raise TypeError: if input_graph is not of the type of networkx.Graph or an adjacency matrix given by a numpy array - :return: two binary matrices representing the stabilizer generators - :rtype: numpy.ndarray, numpy.ndarray + :return: the stabilizer tableau + :rtype: StabilizerTableau """ if isinstance(input_graph, nx.Graph): adj_matrix = nx.to_numpy_array(input_graph) @@ -223,8 +229,9 @@ def stabilizer_to_graph(input_stabilizer, validate=True): :param validate: validate if input_stabilizer is a graph state when enabled :type validate: bool :raises AssertionError: if input stabilizer is not a graph state when validate is True + :raises ValueError: if input_stabilizer is neither a list nor a StabilizerTableau :return: a graph representation - :rtype: [(float, numpy.ndarray)] + :rtype: list[(float, networkx.Graph)] """ graph_list = [] if isinstance(input_stabilizer, list): @@ -242,8 +249,20 @@ def stabilizer_to_graph(input_stabilizer, validate=True): ) if validate: + # the same state has many generator sets, so compare canonical forms; + # canonical_form works in place, hence the copies + if isinstance(input_stabilizer, list): + input_canonical = [ + (p_i, canonical_form(s_i.copy())) for p_i, s_i in input_stabilizer + ] + else: + input_canonical = canonical_form(input_stabilizer.copy()) + graph_canonical = [ + (p_i, canonical_form(s_i.copy())) + for p_i, s_i in graph_to_stabilizer(graph_list) + ] assert mixed_stabilizer_equivalency( - input_stabilizer, graph_to_stabilizer(graph_list) + input_canonical, graph_canonical ), "Input stabilizer is not a graph state." return graph_list @@ -253,8 +272,16 @@ def stabilizer_to_density(input_stabilizer): """ Convert a stabilizer to a density matrix + A list[str] input carries no signs: every generator is read with a +1 phase, and a sign character + such as "-" is rejected. Only a StabilizerTableau input keeps its phase vector. + :param input_stabilizer: the stabilizer representation - :type input_stabilizer: list[(float, StabilizerTableau)] or StabilizerTableau + :type input_stabilizer: list[(float, StabilizerTableau)] or StabilizerTableau or list[str] + :raises ValueError: if input_stabilizer is a list[str] with a character other than I, X, Y, Z + (case-insensitive), with generators of inconsistent lengths, or whose number of generators differs from + the number of qubits + :raises ValueError: if input_stabilizer is a list whose first element is neither str nor tuple, or if it is + itself neither a list nor a StabilizerTableau :return: a density matrix :rtype: numpy.ndarray """ @@ -264,10 +291,14 @@ def stabilizer_to_density(input_stabilizer): stab = StabilizerTableau([x_matrix, z_matrix]) return _stabilizer_to_density_pure(stab) elif isinstance(input_stabilizer[0], tuple): - rho = 0 + # complex accumulator: a branch containing Y is complex, and adding + # it in place to a real array (from an earlier real branch) raises + n_qubits = input_stabilizer[0][1].n_qubits + rho = np.zeros((2**n_qubits, 2**n_qubits), dtype=complex) for p_i, tableau_i in input_stabilizer: rho += p_i * _stabilizer_to_density_pure(tableau_i) + return rho else: raise ValueError( "Invalid stabilizer input: use either StabilizerTableau or a mixed state representation." @@ -291,10 +322,13 @@ def _stabilizer_to_density_pure(input_stabilizer): """ n_qubits = input_stabilizer.n_qubits generator_string = input_stabilizer.to_labels() - rho = np.eye(2**n_qubits) - for generator in generator_string: + # to_labels() carries no sign, so read it from the phase vector + phase = input_stabilizer.phase + identity = np.eye(2**n_qubits) + rho = identity + for i, generator in enumerate(generator_string): stabilizer_elem = sfu.get_stabilizer_element_by_string(generator) - rho = np.matmul(rho, (stabilizer_elem + np.eye(2**n_qubits)) / 2) + rho = np.matmul(rho, (identity + (-1) ** int(phase[i]) * stabilizer_elem) / 2) return rho @@ -305,22 +339,37 @@ def density_to_stabilizer(input_matrix): This works only for graph states, not for general stabilizer states that are not graph states + A mixed input is decomposed spectrally and each eigenvector with an eigenvalue above 1e-9 is + converted. This is guaranteed to succeed for a mixture of mutually orthogonal graph states + (with an all-zero phase vector) with distinct weights; the eigenvectors of any other mixture + generally are not such graph states, and it is then rejected. + :param input_matrix: a density matrix :type input_matrix: numpy.ndarray + :raise AssertionError: if input_matrix, or an eigenvector of a mixed input, is not a graph state :return: a stabilizer representation :rtype: list[(float, StabilizerTableau)] """ # first check if the state is pure if dmf.is_pure(input_matrix): - graph = _density_to_graph_pure(input_matrix) + # density_to_graph (rather than the unvalidated _density_to_graph_pure) + # so a state that isn't actually a graph state raises instead of + # silently producing the wrong stabilizer - see this function's + # docstring on the graph-state-only restriction + graph = density_to_graph(input_matrix) stabilizer = _graph_to_stabilizer_pure(graph) return [(1.0, stabilizer)] else: # eigen-decomposition eigenvalues, eigenvectors = linalg.eigh(input_matrix) stabilizer = [] - for i in range(len(eigenvectors)): - rho_i = eigenvectors[i] @ np.conjugate(eigenvectors[i].T) + for i in range(len(eigenvalues)): + # a zero-weight eigenvector is an arbitrary null-space vector, not a + # graph state, and does not contribute to the mixture + if eigenvalues[i] <= 1e-9: + continue + v = eigenvectors[:, i] + rho_i = np.outer(v, np.conjugate(v)) graph = density_to_graph(rho_i) tmp_stabilizer = graph_to_stabilizer(graph) stabilizer.append((eigenvalues[i], tmp_stabilizer[0][1])) @@ -413,7 +462,7 @@ def state_to_graph(state): try: graph = nx.from_numpy_array(state) tab = get_stabilizer_tableau_from_graph(graph) - return graph, [], tab + return graph, tab, [] except: raise ValueError( "the input numpy array is not a valid adjacency matrix, try fixing it or using other valid input types" @@ -452,22 +501,13 @@ def _position_finder(x_matrix): :return: list of qubit positions to apply the Hadamard on :rtype: list """ - pivot = [0, 0] - n = x_matrix.shape[0] - pos_list = [] - while pivot[0] < n and pivot[1] < n: - try: - if x_matrix[pivot[0] + 1, pivot[1]] == 1: - pivot = [pivot[0] + 1, pivot[1]] - if x_matrix[pivot[0] + 1, pivot[1] + 1] == 1: - pivot = [pivot[0] + 1, pivot[1] + 1] - else: - pivot = [pivot[0], pivot[1] + 1] - pos_list.append(pivot[1]) - except: - break - - return pos_list + n_row, n_column = x_matrix.shape + pivot_columns = set() + for row in range(n_row): + nonzero_cols = np.nonzero(x_matrix[row])[0] + if len(nonzero_cols): + pivot_columns.add(int(nonzero_cols[0])) + return [col for col in range(n_column) if col not in pivot_columns] def _graph_finder(x_matrix, z_matrix, get_ops_data=False): diff --git a/graphiq/benchmarks/alternate_circuits.py b/graphiq/benchmarks/alternate_circuits.py index 582c37e0..c83e3a75 100644 --- a/graphiq/benchmarks/alternate_circuits.py +++ b/graphiq/benchmarks/alternate_circuits.py @@ -45,7 +45,7 @@ def search_for_alternative_circuits( :param metric_class: a metric class :type metric_class: MetricBase or its subclass :param solver_setting: specifies the setting of a chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting :param random_seed: a random seed :type random_seed: int :return: results are stored in terms of (score, survival probability, circuit) @@ -120,7 +120,7 @@ def report_alternate_circuits(results): A way to print out results :param results: results are stored in terms of (score, survival probability, circuit) - :type results: tuple + :type results: list[tuple(float, float, CircuitDAG)] :return: nothing :rtype: None """ @@ -230,7 +230,7 @@ def exemplary_run(graph, noise_model_mapping, solver_setting=None, random_seed=1 :param noise_model_mapping: a way to map noises to gates :type noise_model_mapping: dict :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :param random_seed: a random seed :type random_seed: int :return: results are stored in terms of (score, survival probability, circuit) @@ -253,7 +253,7 @@ def exemplary_test(graph, noise_model_mapping, solver_setting=None, random_seed= :param noise_model_mapping: a way to map noises to gates :type noise_model_mapping: dict :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :param random_seed: a random seed :type random_seed: int :return: nothing @@ -276,7 +276,7 @@ def exemplary_multiple_test( :param random_numbers: a list of random numbers :type random_numbers: list[int] :param solver_setting: specifies the setting of the chosen solver - :type solver_setting: RandomSearchSolverSetting or EvolutionarySolverSetting + :type solver_setting: EvolutionarySolverSetting or None :return: nothing :rtype: None """ diff --git a/graphiq/benchmarks/benchmark_utils.py b/graphiq/benchmarks/benchmark_utils.py index e8da3b04..7fe3961a 100644 --- a/graphiq/benchmarks/benchmark_utils.py +++ b/graphiq/benchmarks/benchmark_utils.py @@ -24,13 +24,32 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.solvers.evolutionary_solver import EvolutionarySolver -from graphiq import benchmarks as circ +from graphiq.benchmarks import circuits as circ from graphiq.metrics import Infidelity from graphiq.io import IO def circuit_measurement_independent(circuit, compiler): + """ + Check whether the compiled state, after tracing out the last qubit, is the same regardless + of which classical measurement outcome is forced during compilation. + + Compiles the circuit twice, once with the compiler's measurement determinism forced to 0 + and once forced to 1, then restores the compiler's original measurement_determinism + setting. Only compares the two deterministic ("all 0s", "all 1s") resolutions, not every + possible measurement outcome, and only works with a density-matrix compiler/state at the + moment. + + :param circuit: the circuit to compile + :type circuit: CircuitDAG + :param compiler: the compiler to compile it with; its measurement_determinism attribute is + overwritten twice and then restored + :type compiler: DensityMatrixCompiler + :return: whether the two traced-out states are numerically close, and the two traced-out + states themselves + :rtype: tuple[bool, QuantumState, QuantumState] + """ # Note: this doesn't check every option, only "all 0s, all 1s" # TODO: make applicable to other representations. This only works on density matrices at the moment original_measurement_determinism = compiler.measurement_determinism @@ -79,17 +98,34 @@ def benchmark_data( d) Final circuit depth [not done] e) Runtime of solver iteration [not done] - :param solver_class: the solver being benchmarked - :type solver_class: SolverBase - :param targets: a list of (target_circuit, target_state) on which to evaluate - :type targets: list + :param solver_class: the solver being benchmarked: a class, or any callable returning a solver, + that is called with the keyword arguments target, metric, compiler, n_emitter and n_photon + (the last two from the target's "n_emitters" and "n_photons"). The solver must have a hall + of fame, hof, after solve(), whose first entry is the best (score, circuit) pair. + EvolutionarySolver qualifies (use functools.partial to give it a solver_setting). A class + that works out n_emitter and n_photon itself, such as HybridEvolutionarySolver, can be + passed through a callable that drops them, e.g. + lambda n_emitter, n_photon, **kwargs: HybridEvolutionarySolver(**kwargs); for + HybridEvolutionarySolver this works only for targets whose density matrix is itself a + graph state (not the GHZ targets in graphiq.benchmarks.circuits, for example). + TimeReversedSolver has no hof and cannot be benchmarked. + :type solver_class: type[SolverBase] or callable + :param targets: a list of (target_circuit, target) pairs on which to evaluate; target_circuit + is not read, and target is a dict holding "name", "n_emitters", "n_photons" and the target + state under the key target_type + :type targets: list[tuple] :param metric_class: the class of the cost function metric to use in benchmarking :type metric_class: MetricBase - :param compiler: the compiler which the solver should use + :param compiler: the compiler which the solver should use; it must be a density-matrix + compiler, since each best circuit is also checked with circuit_measurement_independent, + which compares density matrices (a StabilizerCompiler raises TypeError there) + :type compiler: DensityMatrixCompiler :param target_type: a string describing the target state representation :type target_type: str :param per_target_retries: the number of times we should reseed and run the same solver problem :type per_target_retries: int + :param seed_offset: added to each retry's index to form the seed passed to the solver + :type seed_offset: int :param save_directory: if None, return data without saving. Else, save data to specified directory :type save_directory: str :return: dataframe summarizing benchmark information + other info @@ -142,12 +178,20 @@ def benchmark_data( n_emitter=target["n_emitters"], n_photon=target["n_photons"], ) + # seed before reading solver_info, whose "seed" field reports it + solver.seed(i + seed_offset) solver_data = solver.solver_info if save_directory and i == 0 and j == 0: - io.save_json(solver_data, "solver_info.json") + # a solver setting object is not JSON serializable; save its description + io.save_json( + { + key: str(value) if key == "solver setting" else value + for key, value in solver_data.items() + }, + "solver_info.json", + ) start_time = time.time() - solver.seed(i + seed_offset) solver.solve() total_time = time.time() - start_time df.loc["runtime (s)", (target["name"], i)] = total_time @@ -164,7 +208,7 @@ def benchmark_data( circuit_data = { "name": target["name"], - "seed": i, + "seed": i + seed_offset, "runtime (s)": total_time, "Measurement independent (T/F)": df.loc[ f"Measurement independent (T/F)", (target["name"], i) @@ -173,7 +217,7 @@ def benchmark_data( } hof_circuits.append(circuit_data) if save_directory is not None: - io.save_json(solver_data, f"circuit_target{j}_retry{i}.json") + io.save_json(circuit_data, f"circuit_target{j}_retry{i}.json") io.save_txt( circuit_data["Circuit description"], f"circuit_description_target{j}_retry{i}.txt", @@ -195,13 +239,18 @@ def benchmark_data( if __name__ == "__main__": + circuit, state = circ.ghz3_state_circuit() target_list = [ - circ.ghz3_state_circuit(), - circ.ghz4_state_circuit(), - circ.linear_cluster_3qubit_circuit(), - circ.linear_cluster_4qubit_circuit(), + ( + circuit, + { + "name": "ghz3", + "dm": state, + "n_emitters": circuit.n_emitters, + "n_photons": circuit.n_photons, + }, + ) ] - target_list = [circ.ghz3_state_circuit()] compiler = DensityMatrixCompiler() compiler.measurement_determinism = 1 df = benchmark_data( diff --git a/graphiq/benchmarks/circuits.py b/graphiq/benchmarks/circuits.py index 6eba20da..fb01bde9 100644 --- a/graphiq/benchmarks/circuits.py +++ b/graphiq/benchmarks/circuits.py @@ -37,6 +37,10 @@ def bell_state_circuit(): """ Two-qubit Bell state preparation circuit + + :return: the circuit that prepares the Bell state, and the ideal Bell state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(2 * [state_ketz0()]) + tensor(2 * [state_ketz1()]))) / 2.0 ideal_state = QuantumState(rho, rep_type="dm") @@ -49,6 +53,10 @@ def bell_state_circuit(): def ghz3_state_circuit(): """ Three-qubit GHZ state + + :return: the circuit that prepares the 3-qubit GHZ state, and the ideal GHZ state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(3 * [state_ketz0()]) + tensor(3 * [state_ketz1()]))) / 2.0 ideal_state = QuantumState(rho, rep_type="dm") @@ -84,6 +92,10 @@ def ghz3_state_circuit(): def ghz4_state_circuit(): """ Four-qubit GHZ state + + :return: the circuit that prepares the 4-qubit GHZ state, and the ideal GHZ state as a QuantumState in density + matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ rho = ket2dm((tensor(4 * [state_ketz0()]) + tensor(4 * [state_ketz1()]))) / 2.0 @@ -122,6 +134,10 @@ def ghz4_state_circuit(): def linear_cluster_3qubit_circuit(): """ Three-qubit linear cluster state + + :return: the circuit that prepares the 3-qubit linear cluster state, and the ideal linear cluster state as a + QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ graph = nx.Graph([(1, 2), (2, 3)]) state = DensityMatrix.from_graph(graph) @@ -151,6 +167,10 @@ def linear_cluster_3qubit_circuit(): def linear_cluster_4qubit_circuit(): """ Four-qubit linear cluster state + + :return: the circuit that prepares the 4-qubit linear cluster state, and the ideal linear cluster state as a + QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ graph = nx.Graph([(1, 2), (2, 3), (3, 4)]) state = DensityMatrix.from_graph(graph) @@ -184,6 +204,9 @@ def variational_entangling_layer_2qubit(): """ Variational circuit composed of one local rotation and entangling rotation gate, with the ideal state being a Bell state. + + :return: the circuit, and the ideal Bell state as a QuantumState in density matrix representation + :rtype: tuple[CircuitDAG, QuantumState] """ ket = dmf.tensor([dmf.state_ketz0(), dmf.state_ketz0()]) + dmf.tensor( [dmf.state_ketz1(), dmf.state_ketz1()] @@ -204,7 +227,7 @@ def variational_entangling_layer_2qubit(): def strongly_entangling_layer(n_qubits=3, layers=1): """ - Variational circuit composed of a single entangling layer. Ideal state is an n-qubit GHZ state. + Variational circuit composed of ``layers`` entangling layers. Ideal state is an n-qubit GHZ state. """ ket = ( diff --git a/graphiq/benchmarks/graph_states.py b/graphiq/benchmarks/graph_states.py index e54f360e..9cd3d7e3 100644 --- a/graphiq/benchmarks/graph_states.py +++ b/graphiq/benchmarks/graph_states.py @@ -43,7 +43,7 @@ def linear_cluster_state(n_qubits): def lattice_cluster_state(dimension): """ - Generates an n_side by n_side cluster state lattice + Generates a cluster state lattice with the number of qubits along each dimension given by dimension :param dimension: a tuple containing the number of qubits along each dimension :type dimension: tuple @@ -101,7 +101,9 @@ def _gnp_random_connected_graph(n, p, np_rng): :type n: int :param p: the probability of an edge being added :type p: float - :return: + :param np_rng: the random number generator used to choose the edges + :type np_rng: numpy.random.Generator + :return: a random graph on n nodes; p <= 0 gives the graph with no edges and p >= 1 the complete graph :rtype: networkx.Graph """ edges = itertools.combinations(range(n), 2) @@ -259,7 +261,7 @@ def crazy(n_list: list, pos=False, alternate_order=False): edges.extend([(x, y) for x in current_nodes for y in next_col_nodes]) g.add_edges_from(edges) if alternate_order: - from src.utils.relabel_module import relabel + from graphiq.utils.relabel_module import relabel nodes = [] for k in range(0, len(n_list), 2): @@ -272,7 +274,7 @@ def crazy(n_list: list, pos=False, alternate_order=False): g = nx.from_numpy_array(new_adj) pos_dict2 = {} for i, n in enumerate(range(len(pos_dict))): - pos_dict2[n] = pos_dict[nodes[i]] + pos_dict2[nodes[i]] = pos_dict[i] pos_dict = pos_dict2 if pos: return g, pos_dict @@ -286,7 +288,8 @@ def two_d_cluster(row_col: tuple, pos=False): :param row_col: a tuple of number of rows and columns in the cluster state :param pos: if True the function also returns the position dictionary for the graph - :return: a networkx graph that represents the crazy graph or a tuple including the graph and its node-position dict + :return: a networkx graph that represents the 2D cluster state or a tuple including the graph and its node-position + dict :rtype: networkx.Graph or (networkx.Graph, pos_dict) """ g = nx.Graph() @@ -331,7 +334,8 @@ def three_d_cluster(row_col_depth: tuple, pos=False): :param row_col_depth: a tuple of number of rows and columns and depth of the cluster state :param pos: if True the function also returns the position dictionary for the graph - :return: a networkx graph that represents the crazy graph or a tuple including the graph and its node-position dict + :return: a networkx graph that represents the 3D cluster state or a tuple including the graph and its node-position + dict :rtype: networkx.Graph or (networkx.Graph, pos_dict) """ g = nx.Graph() diff --git a/graphiq/benchmarks/lc_equivalence.py b/graphiq/benchmarks/lc_equivalence.py index c6a1818f..e3035ccd 100644 --- a/graphiq/benchmarks/lc_equivalence.py +++ b/graphiq/benchmarks/lc_equivalence.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Demo scripts that build a graph, draw it and graphs reached from it by local +complementation, and print an alternate-circuit search report for each graph drawn. Run this +module directly to execute whichever example is left uncommented under ``__main__``. +""" + import matplotlib.pyplot as plt from graphiq.benchmarks.alternate_circuits import ( exemplary_run, @@ -21,14 +27,57 @@ from graphiq.utils.preprocessing import * +def _first_distinct_graph(candidates, graph): + """ + Return the first candidate graph whose edge set differs from ``graph``'s. + + The LC heuristics in ``graphiq.utils.preprocessing`` seed their candidate list + with the input graph, so when the input ties on the metric (e.g. K(3, 3)) + ``candidates[0][1]`` is the input itself. + + :param candidates: (metric value, graph) pairs, lowest metric value first + :type candidates: list + :param graph: the graph the candidates were generated from + :type graph: networkx.Graph + :raises ValueError: if no candidate's edge set differs from ``graph``'s + :return: the first candidate whose edge set differs from ``graph``'s + :rtype: networkx.Graph + """ + reference = set(map(frozenset, graph.edges())) + for _, candidate in candidates: + if set(map(frozenset, candidate.edges())) != reference: + return candidate + raise ValueError("no candidate graph differs from the input graph") + + def run_multiple_graphs_and_report(graph, lc_nodes): + """ + Draw the given graph and, for each node in lc_nodes, the graph reached from it by one local + complementation at that node, and print an alternate-circuit search report for each, so + ``1 + len(lc_nodes)`` searches and reports in all. + + Each local complementation returns a graph labelled 0..n-1 by position. + + Each local complementation is computed from the original graph, not cumulatively, so + lc_nodes describes a fan out from the one graph rather than a chain. Every graph drawn is + the graph its accompanying report describes. + + :param graph: the graph to draw and search + :type graph: networkX.Graph + :param lc_nodes: the nodes to apply a local complementation to, one at a time, given as positions in graph's + node iteration order (local_comp_graph reads them so); these are the node labels when graph's nodes are + 0..n-1 in iteration order, as in every example in this module + :type lc_nodes: list + :return: nothing + :rtype: None + """ results = [exemplary_run(graph, None)] graphs = [graph] for node in lc_nodes: alt_graph = local_comp_graph(graph, node) graphs.append(alt_graph) - results.append(exemplary_run(graph, None)) + results.append(exemplary_run(alt_graph, None)) for i in range(len(results)): ax = plt.axes() nx.draw(graphs[i], ax=ax, with_labels=True) @@ -36,35 +85,88 @@ def run_multiple_graphs_and_report(graph, lc_nodes): def linear_4_example(): + """ + Run and report on a 4-node linear cluster graph (path 0-1-2-3) and the local + complementations of it at nodes 1 and 2. + + :return: nothing + :rtype: None + """ graph = nx.Graph([(0, 1), (1, 2), (2, 3)]) run_multiple_graphs_and_report(graph, [1, 2]) def square_4_example(): + """ + Run and report on a 4-node graph (a 4-cycle 0-1-3-2-0 plus the diagonal edge 1-2) and the + local complementations of it at nodes 0, 1 and 2. + + :return: nothing + :rtype: None + """ graph = nx.Graph([(0, 1), (0, 2), (2, 3), (1, 3), (1, 2)]) - run_multiple_graphs_and_report(graph, [0, 1, 2, 3]) + # nodes 0 and 3 share the neighbourhood {1, 2}, so LC(0) == LC(3) + run_multiple_graphs_and_report(graph, [0, 1, 2]) def repeater_graph_example(n_inner_qubits): + """ + Run and report on a repeater graph state with n_inner_qubits inner qubits, and the local + complementation of it at node 1. + + :param n_inner_qubits: number of qubits in the repeater graph's inner layer + :type n_inner_qubits: int + :return: nothing + :rtype: None + """ graph = repeater_graph_states(n_inner_qubits) run_multiple_graphs_and_report(graph, [1]) def star_graph_example(n_points): + """ + Run and report on a star graph with n_points leaves attached to the central node, and the + local complementation of it at the central node (0). + + :param n_points: number of leaf nodes attached to the central node + :type n_points: int + :return: nothing + :rtype: None + """ graph = nx.star_graph(n_points) - run_multiple_graphs_and_report(graph, [0, 1]) + # LC at a degree-1 leaf is the identity; only the centre changes the graph + run_multiple_graphs_and_report(graph, [0]) def biclique_example(n_pairs): - edges = [] - for i in range(n_pairs): - for j in range(n_pairs): - edges.append((i, j + n_pairs)) - graph = nx.Graph(edges) - run_multiple_graphs_and_report(graph, [0, 1, n_pairs]) + """ + Run and report on the complete bipartite graph K(n_pairs, n_pairs) (nodes 0..n_pairs-1 on one + side, n_pairs..2*n_pairs-1 on the other, every cross edge present), and the local + complementations of it at nodes 0 and n_pairs. + + :param n_pairs: number of nodes on each side of the bipartite graph + :type n_pairs: int + :return: nothing + :rtype: None + """ + # built by the generator so nodes are inserted in ascending order: the local + # complementation indexes nodes by adjacency-matrix position, and an edge + # list inserts them as 0, n_pairs, ... and complements the wrong ones. + # Nodes on one side share a neighbourhood, so LC(0) == LC(1). + graph = nx.complete_bipartite_graph(n_pairs, n_pairs) + run_multiple_graphs_and_report(graph, [0, n_pairs]) def imperfect_repeater_graph_example(): + """ + Draw and report on two 11-node graphs that share a hub node (2) linked to five inner nodes + (1, 4, 6, 8, 10), each inner node carrying one pendant leaf (0, 3, 5, 7, 9 respectively): the + first also has a clique on the five inner nodes, the second omits it. Neither is a repeater + graph state, which has an even number of nodes. + + :return: nothing + :rtype: None + """ edges_set1 = [(2, 1), (2, 4), (2, 6), (2, 8), (2, 10)] edges_set1 += [(0, 1), (3, 4), (5, 6), (7, 8), (9, 10)] edges_set2 = edges_set1.copy() @@ -83,41 +185,87 @@ def imperfect_repeater_graph_example(): def heuristic_example1(): + """ + Draw and report on the complete graph on 5 nodes, then on a graph reached from it by the + max-edge local-complementation heuristic. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + used here is the first one differing from the input (see ``_first_distinct_graph``). All ten + retained candidates score -2 on ``graph_max_eccentricity``, so which one is drawn carries no + meaning beyond being one of the lowest-scoring set. + + :return: nothing + :rtype: None + """ graph = nx.complete_graph(5) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) report_alternate_circuits(result) - graph2 = get_lc_graph_by_max_edge(graph, 10) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_edge(graph, 10, graph_metric_lists[0]), graph + ) nx.draw(graph2, with_labels=True) result = exemplary_run(graph2, None) report_alternate_circuits(result) def heuristic_example2(): + """ + Draw and report on the complete bipartite graph K(3, 3), then on one further local + complementation of the graph the max-edge heuristic selects -- the lines that would draw and + search the heuristic's own graph are commented out. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + taken from it here is the first one differing from K(3, 3) itself (see + ``_first_distinct_graph``): K(3, 3) ties with all its candidates at -2, so pair 0 is the + input unchanged. That graph is ``LC(K(3, 3), 0)``, so the further complementation is at + node 3; complementing at node 0 again would return K(3, 3) itself. + + :return: nothing + :rtype: None + """ graph = nx.complete_bipartite_graph(3, 3) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) report_alternate_circuits(result) - graph2 = get_lc_graph_by_max_edge(graph, 3) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]), graph + ) # nx.draw(graph2, with_labels=True) # result = exemplary_run(graph2, None) # report_alternate_circuits(result) - graph3 = local_comp_graph(graph2, 0) + # graph2 is LC(K(3, 3), 0); complementing at node 0, 1 or 2 again would + # return K(3, 3) itself, while node 3 gives a distinct graph + graph3 = local_comp_graph(graph2, 3) nx.draw(graph3, with_labels=True) result = exemplary_run(graph3, None) report_alternate_circuits(result) def heuristic_example3(): + """ + Draw and report on the complete bipartite graph K(3, 3), then on a graph reached from it by + the max-neighbor-edge local-complementation heuristic. + + The heuristic returns (metric value, graph) pairs, lowest metric value first, and the graph + used here is the first one differing from K(3, 3) itself (see ``_first_distinct_graph``): + K(3, 3) ties with all its candidates at -2, so pair 0 is the input unchanged. Which distinct + candidate is used carries no meaning beyond being one of the lowest-scoring set. + + :return: nothing + :rtype: None + """ graph = nx.complete_bipartite_graph(3, 3) nx.draw(graph, with_labels=True) result = exemplary_run(graph, None) report_alternate_circuits(result) # graph2 = candidate_graphs(graph, 3) - graph2 = get_lc_graph_by_max_neighbor_edge(graph, 3) + graph2 = _first_distinct_graph( + get_lc_graph_by_max_neighbor_edge(graph, 3, graph_metric_lists[0]), graph + ) nx.draw(graph2, with_labels=True) result = exemplary_run(graph2, None) report_alternate_circuits(result) diff --git a/graphiq/benchmarks/pipeline.py b/graphiq/benchmarks/pipeline.py index 94a0ac7c..b487eca2 100644 --- a/graphiq/benchmarks/pipeline.py +++ b/graphiq/benchmarks/pipeline.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Utilities for assembling and running a solver benchmarking pipeline: combining solvers, targets, +compilers and metrics into individual runs, executing those runs (optionally in parallel with +ray), and saving the resulting data. +""" + import pandas as pd import time import psutil @@ -27,6 +33,13 @@ def metadata(): + """ + Collect metadata about the machine running the benchmark. + + :return: a dictionary with the system name, OS version, machine architecture, processor, and + CPU count + :rtype: dict + """ uname = platform.uname() md = dict( system=uname.system, @@ -42,15 +55,19 @@ def run_combinations(solvers, targets, compilers, metrics): """ Produces a benchmarking run for all possible combinations from lists of solvers, targets, compilers, and metrics. - :param solvers: a list of Solver classes (not instanced) - :type solvers: list - :param targets: a list of the target states - :type targets: list - :param compilers: list of compilers to use + :param solvers: a list of (Solver, solver_attr) pairs, where Solver is a solver class (not instanced) and + solver_attr is a dictionary of extra keyword arguments passed to its constructor + :type solvers: list[tuple] + :param targets: a list of (target, target_attr) pairs, where target is the target state and target_attr is a + dictionary providing the "n_photon" and "n_emitter" values passed to the solver + :type targets: list[tuple] + :param compilers: a list of compiler classes (not instanced); each is instantiated with no arguments :type compilers: list - :param metrics: list of metrics to use + :param metrics: a list of metric classes (not instanced); each is instantiated as Metric(target=target) :type metrics: list - :return: a list of dictionaries, each defining a single benchmarking run + :return: a dictionary mapping each run name ("run0", "run1", ...) to a dictionary defining a single + benchmarking run + :rtype: dict """ runs = {} @@ -94,7 +111,9 @@ def benchmark_run(run: dict, name: str, io: IO): t0 = time.time() run["solver"].io = io - run["solver"].save_openqasm = "both" + # save_openqasm is a setting; a solver without a setting saves no openQASM + if hasattr(run["solver"], "setting"): + run["solver"].setting.save_openqasm = "both" run["solver"].solve() t1 = time.time() @@ -110,7 +129,7 @@ def benchmark_run(run: dict, name: str, io: IO): path=io.path.name, solver=run["solver"].__class__.__name__, compiler=run["compiler"].__class__.__name__, - metric=run["compiler"].__class__.__name__, + metric=run["metric"].__class__.__name__, # target=run["target"], # TODO: ideally the target is a QuantumState object, and we can use the __name__ here time=(t1 - t0), circuit_cost=solver.hof[0][0], @@ -123,8 +142,9 @@ def benchmark(runs: dict, io: IO, remote=True): """ Runs a sequence of benchmark runs, either using parallel or serial processing. - :param runs: a list of runs, each being a dictionary containing the important class instances (solvers, etc.) - :type: list + :param runs: a dictionary mapping each run name to a run, each being a dictionary containing the important + class instances (solvers, etc.) + :type runs: dict :param io: a top-level IO object for saving benchmark data :type io: IO instance :param remote: True/False flag. If True, will run solvers in parallel using `ray` @@ -178,7 +198,7 @@ def benchmark_run_graph_search_solver(run, name, io, circuit_format="qasm"): :param circuit_format: a variable define what format the circuit will be saved to :type circuit_format: str :return: dictionary storing all the results/metadata for the current run (can be added to a DataFrame) - :rtype: pandas.DataFrame + :rtype: dict """ solver = run["solver"] t0 = time.time() @@ -220,12 +240,15 @@ def benchmark_run_graph_search_solver(run, name, io, circuit_format="qasm"): def benchmark_graph_search_solver(runs, io, circuit_format="qasm"): """ - Runs a sequence of benchmark runs, either using parallel or serial processing. + Runs a sequence of benchmark runs serially. - :param runs: a list of runs, each being a dictionary containing the important class instances (solvers, etc.) - :type: list + :param runs: a dictionary mapping each run name to a run, each being a dictionary containing the important + class instances (solvers, etc.) + :type runs: dict :param io: a top-level IO object for saving benchmark data :type io: IO instance + :param circuit_format: the format each run's circuits are saved in, either "qasm" or "json" + :type circuit_format: str :return: DataFrame summarizing the results of all benchmarking runs """ futures = [] diff --git a/graphiq/benchmarks/solvers.py b/graphiq/benchmarks/solvers.py index 569e51bc..6cf086dd 100644 --- a/graphiq/benchmarks/solvers.py +++ b/graphiq/benchmarks/solvers.py @@ -17,10 +17,12 @@ """ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.metrics import Infidelity from graphiq.io import IO -from graphiq.visualizers.solver_logs import plot_solver_logs from graphiq.benchmarks.circuits import ghz3_state_circuit, ghz4_state_circuit from graphiq.benchmarks.pipeline import benchmark, run_combinations @@ -28,14 +30,28 @@ if __name__ == "__main__": # %% provide all combinations of solvers, targets, compilers, and metrics to run as a list solvers = [ - (EvolutionarySolver, dict(n_stop=30, n_pop=80, n_hof=10, tournament_k=2)), - (EvolutionarySolver, dict(n_stop=30, n_pop=100, n_hof=10, tournament_k=2)), + ( + EvolutionarySolver, + dict( + solver_setting=EvolutionarySolverSetting( + n_stop=30, n_pop=80, n_hof=10, tournament_k=2 + ) + ), + ), + ( + EvolutionarySolver, + dict( + solver_setting=EvolutionarySolverSetting( + n_stop=30, n_pop=100, n_hof=10, tournament_k=2 + ) + ), + ), ] # provide a list of targets targets = [ - (ghz3_state_circuit()[1]["dm"], dict(name="ghz3", n_photon=3, n_emitter=1)), - (ghz4_state_circuit()[1]["dm"], dict(name="ghz4", n_photon=4, n_emitter=1)), + (ghz3_state_circuit()[1], dict(name="ghz3", n_photon=3, n_emitter=1)), + (ghz4_state_circuit()[1], dict(name="ghz4", n_photon=4, n_emitter=1)), ] compilers = [DensityMatrixCompiler] @@ -50,5 +66,3 @@ ) df = benchmark(runs=runs, io=io, remote=True) print(df) - - plot_solver_logs() diff --git a/graphiq/circuit/circuit_base.py b/graphiq/circuit/circuit_base.py index ca5d6764..dc49354d 100644 --- a/graphiq/circuit/circuit_base.py +++ b/graphiq/circuit/circuit_base.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Base class (interface) that every circuit representation implements. +""" + import copy import random import warnings @@ -62,6 +66,12 @@ def __init__(self, openqasm_imports=None, openqasm_defs=None): @property def register(self): + """ + The register dictionary of the circuit, keyed by register type + + :return: a shallow copy of the register dictionary + :rtype: dict + """ return self._registers.register @property @@ -295,7 +305,7 @@ def _add_register(self, reg_type: str, size=1): :return: the index number of the added register :rtype: int """ - self._registers.add_register(reg_type, size) + return self._registers.add_register(reg_type, size) def expand_emitter_register(self, register, new_size): """ @@ -413,8 +423,17 @@ def parameters(self): def parameters(self, parameters: dict): self._parameters = parameters - for op in self.sequence(unwrapped=True): - op.params = self._parameters.get(self.map[id(op)], tuple()) + sequence = self.sequence(unwrapped=True) + # the map is built when parameters are initialised; ops added (or copied) + # since then are not in it, so rebuild it the way initialize_parameters does + # (a OneQubitGateWrapper's sub-gates are new objects on every unwrap, so a + # circuit with a wrapper rebuilds the map on every call) + if any(id(op) not in self._map for op in sequence): + self._map = self._fmap() + for op in sequence: + # a wrapper's sub-gates are new objects on every unwrap and are never in + # the map; they carry no parameters + op.params = self._parameters.get(self._map.get(id(op)), tuple()) @property def fmap(self): @@ -444,7 +463,12 @@ def map(self): return self._map def _default_map(self): - """Default map, in which each op is mapped to itself (no parameter sharing, except for copied ops)""" + """ + Default map, in which each op is mapped to itself (no parameter sharing, except for copied ops) + + :return: a mapping from the id of each op in the unwrapped sequence to itself + :rtype: dict + """ _map = {id(op): id(op) for op in self.sequence(unwrapped=True)} return _map diff --git a/graphiq/circuit/circuit_dag.py b/graphiq/circuit/circuit_dag.py index c02e84de..43fa5735 100644 --- a/graphiq/circuit/circuit_dag.py +++ b/graphiq/circuit/circuit_dag.py @@ -38,6 +38,7 @@ import graphiq.circuit.ops as ops from graphiq.circuit.circuit_base import CircuitBase +from graphiq.noise.noise_mapping import noise_for_op, register_section from graphiq.noise.noise_models import NoNoise from graphiq.utils.circuit_comparison import compare_circuits from graphiq.visualizers.dag import dag_topology_pos @@ -71,6 +72,12 @@ def __init__( :type n_photon: int :param n_classical: the number of classical bits in the system :type n_classical: int + :param openqasm_imports: None or an (ordered) dictionary, where the keys are import strings for openqasm + (the values are not used) + :type openqasm_imports: a dictionary (with str keys) or None + :param openqasm_defs: None or an (ordered) dictionary, where the keys are definitions strings for openqasm gates + (the values are not used) + :type openqasm_defs: a dictionary (with str keys) or None :return: nothing :rtype: None """ @@ -333,7 +340,9 @@ def get_node_by_labels(self, labels): """ remaining_nodes = set(self.dag.nodes) for label in labels: - remaining_nodes = remaining_nodes.intersection(set(self.node_dict[label])) + remaining_nodes = remaining_nodes.intersection( + set(self.node_dict.get(label, [])) + ) return list(remaining_nodes) def get_node_exclude_labels(self, labels): @@ -348,7 +357,7 @@ def get_node_exclude_labels(self, labels): all_nodes = set(self.dag.nodes) exclusion_nodes = set() for label in labels: - exclusion_nodes = exclusion_nodes.union(set(self.node_dict[label])) + exclusion_nodes = exclusion_nodes.union(set(self.node_dict.get(label, []))) return list(all_nodes - exclusion_nodes) def remove_op(self, node): @@ -505,7 +514,8 @@ def draw_dag(self, show=True, fig=None, ax=None): :param show: if True, the DAG is displayed (shown). If False, the DAG is drawn but not displayed :type show: bool - :param fig: fig on which to draw the DAG (optional) + :param fig: fig on which to draw the DAG (optional); without ax, the DAG is drawn on fig's current axes, + which is created if fig has none :type fig: None or matplotlib.pyplot.figure :param ax: ax on which to draw the DAG (optional) :type ax: None or matplotlib.pyplot.axes @@ -515,8 +525,14 @@ def draw_dag(self, show=True, fig=None, ax=None): # TODO: fix this such that we can see double edges properly! pos = dag_topology_pos(self.dag, method="topology") - if ax is None or fig is None: - fig, ax = plt.subplots() + if ax is None: + if fig is None: + fig, ax = plt.subplots() + else: + ax = fig.gca() + elif fig is None: + # ax.figure is a SubFigure when ax sits in one; its .figure is the root Figure. + fig = ax.figure.figure nx.draw(self.dag, pos=pos, ax=ax, with_labels=True) if show: plt.show() @@ -669,16 +685,20 @@ def _parse_if(command): if gate_class is not None: circuit.add(gate_class(register=reg, reg_type=reg_type)) else: - circuit_list = [ops.name_to_class_map(letter) for letter in name] - assert None not in circuit_list, ( - f"Gate not recognized, parsing invalid/" - f"{name} parsed to {circuit_list}" - ) - circuit.add( - ops.OneQubitGateWrapper( - circuit_list, register=reg, reg_type=reg_type + # a OneQubitGateWrapper writes its gates' names with no separator, and + # some names are several characters long (e.g. "sdg") + circuit_list = ops._decode_wrapped_gate_name(name) + assert ( + circuit_list is not None + ), f"Gate not recognized, parsing invalid/{name}" + if len(circuit_list) == 1: + circuit.add(circuit_list[0](register=reg, reg_type=reg_type)) + else: + circuit.add( + ops.OneQubitGateWrapper( + circuit_list, register=reg, reg_type=reg_type + ) ) - ) elif command.count("[0]") == 2: # two-qubit gate command_breakdown = command.split() name = command_breakdown[0] @@ -715,16 +735,19 @@ def _add_register(self, reg_type: str, size=1): :param reg_type: 'e' to add an emitter qubit register, 'p' to add a photonic qubit register, 'c' to add a classical register :type reg_type: str - :return: function returns nothing - :rtype: None + :param size: the size of the register to add; this circuit class only supports registers of size 1 + :type size: int + :raises ValueError: if size is not 1 + :return: the index of the added register + :rtype: int """ if size != 1: raise ValueError( f"_add_register for this circuit class must only add registers of size 1" ) - self._add_reg_if_absent( - register=len(self._registers[reg_type]), reg_type=reg_type - ) + register = len(self._registers[reg_type]) + self._add_reg_if_absent(register=register, reg_type=reg_type) + return register def _add_reg_if_absent(self, register, reg_type): """ @@ -957,7 +980,7 @@ def calculate_reg_depth(self, reg_type: str): :param reg_type: str indicates register type. Can be "e", "p", or "c" :type reg_type: str :return: the array of register depth of all register in the register type - :rtype: numpy.array + :rtype: list[int] """ if reg_type not in self._register_depth: raise ValueError(f"register type {reg_type} is not in this circuit") @@ -1020,6 +1043,13 @@ def to_json(self): """ Function to convert circuit object to json data format. + .. warning:: + Operations for which ``ops.class_to_name_mapping`` returns None do not round-trip. These include + the parameterized gates (e.g. RX, RY, RZ, ParameterizedOneQubitRotation, + ParameterizedControlledRotationQubit) as well as MeasurementZ, PhaseDagger and Identity. Such an + operation is written with type None, or dropped from the op_list of a OneQubitGateWrapper, and gate + parameters are not stored. from_json then raises TypeError on a type None entry. + :return: circuit json object :rtype: dict """ @@ -1065,8 +1095,8 @@ def from_json(cls, data_dict): :param data_dict: circuit json dict :type data_dict: dict - :return: nothing - :rtype: None + :return: the circuit built from data_dict + :rtype: CircuitDAG """ circuit = CircuitDAG( n_photon=data_dict["n_photons"], @@ -1085,9 +1115,10 @@ def from_json(cls, data_dict): else: gate = ops.name_to_class_map(op["type"]) gate = gate() - gate.q_registers = op["q_registers"] - gate.q_registers_type = op["q_registers_type"] - gate.c_registers = op["c_registers"] + # a real JSON dump/load turns these tuples into lists + gate.q_registers = tuple(op["q_registers"]) + gate.q_registers_type = tuple(op["q_registers_type"]) + gate.c_registers = tuple(op["c_registers"]) circuit.add(gate) @@ -1154,8 +1185,16 @@ def assign_noise(self, noise_model_map): """ Create a copy of the circuit where each gate is appended its noise model - :param noise_model_map: - :type noise_model_map: + :param noise_model_map: a nested dictionary whose outer keys are register-type sections ("e", "p", "ee", + "ep", ...) and whose inner dictionaries map an operation's class name to its noise model; for a + two-qubit operation, the section is its control type followed by its target type, and its per-leg keys + ``_control`` and ``_target`` are read first; only when neither is present does the bare class name + apply, and its noise model may then be a list of two (one per leg). Operations without an entry get + NoNoise. A section's ``"OneQubitGateWrapper"`` key gives each wrapper in it that key's value (a + single model for the whole wrapper, or a list with one per wrapped gate), and the section's per-gate + keys are then not read for wrappers. A OneQubitGateWrapper carrying a single model for the whole + wrapper keeps it, and the mapping is not applied to it + :type noise_model_map: dict[str, dict] :return: a new circuit :rtype: CircuitDAG """ @@ -1170,50 +1209,75 @@ def assign_noise(self, noise_model_map): return empty_circ def _noisy_gates(self, noise_model_map): - seq = self._slim_seq() + """ + Helper function that returns the circuit's operations with a noise model attached to each + + Operates on a copy, so the circuit itself is not modified. A wrapper carrying a single model for the whole + wrapper keeps it and is left unchanged. Otherwise, when the wrapper's section has a ``"OneQubitGateWrapper"`` + key, the wrapper receives that value as it is, except that a NoNoise becomes one NoNoise per wrapped gate, as + in the wrapper's constructor; this is the noise ``SolverBase._wrap_noise`` gives a wrapper. A single model + under the key is applied once for the whole wrapper by ``OneQubitGateWrapper.unwrap``, which reads its "After + gate" parameter, so a model without one (not an addition-type model) raises KeyError when the circuit is + compiled. Without the key, the wrapper receives a list with one noise model per wrapped gate, in the order of + its ``operations``, since the wrapper pairs ``noise[i]`` with ``operations[i]``. + A controlled-pair operation receives its ``_control`` and ``_target`` models, or, when neither key is + present, its bare name's list of two models or its single model duplicated. Every other operation receives + the model its register section names. Operations without an entry get NoNoise. The lookup is + ``graphiq.noise.noise_mapping.noise_for_op``, which ``SolverBase._identify_noise`` also uses. + + :param noise_model_map: a nested dict of register-type section to operation class name to noise model, as + accepted by ``assign_noise`` + :type noise_model_map: dict[str, dict] + :raises ValueError: if a controlled-pair operation's bare noise list does not have length 2 + :raises KeyError: if noise_model_map has no entry for an operation's register-type section + :return: the operations (Input and Output excluded), each carrying its noise model + :rtype: list[ops.OperationBase] + """ + # copy first: the noise is set on the ops in place, and these must not + # be this circuit's own ops + seq = self.copy()._slim_seq() noisy_ops = [] for op in seq: - is_controlled = False if isinstance(op, ops.OneQubitGateWrapper): - op_type_seq = [type(gate) for gate in op.unwrap()] - noise_list = self._find_wrapped_noise( - op_type_seq, noise_model_map[op.reg_type] - ) - op.noise = noise_list + if not isinstance(op.noise, list): + # a single model for the whole wrapper is left as it is + noisy_ops.append(op) + continue + section = noise_model_map[op.reg_type] + if "OneQubitGateWrapper" in section: + # the key's value for the whole wrapper, as + # SolverBase._wrap_noise gives it + noise = section["OneQubitGateWrapper"] + # as in the wrapper's constructor, a single NoNoise becomes + # one per wrapped gate + if isinstance(noise, NoNoise): + noise = len(op.operations) * [NoNoise()] + op.noise = noise + else: + op_type_seq = list(op.operations) + op.noise = self._find_wrapped_noise(op_type_seq, section) noisy_ops.append(op) else: - if isinstance( - op, - ( - ops.ControlledPairOperationBase, - ops.ClassicalControlledPairOperationBase, - ), - ): - control_type = op.control_type - target_type = op.target_type - mapping = noise_model_map[control_type + target_type] - is_controlled = True - else: - mapping = noise_model_map[op.reg_type] - name = type(op).__name__ - if name in mapping: - noise_object = mapping[name] - else: - noise_object = NoNoise() - if is_controlled: - if isinstance(noise_object, list): - assert ( - len(noise_object) == 2 - ), "controlled gate noise list must be of length 2" - op.noise = noise_object - else: - op.noise = [noise_object, noise_object] - else: - op.noise = noise_object + mapping = noise_model_map[register_section(op)] + op.noise = noise_for_op(op, mapping) noisy_ops.append(op) return noisy_ops def _find_wrapped_noise(self, op_type_list, mapping): + """ + Helper function that looks up one noise model per gate wrapped inside a OneQubitGateWrapper + + A gate whose class name is absent from the mapping gets NoNoise, so the returned list always has the same + length as op_type_list. + + :param op_type_list: the classes of the wrapped gates; ``_noisy_gates`` passes the wrapper's ``operations``, + in constructor order + :type op_type_list: list[type] + :param mapping: one section of a noise model map, keyed by operation class name + :type mapping: dict + :return: a noise model for each entry of op_type_list, in the same order + :rtype: list[graphiq.noise.noise_models.NoiseBase] + """ noise_list = [] for op_type in op_type_list: op_name = op_type.__name__ @@ -1224,6 +1288,12 @@ def _find_wrapped_noise(self, op_type_list, mapping): return noise_list def _slim_seq(self): + """ + Helper function that returns the operation sequence with the Input and Output placeholders removed + + :return: the operations of the circuit, in topological order, with wrappers not unwrapped + :rtype: list[ops.OperationBase] + """ seq = self.sequence() length = len(seq) - 1 for i, op in enumerate(seq[::-1]): diff --git a/graphiq/circuit/ops.py b/graphiq/circuit/ops.py index 08af9fab..4440552f 100644 --- a/graphiq/circuit/ops.py +++ b/graphiq/circuit/ops.py @@ -78,8 +78,8 @@ def __init__( of registers the gate requires :type q_registers: tuple (tuple of: integers OR tuples of length 2) :param q_registers_type: tuple of strings, each is either 'p' (photonic qubit) or 'e' (emitter qubit) - :type q_registers: tuple (of str) - :param c_registers: same as photon/emitter_registers, but for the classical registers + :type q_registers_type: tuple (of str) + :param c_registers: same as q_registers, but for the classical registers :type c_registers: tuple (tuple of: integers OR tuples of length 2) :param noise: Noise model :type noise: graphiq.noise.noise_models.NoiseBase @@ -87,7 +87,8 @@ def __init__( :type params: tuple :param param_info: a dictionary that specifies all the information regarding parameters :type param_info: dict - :raises AssertionError: if photon_register, emitter_register, c_registers are not tuples, + :raises AssertionError: if q_registers, q_registers_type, c_registers are not tuples, + OR if q_registers and q_registers_type differ in length, OR if the elements of the tuple do not correspond to the notation described above :return: nothing :rtype: None @@ -129,7 +130,7 @@ def openqasm_info(cls): (needed imports, how to define a gate, how to use a gate), packaged as a single object. This is used by the Circuit classes to generate openQASM scripts when needed - :return: the operation class's openqasm information (possibly None) + :return: the operation class's openqasm information """ if cls._openqasm_info is None: raise ValueError("Operation does not have an openQASM translation") @@ -168,15 +169,15 @@ def c_registers(self): @q_registers.setter def q_registers(self, q_reg): """ - Allows us to change the emitter_registers object on which an Operation acts + Allows us to change the q_registers object on which an Operation acts This should only be used by the circuit class! Subclass-specific implementations of this function also update other register-related fields (e.g. register, target, control) automatically - when photon_registers is updated + when q_registers is updated - :param q_reg: new emitter_register which the operation should target + :param q_reg: new q_registers tuple which the operation should target :type q_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.emitter_registers (Operations + :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -194,7 +195,7 @@ def c_registers(self, c_reg): :param c_reg: new c_register which the operation should target :type c_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations + :raises ValueError: if the new c_reg object does not match the length of self.c_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -220,6 +221,8 @@ def labels(self, new_labels): """ Sets the labels to be new_labels + :param new_labels: the labels to replace the existing ones with + :type new_labels: list[str] :return: nothing :rtype: None """ @@ -229,6 +232,8 @@ def add_labels(self, new_labels): """ Adds new labels to existing labels + :param new_labels: the label or labels to add to the existing ones + :type new_labels: list[str] or str :return: nothing :rtype: None """ @@ -253,9 +258,9 @@ def _update_q_reg(self, q_reg): This is broken into a separate function because subclasses will need to use this as well, and using the super() keyword gets messy with class properties - :param q_reg: new emitter_register which the operation should target + :param q_reg: new q_registers tuple which the operation should target :type q_reg: tuple - :raises ValueError: if the new q_reg object does not match the length of self.emitter_registers (Operations + :raises ValueError: if the new q_reg object does not match the length of self.q_registers (Operations should not have variable register numbers) :return: function returns nothing :rtype: None @@ -463,6 +468,10 @@ def __init__(self, control, control_type, target, target_type, noise=nm.NoNoise( :type target: int OR tuple (of ints, length 2) :param target_type: 'p' if photonic qubit, 'e' if emitter qubit :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 :return: function returns nothing :rtype: None """ @@ -592,6 +601,31 @@ class OneQubitGateWrapper(OneQubitOperationBase): """ def __init__(self, operations: list, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a wrapper treating a list of one-qubit operations as a single component + + A NoNoise given as a scalar is expanded to one NoNoise per wrapped operation. Any other scalar noise model is + stored as-is. For an addition-type model, ``unwrap`` then gives every wrapped gate NoNoise and carries that + model on a separate Identity operation, applied after the gates when its "After gate" parameter is True and + before them otherwise; ``unwrap`` raises KeyError for any other scalar model (one without an "After gate" + parameter). + + :param operations: the one-qubit operation classes to compose; ``unwrap`` applies them last entry first + :type operations: list[type] + :param register: the (quantum) register on which the composed operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: one noise model per wrapped operation, in the same order as ``operations`` (``noise[i]`` is + applied with ``operations[i]``), or a single model for the whole wrapper + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises ValueError: if operations is empty + :raises AssertionError: if any entry is an operation class that is not a OneQubitOperationBase subclass, or + is itself a OneQubitGateWrapper + :raises TypeError: if any entry is not a class (e.g. an operation instance) + :return: nothing + :rtype: None + """ if isinstance(noise, nm.NoNoise): noise = len(operations) * [nm.NoNoise()] super().__init__(register, reg_type, noise) @@ -602,7 +636,7 @@ def __init__(self, operations: list, register=0, reg_type="e", noise=nm.NoNoise( for op_class in operations: assert issubclass(op_class, OneQubitOperationBase) # can only contain base classes - assert not isinstance(op_class, OneQubitGateWrapper) + assert not issubclass(op_class, OneQubitGateWrapper) self.operations = operations self._openqasm_info = oq_lib.single_qubit_wrapper_info(operations) @@ -611,6 +645,8 @@ def unwrap(self): Unwraps the Operation into a list of sub-operations (this can be useful for any operations which are composed of multiple other operations) in the reverse order, which corresponds to the order of applying gates + A list-valued ``noise`` is paired with ``operations`` by index before the reversal. + :return: a sequence of base operations (i.e. operations which are not compositions of other operations) :rtype: list """ @@ -640,6 +676,15 @@ def unwrap(self): return gates[::-1] def openqasm_info(self): + """ + Returns the information needed to generate an openQASM script using this operation + (needed imports, how to define a gate, how to use a gate), packaged as a single object. + + Unlike the class-level lookup on OperationBase, this returns the per-instance value built from the wrapped + operation list at construction time. + + :return: the openqasm information for this wrapper's operation list + """ return self._openqasm_info @@ -651,6 +696,18 @@ class Hadamard(OneQubitOperationBase): _openqasm_info = oq_lib.hadamard_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Hadamard gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -662,6 +719,18 @@ class SigmaX(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_x_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli X gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -673,6 +742,18 @@ class SigmaY(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_y_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli Y gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -684,6 +765,18 @@ class SigmaZ(OneQubitOperationBase): _openqasm_info = oq_lib.sigma_z_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Pauli Z gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -695,6 +788,18 @@ class Phase(OneQubitOperationBase): _openqasm_info = oq_lib.phase_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Phase (S) gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -706,6 +811,18 @@ class PhaseDagger(OneQubitOperationBase): _openqasm_info = oq_lib.phase_dagger_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create a Phase-dagger (S-dagger) gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -721,6 +838,25 @@ class ParameterizedOneQubitRotation(OneQubitOperationBase): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a general parameterized one-qubit rotation, with angles (theta, phi, lambda) + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angles; defaults to (0.0, 0.0, 0.0) + :type params: tuple + :param param_info: bounds and labels for each parameter; defaults to (-pi, pi) for each of theta, phi and + lambda + :type param_info: dict + :raises ValueError: if params has a length other than 3, or param_info is not a dict with 'bounds' and + 'labels' entries of length 3 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -762,6 +898,31 @@ def __init__( params=None, param_info=None, ): + """ + Create a parameterized two-qubit controlled rotation, with angles (theta, phi, lambda) + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :param params: an ordered list of parameter values for the rotation angles; defaults to (0.0, 0.0, 0.0) + :type params: tuple + :param param_info: bounds and labels for each parameter; defaults to (-pi, pi) for each of theta, phi and + lambda + :type param_info: dict + :raises ValueError: if params has a length other than 3, or param_info is not a dict with 'bounds' and + 'labels' entries of length 3 + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) if params is None: params = (0.0, 0.0, 0.0) @@ -792,6 +953,25 @@ class RY(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the Y axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("theta",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -801,8 +981,8 @@ def __init__( raise ValueError("Length of params must be 1") if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "theta", + "bounds": ((-np.pi, np.pi),), + "labels": ("theta",), } else: if not self._validate_param_info(param_info, 1): @@ -822,6 +1002,25 @@ class RX(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the X axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("theta",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -833,8 +1032,8 @@ def __init__( if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "theta", + "bounds": ((-np.pi, np.pi),), + "labels": ("theta",), } else: if not self._validate_param_info(param_info, 1): @@ -854,6 +1053,25 @@ class RZ(ParameterizedOneQubitRotation): def __init__( self, register=0, reg_type="e", noise=nm.NoNoise(), params=None, param_info=None ): + """ + Create a rotation around the Z axis + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :param params: an ordered list of parameter values for the rotation angle; defaults to (0.0,) + :type params: tuple + :param param_info: bounds and labels for the parameter; defaults to {"bounds": ((-pi, pi),), "labels": + ("phi",)}. A dict passed here must have that shape: 'bounds' and 'labels' each a length-1 sequence + :type param_info: dict + :raises ValueError: if params has a length other than 1, or param_info is not a dict with 'bounds' and + 'labels' entries of length 1 + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) if params is None: @@ -864,8 +1082,8 @@ def __init__( raise ValueError("Length of params must be 1") if param_info is None: param_info = { - "bounds": (-np.pi, np.pi), - "labels": "phi", + "bounds": ((-np.pi, np.pi),), + "labels": ("phi",), } else: if not self._validate_param_info(param_info, 1): @@ -883,6 +1101,18 @@ class Identity(OneQubitOperationBase): _openqasm_info = oq_lib.empty_info() def __init__(self, register=0, reg_type="e", noise=nm.NoNoise()): + """ + Create an Identity gate Operation + + :param register: the (quantum) register on which the single-qubit operation acts + :type register: int OR tuple (of ints, length 2) + :param reg_type: 'e' if emitter qubit, 'p' if a photonic qubit + :type reg_type: str + :param noise: Noise model + :type noise: graphiq.noise.noise_models.NoiseBase + :return: nothing + :rtype: None + """ super().__init__(register, reg_type, noise) @@ -896,6 +1126,24 @@ class CNOT(ControlledPairOperationBase): def __init__( self, control=0, control_type="e", target=0, target_type="e", noise=nm.NoNoise() ): + """ + Create a CNOT gate Operation + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) @@ -909,6 +1157,24 @@ class CZ(ControlledPairOperationBase): def __init__( self, control=0, control_type="e", target=0, target_type="e", noise=nm.NoNoise() ): + """ + Create a CZ (controlled-Z) gate Operation + + :param control: control register/qubit for the Operation + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, noise) @@ -944,6 +1210,27 @@ def __init__( c_register: object = 0, noise: object = nm.NoNoise(), ) -> object: + """ + Create an Operation that measures the control in the Z basis, applies X to the target conditioned on the + outcome, records the outcome in the classical register, and resets the control + + :param control: the control register/qubit + :type control: int OR tuple (of ints, length 2) + :param control_type: 'p' if photonic qubit, 'e' if emitter qubit + :type control_type: str + :param target: target register/qubit for the Operation + :type target: int OR tuple (of ints, length 2) + :param target_type: 'p' if photonic qubit, 'e' if emitter qubit + :type target_type: str + :param c_register: the classical register/cbit + :type c_register: int OR tuple (of ints, length 2) + :param noise: Noise model, or a list of two noise models (one for the control, one for the target); + a single noise model is applied to both + :type noise: graphiq.noise.noise_models.NoiseBase or list + :raises AssertionError: if noise is a list whose length is not 2 + :return: nothing + :rtype: None + """ super().__init__(control, control_type, target, target_type, c_register, noise) @@ -967,7 +1254,7 @@ def __init__(self, register=0, reg_type="e", c_register=0, noise=nm.NoNoise()): :param c_register: the classical register to which the measurement result is saved :type c_register: int OR tuple (of ints, length 2) :param noise: Noise model - :type noise: src.noise.noise_models.NoiseBase + :type noise: graphiq.noise.noise_models.NoiseBase :return: this function returns nothing :rtype: None """ @@ -1019,16 +1306,42 @@ class Input(InputOutputOperationBase): """ def __init__(self, register=None, reg_type="e"): + """ + Create an Input placeholder Operation + + :param register: the register/qubit which this I/O operation acts on + :type register: int OR tuple (of ints, length 2) + :param reg_type: the input/output is for a quantum photonic register if 'p', + for a quantum emitter register if 'e', + and for a classical register if 'c' + :type reg_type: str + :raises ValueError: if reg_type is not 'p', 'e' or 'c' + :return: nothing + :rtype: None + """ super().__init__(register, reg_type=reg_type) class Output(InputOutputOperationBase): """ - Input Operation. Serves as a placeholder in the circuit so that we know that this is the final operation on a + Output Operation. Serves as a placeholder in the circuit so that we know that this is the final operation on a qubit/cbit (i.e. there are no subsequent operations on it) """ def __init__(self, register=None, reg_type="e"): + """ + Create an Output placeholder Operation + + :param register: the register/qubit which this I/O operation acts on + :type register: int OR tuple (of ints, length 2) + :param reg_type: the input/output is for a quantum photonic register if 'p', + for a quantum emitter register if 'e', + and for a classical register if 'c' + :type reg_type: str + :raises ValueError: if reg_type is not 'p', 'e' or 'c' + :return: nothing + :rtype: None + """ super().__init__(register, reg_type=reg_type) @@ -1036,6 +1349,15 @@ def __init__(self, register=None, reg_type="e"): def local_clifford_composition(): + """ + Returns the two gate-sequence lists whose products generate every one-qubit local Clifford + + Concatenating one entry of the first list with one entry of the second gives each of the 24 one-qubit Cliffords + (up to a global phase) exactly once, which is how ``one_qubit_cliffords`` enumerates them. + + :return: the six-entry list and the four-entry list of gate-class sequences + :rtype: tuple[list, list] + """ a = [ [Identity], [Hadamard, Phase, Hadamard, Phase], @@ -1123,7 +1445,6 @@ def find_local_clifford_by_matrix(matrix): :type matrix: numpy.ndarray :raises ValueError: if the matrix does not correspond to a valid one-qubit Clifford gate. :return: the local Clifford gate specified by a list of basic gates it consists of - or None if the input matrix is not a valid one-qubit Clifford gate :rtype: list """ gate_list1, gate_list2 = local_clifford_composition() @@ -1152,6 +1473,49 @@ def simplify_local_clifford(gate_list): return find_local_clifford_by_matrix(matrix) +# Openqasm gate names emitted for single-qubit gates by single_qubit_wrapper_info +# (OneQubitGateWrapper serialization concatenates these with no separator), plus +# "p", which name_to_class_map also accepts for Phase. Kept as an explicit +# companion so the name_to_class_map dictionary stays untouched. +# Multi-character tokens (e.g. "sdg") are decoded by greedy longest-match. +_ONE_QUBIT_WRAPPER_TOKENS = { + "h": Hadamard, + "x": SigmaX, + "y": SigmaY, + "z": SigmaZ, + "s": Phase, + "p": Phase, + "sdg": PhaseDagger, +} + + +def _decode_wrapped_gate_name(name): + """ + Decode a concatenated one-qubit-gate wrapper name (e.g. "hsdgy") into its + component operation classes, using greedy longest-match tokenisation over the + single-qubit openqasm gate names. This correctly handles multi-character gate + names such as "sdg" (PhaseDagger). + + :param name: concatenated openqasm gate name + :type name: str + :return: list of operation classes, or None if the name cannot be fully tokenised + :rtype: list or None + """ + max_token_len = max(len(token) for token in _ONE_QUBIT_WRAPPER_TOKENS) + op_list = [] + i = 0 + while i < len(name): + for size in range(min(max_token_len, len(name) - i), 0, -1): + token = name[i : i + size] + if token in _ONE_QUBIT_WRAPPER_TOKENS: + op_list.append(_ONE_QUBIT_WRAPPER_TOKENS[token]) + i += size + break + else: + return None + return op_list + + def name_to_class_map(name): """ Maps our openqasm naming scheme to operation classes. Does not handle multi-gate wrappers @@ -1175,6 +1539,8 @@ def name_to_class_map(name): "classical x": ClassicalCNOT, "classical z": ClassicalCZ, "classical reset x": MeasurementCNOTandReset, + # the name class_to_name_mapping gives it, so to_json output loads back + "measurement-controlled x and reset": MeasurementCNOTandReset, } if name in mapping: return mapping[name] @@ -1187,7 +1553,9 @@ def class_to_name_mapping(class_op): :param class_op: operation class :type class_op: operation - :return: + :return: the string name used for this operation class in json, or None if class_op has no entry + in the mapping + :rtype: str or None """ mapping = { CNOT: "CX", diff --git a/graphiq/circuit/register.py b/graphiq/circuit/register.py index d48f65e0..3afac85d 100644 --- a/graphiq/circuit/register.py +++ b/graphiq/circuit/register.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Register bookkeeping for circuits: which qubit and cbit registers exist, and their sizes. +""" + + class Register: """ Register class object, the class includes a dictionary which map the register type as the key and the register array @@ -50,6 +55,15 @@ def __init__(self, reg_dict, is_multi_qubit: bool = False): @property def register(self): + """ + The register dictionary, keyed by register type + + This is a shallow copy: adding or removing a register type on the result does not affect the Register, + but the per-type lists are shared, so mutating one of those does. + + :return: a copy of the register dictionary + :rtype: dict + """ return self._registers.copy() def __getitem__(self, key): @@ -67,6 +81,12 @@ def __setitem__(self, key, value): @property def n_quantum(self): + """ + The total number of quantum registers, counting every register type except classical ('c') + + :return: the number of quantum registers + :rtype: int + """ q_sum = 0 for key in self._registers: @@ -83,7 +103,8 @@ def add_register(self, reg_type: str, size: int = 1): :type reg_type: str :param size: the new register size :type size: int - :raises ValueError: if new_size is not greater than the current register size + :raises ValueError: if reg_type is not a valid register type, if size is less than 1, or if size is + greater than 1 and multi-qubit registers are not supported :return: the index number of the added register :rtype: int """ @@ -107,11 +128,12 @@ def expand_register(self, reg_type: str, register: int, new_size: int = 1): :param register: the register index of the register to expand :type register: int :param new_size: the new register size - :type register: int + :type new_size: int :param reg_type: 'p' for a photonic quantum register, 'e' for an emitter quantum register, 'c' for a classical register :type reg_type: str - :raises ValueError: if new_size is not greater than the current register size + :raises ValueError: if reg_type is not a valid register type, if new_size is greater than 1 and multi-qubit + registers are not supported, or if new_size is not greater than the current register size :return: this function returns nothing :rtype: None """ diff --git a/graphiq/data_collection/correlation_module.py b/graphiq/data_collection/correlation_module.py index 34a97826..5a4be8cb 100644 --- a/graphiq/data_collection/correlation_module.py +++ b/graphiq/data_collection/correlation_module.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import random -from math import ceil +from math import ceil, factorial import graphiq.backends.state_rep_conversion as converter import networkx as nx @@ -74,14 +74,17 @@ def __init__( :param graph_list: a list of graphs; if provided the sample to study would be this set of given graphs :type graph_list: list[networkx.Graph] :param trials: the size of the random graph sample to analyze - :param relabel_trials: if provided, is used to evaluate the circuit metric over this number of different - relabeling of each graph in the sample, such that the final circuit metric value is the minimum or maximum over - all these relabeling trials. This is done to make the metric value somewhat labeling-agnostic, since the graph - metrics used here are independent of the labeling too. + :param relabel_trials: if truthy, the circuit metric of each graph in the sample is the minimum over at most + num_isomorph distinct labelings of it, the given labeling included (fewer if the graph has fewer distinct + relabelings or the search stops early). The value only switches this on; num_isomorph sets the count, and + at the default num_isomorph=1 no other labeling is used. This makes the metric value somewhat + labeling-agnostic, since the graph metrics used here are independent of the labeling too. If None or 0, + each graph is evaluated as given. :type relabel_trials: int :param num_isomorph: if an initial graph is provided to work with, this parameter determines the size of the sample that is acquired by relabeling of the initial graph. The actual sample size might be less than this - parameter if the maximum number of isomorphism is reached. + parameter if the maximum number of isomorphism is reached. When relabel_trials is set, it is also the most + distinct labelings, the given one included, over which each graph's circuit metric is minimised. :type num_isomorph: int """ # if we have an initial graph, correlations are found based on the relabeling of that graph. Otherwise, if a @@ -115,6 +118,7 @@ def __init__( raise ValueError("The circuit metric should be a valid string") if initial_graph is None: + self._initial_graph = None self._graph_list = graph_list if graph_list: assert isinstance( @@ -135,9 +139,7 @@ def initial_graph(self): @initial_graph.setter def initial_graph(self, g): - assert isinstance(g, nx.Graph) or ( - g is None - ), "initial graph must be a networkx graph object" + assert isinstance(g, nx.Graph), "initial graph must be a networkx graph object" self._initial_graph = g self.graph_list = self._graph_list_maker( self._initial_graph, count=self._num_isomorph @@ -164,7 +166,8 @@ def num_isomorph(self): def num_isomorph(self, num): assert isinstance(num, int) self._num_isomorph = num - self.graph_list = self._graph_list_maker(self._initial_graph, count=num) + if self._initial_graph is not None: + self.graph_list = self._graph_list_maker(self._initial_graph, count=num) @property def relabel_trials(self): @@ -205,7 +208,8 @@ def circ_metric(self, new_met): @staticmethod def _graph_list_maker(g, count): """ - makes a list of relabeled graphs out of the initial one. List size = count. + makes a list of relabeled graphs out of the initial one: the initial graph followed by up to count labelings of + it, the first of which is the initial labeling again. :param g: initial input graph :type g: nx.Graph @@ -216,7 +220,7 @@ def _graph_list_maker(g, count): """ adj = nx.to_numpy_array(g) nod = g.number_of_nodes() - iso_count = min(np.math.factorial(nod), count) + iso_count = min(factorial(nod), count) adj_arr = iso_finder(adj, iso_count, allow_exhaustive=True) graph_list = [g] for adj_i in adj_arr: @@ -260,10 +264,10 @@ def finder(self, show_plot=True, n=None, p=None, swap_axes=False, graph_type=Non """ finds and plots correlation between one graph and one circuit metric for a set of graphs. For all the graphs in the sample that have the same metric value, it takes the average of their graph metric value and returns the - average values alongside the standard deviation of corresponding to each bunch of graphs that share the same - circuit metric value. For instance, for all graph with the same number of emitters, take the average of their - graph metric, say connectivity, and plot this data point (num_emitter, avg_connect) on a diagram. The standard - deviation is taken for the graph metric of those graphs with the same circuit metric. + average values; the standard deviation of each bunch of graphs that share the same circuit metric value is only + drawn as error bars when show_plot is True. For instance, for all graph with the same number of emitters, take + the average of their graph metric, say connectivity, and plot this data point (num_emitter, avg_connect) on a + diagram. The standard deviation is taken for the graph metric of those graphs with the same circuit metric. For correlation between two graph or circuit metrics try the 'met_met' method instead. " :param show_plot: if True a plot of graph metric vs circuit metric will be shown @@ -279,7 +283,7 @@ def finder(self, show_plot=True, n=None, p=None, swap_axes=False, graph_type=Non :type graph_type: str :return: pearson correlation between all graph metric vs circuit metric values, pearson correaltion for the average graph metric values vs unique circuit metric values, and a tuple of lists containing the x and y data - point of the plot, corresponding to the average graph metric values and unique circuit metric values. + point of the plot: the unique circuit metric values and the corresponding average graph metric values. :rtype: float, float, tuple:(list, list) """ graph_met_list = [] @@ -376,10 +380,10 @@ def corr_n_dependence(self, p, n_step=10, constant_np=None): :type p: float :param n_step: the step with which the n is changed, at least 10 steps will be considered. :type n_step: int - :param constant_np: if True, for each n, p is chosen such that the value of n*p remains constant, meaning that - the average degree of each node in the graph remains the same even if the sizes of the graphs in the sample - change - :type constant_np: bool + :param constant_np: if given, for each n, p is set to constant_np / n so that n*p stays equal to constant_np + (the intended average degree, larger than 1), meaning that the average degree of each node in the graph remains + the same even if the sizes of the graphs in the sample change + :type constant_np: float or None :return: nothing """ # number of nodes starting from 5 and going up to at least 100, or possibly 10 times the chosen n_step @@ -495,8 +499,8 @@ def met_met(self, met1, met2, n=None, p=None, show_plot=True): :param show_plot: if True a distribution diagram is shown :type show_plot: bool :return: all pairs of (fist, second) metric values of all graphs, in two separate lists and their respective - repetition count over the sample in a third list. - :rtype: list, list, list + repetition count over the sample in a numpy array. + :rtype: list, list, numpy.ndarray """ met1_list = [] met2_list = [] @@ -651,7 +655,7 @@ def _circ_met_value(self, g): if isinstance(g, nx.Graph): g_temp = g # use the circuit list instead of the graph if possible - g = self._graph_circ_dictionary[g] if self._graph_circ_dictionary else g + g = self._graph_circ_dictionary.get(g, g) if self._graph_circ_dictionary else g # if graph list is provided, g is now a circuit and g_temp is the corresponding graph if self.circ_metric == "num_emit": circ_value = height_max(graph=g_temp) @@ -664,7 +668,7 @@ def _circ_met_value(self, g): elif self.circ_metric == "cnot": circ_value = graph_to_cnot(graph=g) elif self.circ_metric == "cnot_per_photon": - circ_value = graph_to_cnot(graph=g) / len([g_temp]) + circ_value = graph_to_cnot(graph=g) / g_temp.number_of_nodes() elif self.circ_metric == "cnot_per_emitter": circ_value = graph_to_cnot(graph=g) / height_max(graph=g_temp) else: @@ -760,6 +764,7 @@ def graph(self): def graph(self, g): assert isinstance(g, nx.Graph) self._graph = g + self._adj = nx.to_numpy_array(g) self._update_data() @property @@ -798,7 +803,7 @@ def metric(self, new_met): def order_corr(self): """ Determines and plots the relation of average pearson correlation function ,between nodes' emission order and the - metric-value order, and the circuit metric value. The average of the graph metric is taken for all graphs that + metric-value order, and the circuit metric value. The average of that correlation is taken for all graphs that have the same circuit metric value. :return: three lists, the list of unique metric values, the list of average correlation, the list of standard @@ -857,9 +862,9 @@ def met_order_error(self): The larger the data point is plotted, the more the number of graphs in the sample that had the same metric and error values. - :return: three lists, the list of unique metric values, the list of sum squared errors, the list of repetition - count for each of the data points over the whole sample. - :rtype: list, list, list + :return: two lists and a numpy array: the list of unique metric values, the list of sum squared errors, the + array of repetition count for each of the data points over the whole sample. + :rtype: list, list, numpy.ndarray """ adj_list, circ_met_list = self.adj_list, self.met_list n = self.adj.shape[0] @@ -896,9 +901,9 @@ def next_node_corr(self): The larger the data point is plotted, the more the number of graphs in the sample that had the same metric and error values. - :return: three lists, the list of unique metric values, the list of sum squared errors, the list of repetition - count for each of the data points over the whole sample. - :rtype: list, list, list + :return: two lists and a numpy array: the list of unique metric values, the list of sum squared errors, the + array of repetition count for each of the data points over the whole sample. + :rtype: list, list, numpy.ndarray """ # based on discarding the part of the graph state that has been emitted so far. adj_list, circ_met_list = self.adj_list, self.met_list @@ -1017,6 +1022,9 @@ def rnd_graph(n, p=None, m=None, seed=None, model="erdos"): elif model == "albert": rnd_maker = nx.barabasi_albert_graph x = m + if isinstance(seed, int): + # one seeded stream for all draws, so a disconnected draw is not repeated forever + seed = random.Random(seed) g = nx.Graph() g.add_edges_from(((0, 1), (2, 3))) while not nx.is_connected(g): @@ -1035,12 +1043,21 @@ def graph_to_circ(graph, show=False): :type show: bool :return: A circuit corresponding to the input graph :rtype: CircuitDAG + :raises ValueError: if the graph has an isolated vertex """ if not isinstance(graph, nx.Graph): graph = nx.from_numpy_array(graph) assert isinstance( graph, nx.Graph ), "input must be a networkx graph object or a numpy adjacency matrix" + n_isolated = nx.number_of_isolates(graph) + if n_isolated: + raise ValueError( + f"graph has an isolated vertex ({n_isolated} in total); an isolated " + "vertex is a photon entangled with no other qubit, which " + "TimeReversedSolver cannot absorb into an emitter, so graph_to_circ " + "does not support it" + ) tabs = converter.graph_to_stabilizer(graph) c_tableau = CliffordTableau(tabs[0][1]) ideal_state = QuantumState(c_tableau, rep_type="stabilizer") @@ -1106,7 +1123,7 @@ def graph_to_emit_depth(graph): circ.unwrap_nodes() circ.remove_identity() for e_i in range(circ.n_emitters): - depth[e_i] = len(circ.reg_gate_history(reg=e_i)[1]) + depth[e_i] = len(circ.reg_gate_history(reg=e_i)[1]) - 2 return max(depth.values()) @@ -1146,7 +1163,7 @@ def adj_emit_sorted_list(adj, count=1000): :rtype: list, list """ n = adj.shape[0] - iso_count = min(np.math.factorial(n), count) + iso_count = min(factorial(n), count) arr = iso_finder(adj, iso_count, allow_exhaustive=True) li11 = emitter_sorted(arr) emit_list = [x[1] for x in li11] @@ -1233,7 +1250,7 @@ def _rep_counter(x_data, y_data): :type x_data: An iterable :param y_data: A set of numeric data the same size as the x_data :type y_data: An iterable - :return: Returns all respective x, y value in two lists removing the redundancies and a third list containing the + :return: Returns all respective x, y value in two lists removing the redundancies and a numpy array containing the repetition count of each of the data points. """ assert len(x_data) == len(y_data), "two data sets must be of same size" diff --git a/graphiq/data_collection/ui_functions.py b/graphiq/data_collection/ui_functions.py index f6a40333..7b1351a7 100644 --- a/graphiq/data_collection/ui_functions.py +++ b/graphiq/data_collection/ui_functions.py @@ -56,7 +56,9 @@ def t_graph(gtype, n, seed=None, show=False): if gtype == "draw": g = graph_gui() elif gtype == "tree": - g = nx.random_tree(n, seed=seed) + # nx.random_tree was removed in networkx 3.4 in favour of random_labeled_tree + random_tree = getattr(nx, "random_tree", None) or nx.random_labeled_tree + g = random_tree(n, seed=seed) elif gtype == "rnd": rng = np.random.default_rng(seed=seed) p = rng.integers(low=15, high=95, size=1)[0] / 100 diff --git a/graphiq/data_collection/user_interface.py b/graphiq/data_collection/user_interface.py index d33fe0af..1f8be914 100644 --- a/graphiq/data_collection/user_interface.py +++ b/graphiq/data_collection/user_interface.py @@ -20,7 +20,7 @@ import pandas as pd from scipy.stats import spearmanr -from correlation_module import * +from graphiq.data_collection.correlation_module import * from graphiq.data_collection.ui_functions import * from graphiq.metrics import graph_met_value @@ -173,19 +173,27 @@ def result_maker(result, graph_met_list=None, circ_met_list=None): ] eff_depth[e_i] = max(depth_diff) if "max_emit_depth" in circ_met_list: - max_emit_depth.append(max(e_depth.values())) + max_emit_depth.append(max(e_depth.values(), default=0)) if "max_emit_reset_depth" in circ_met_list: - max_emit_reset_depth.append(max(reset_depths.values())) + max_emit_reset_depth.append(max(reset_depths.values(), default=0)) if "max_emit_eff_depth" in circ_met_list: - max_emit_eff_depth.append(max(eff_depth.values())) + max_emit_eff_depth.append(max(eff_depth.values(), default=0)) if "depth" in circ_met_list: - depth.append(max(c.register_depth["e"])) + depth.append(max(c.register_depth["e"], default=0)) # calculate n_emitter and n_cnots if "n_emitters" in circ_met_list: n_emitters.append(c.n_emitters) if "n_unitary" in circ_met_list: n_u = 0 - for label in ["SigmaX", "SigmaX", "SigmaX", "Phase", "Hadamard", "CNOT"]: + for label in [ + "SigmaX", + "SigmaY", + "SigmaZ", + "Phase", + "PhaseDagger", + "Hadamard", + "CNOT", + ]: if label in c.node_dict: n_u += len(c.get_node_by_labels([label])) n_unitary.append(n_u) @@ -262,7 +270,7 @@ def correlation_checker(result, list_mets1, list_mets2): :param result: result object :param list_mets1: list of circuit metrics :param list_mets2: list of grpah metrics - :return: all combination of graph and circuit metrics linear correlations will be calculated + :return: nothing; the Spearman and Pearson correlations of every combination of the two metric lists are printed """ for met1 in list_mets1: for met2 in list_mets2: @@ -289,7 +297,8 @@ def corr_with_mean(list1, list2, print_result=True): :param list1: to be used as reference :param list2: to be averaged over for each case - :return: pearson correlation coefficient + :return: pearson correlation coefficient, and a tuple of the unique list1 values, the mean list2 value for each and + their standard deviations """ corr_dict = dict() for i, x in enumerate(list1): @@ -318,9 +327,12 @@ def corr_with_mean(list1, list2, print_result=True): index_map_dict[tuple(result["map"][i].items())].append(i) -def plot_map_based(): +def plot_map_based(result): + index_map_dict = {} + for i in range(len(result)): + index_map_dict.setdefault(tuple(result["map"][i].items()), []).append(i) for i, index_list in enumerate(index_map_dict.values()): - plot_figs(indices=index_list, dir_name=f"map_{i}_len{len(index_list)}") + plot_figs(result, indices=index_list, dir_name=f"map_{i}_len{len(index_list)}") def plot_graphs(result): @@ -438,11 +450,12 @@ def find_best( # del out_dict['std of score'] del out_dict["graph_metric"] df = pd.DataFrame(out_dict) - if not os.path.exists(dir_path): - os.makedirs(dir_path) - filename = dir_path + f"/{file_name}.csv" - df.to_csv(filename, index=False) - result.save2json(dir_path, f"res_{file_name}") + if dir_path is not None: + if not os.path.exists(dir_path): + os.makedirs(dir_path) + filename = dir_path + f"/{file_name}.csv" + df.to_csv(filename, index=False) + result.save2json(dir_path, f"res_{file_name}") cost = [] for i in range(len(result)): cost.append( @@ -531,7 +544,7 @@ def graph_analyzer( if fidelity: if out_dict["fidelity"][j] > best_fid[1]: best_fid = (f"LC: {i} iso: {j}", out_dict["fidelity"][j], edge_list) - if out_dict["fidelity"][j] < best_fid[1]: + if out_dict["fidelity"][j] < worst_fid[1]: worst_fid = ( f"LC: {i} iso: {j}", out_dict["fidelity"][j], @@ -729,6 +742,7 @@ def LC_scaling_known(result, dir_path: str, graph_class: str, n_lc_list=None): df_cnot = pd.DataFrame((c_means, c_stds)) df_depth = pd.DataFrame((d_means, d_stds)) + os.makedirs(os.path.join(dir_path, graph_class), exist_ok=True) output_path1 = os.path.join(os.path.join(dir_path, graph_class), "bwCNOT.csv") df_cnot.to_csv(output_path1, index=False, header=False) output_path2 = os.path.join(os.path.join(dir_path, graph_class), "bwDepth.csv") @@ -743,7 +757,7 @@ def rgs_analysis(res_rgsn, dir_path: str, filename: str): n_cnots.append(len(c.get_node_by_labels(["Emitter-Emitter", "CNOT"]))) else: n_cnots.append(0) - res_rgsn.add_properties("n_cnot") + res_rgsn.add_properties("n_cnots") res_rgsn["n_cnots"] = n_cnots graph_met_list = [ "node_connect", @@ -771,6 +785,7 @@ def rgs_analysis(res_rgsn, dir_path: str, filename: str): for i, g in enumerate(res_rgsn["g"]): res_rgsn["graph_metric"][i][met] = graph_met_value(met, g) full_filename = os.path.join(os.path.join(dir_path, "RGS"), filename) + os.makedirs(full_filename, exist_ok=True) res_rgsn.save2json(full_filename, f"{filename}") correlation_checker(res_rgsn, ["n_cnots"], graph_met_list) plot_figs( diff --git a/graphiq/io.py b/graphiq/io.py index c917ecef..592616c4 100644 --- a/graphiq/io.py +++ b/graphiq/io.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Saving and loading of data, figures and dataframes, with consistent file types and folder naming. +""" + import datetime import json import os @@ -44,11 +48,19 @@ class IO: io.load_txt(filename="filename.txt") or - io = IO.create_new_save_folder(folder="subfolder", include_date=True, include_uuid=True) - io.save_df(df, filename="dataframe.txt") + io = IO.new_directory(folder="subfolder", include_date=True, include_id=True) + io.save_dataframe(df, filename="dataframe.txt") + + Without a path, IO and IO.new_directory save under IO.default_path, the folder + `data/` in the parent folder of the graphiq package. In a source checkout or an + editable install (pip install -e) that is the repository's `data/`; for a regular + install (pip install graphiq, or pip install . from a checkout) it is + `/data`. Pass path= to save somewhere else. """ - # default save path always points to `data/` no matter where this repository is located + # default save path: `data/` in the parent folder of the graphiq package, i.e. the + # repository's `data/` in a source checkout or an editable install (pip install -e), + # and `/data` for a regular (non-editable) install default_path = pathlib.Path(__file__).parent.parent.joinpath("data") def __init__(self, path=None, verbose=True): @@ -111,7 +123,7 @@ def new_directory( warnings.warn( "No folder entered. Saving to a folder with a unique identifier" ) - include_data, include_id, verbose = True, True, True + include_date, include_id, verbose = True, True, True # build the full folder name with date, time, and uuid, if selected _str = "" @@ -267,12 +279,12 @@ def load_np_array(self, filename, complex_vals=False): :type filename: str :param complex_vals: True if we expect the numpy array to be complex, False otherwise :type complex_vals: bool - :return: the function returns nothing - :rtype: None + :return: the loaded numpy array + :rtype: numpy.ndarray """ full_path = self.path.joinpath(filename) file = np.loadtxt( - str(full_path), dtype=np.complex if complex_vals else np.float + str(full_path), dtype=np.complex128 if complex_vals else np.float64 ) if self.verbose: print(f"{current_time()} | Loaded from {full_path} successfully.") @@ -282,6 +294,13 @@ def load_np_array(self, filename, complex_vals=False): def _save_json(variable, path): """ Helper method for saving to json files + + :param variable: the object to save + :type variable: JSON-serializable object + :param path: path of the file to which variable should be saved + :type path: str or pathlib.Path + :return: the function returns nothing + :rtype: None """ with open(path, "w+") as json_file: json.dump(variable, json_file, indent=4) @@ -290,6 +309,11 @@ def _save_json(variable, path): def _load_json(path): """ Helper method for loading from json files + + :param path: path of the file from which we are loading the object + :type path: str or pathlib.Path + :return: the object decoded from the file by json.load + :rtype: may vary """ with open(path) as json_file: data = json.load(json_file) @@ -299,6 +323,13 @@ def _load_json(path): def _save_txt(variable, path): """ Helper method for saving to text files + + :param variable: the text to save + :type variable: str + :param path: path of the file to which variable should be saved + :type path: str or pathlib.Path + :return: the function returns nothing + :rtype: None """ with open(path, "w") as txt_file: txt_file.write(variable) @@ -307,7 +338,12 @@ def _save_txt(variable, path): def _load_txt(path): """ Helper method for loading from text files + + :param path: path of the file from which we are loading the text + :type path: str or pathlib.Path + :return: the contents of the file + :rtype: str """ - f = open(path, "r") - data = f.read() + with open(path, "r") as f: + data = f.read() return data diff --git a/graphiq/metrics.py b/graphiq/metrics.py index 18fd25ae..c4bd1df8 100644 --- a/graphiq/metrics.py +++ b/graphiq/metrics.py @@ -68,6 +68,16 @@ def increment(self): class Infidelity(MetricBase): + """ + Infidelity of a state with respect to a fixed target state, 1 - F. + + Zero means the state matches the target. With a stabilizer target and a MixedStabilizer state, the value is + 1 - sum_i p_i F_i over the mixture's tableaux. The weights p_i need not sum to 1 (photon loss lowers them), so + missing probability counts as infidelity. Likewise, a density matrix state with trace t below 1 (photon loss) + is scored as t F(rho / t), so its missing trace counts as infidelity. The target must be in the stabilizer + ("s") or density matrix ("dm") representation. + """ + def __init__(self, target, log_steps=1, *args, **kwargs): """ Creates an Infidelity Metric object, which computes 1-fidelity with respect to the ideal_state @@ -76,7 +86,6 @@ def __init__(self, target, log_steps=1, *args, **kwargs): :type target: QuantumState :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :raises AssertionError: if targe is not a valid density matrix :return: nothing :rtype: None """ @@ -110,7 +119,10 @@ def evaluate(self, state, circuit): :param circuit: circuit which generated state Not used for the fidelity evaluation, but argument is provided for API consistency :type circuit: CircuitBase (or subclass of it) - :raises AssertionError: if the state is not a valid density matrix + :raises AssertionError: if the target is a MixedStabilizer that is not one tableau with probability 1.0, + or, for a density matrix target, if the target is not a valid density matrix, or the state is not + positive semi-definite or has zero trace + :raises ValueError: if the target's rep_type is neither "s" nor "dm" :return: infidelity = 1 - fidelity :rtype: float """ @@ -131,18 +143,27 @@ def evaluate(self, state, circuit): if isinstance(rep_data, Stabilizer): fid = sfm.fidelity(tableau, rep_data.data) - elif isinstance(state.rep_data, MixedStabilizer): + elif isinstance(rep_data, MixedStabilizer): fid = sum( [p_i * sfm.fidelity(tableau, t_i) for p_i, t_i in rep_data.mixture] ) elif self.target.rep_type == "dm": if state.rep_type == "dm": - fid = dmf.fidelity(self.target.rep_data.data, state.rep_data.data) + rho = state.rep_data.data else: tmp_state = state.copy() tmp_state.convert_representation("dm") - fid = dmf.fidelity(self.target.rep_data.data, tmp_state.rep_data.data) + rho = tmp_state.rep_data.data + target = self.target.rep_data.data + trace = np.real(np.trace(rho)) + if np.isclose(trace, 1.0): + fid = dmf.fidelity(target, rho) + else: + # photon loss leaves trace < 1; the fidelity scales with rho's + # trace, F(sigma, t rho') = t F(sigma, rho'), so the missing trace + # counts as infidelity, as the stabilizer arm's sum_i p_i F_i does + fid = trace * dmf.fidelity(target, rho / trace) else: raise ValueError("Cannot compute the infidelity.") @@ -155,12 +176,21 @@ def evaluate(self, state, circuit): class TraceDistance(MetricBase): + """ + Trace distance between a state and a fixed target state. + + Zero means the states are identical and one means they are perfectly distinguishable. The evaluated state is + converted to a density matrix if it is not one already, but the target is not: a target held in any other + representation is rejected. + """ + def __init__(self, target, log_steps=1, *args, **kwargs): """ Creates a TraceDistance Metric object, which computes the trace distance between the current state and the target state. - :param target: the ideal state against which we compute fidelity + :param target: the ideal state against which we compute the trace distance; it must be in the + density matrix representation :type target: QuantumState :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int @@ -178,13 +208,15 @@ def evaluate(self, state, circuit): The trace distance is computed between two density matrices $\rho$ and $\sigma$ as: $$ T(\rho, \sigma) = \frac{1}{2} \text{Tr}\left( \sqrt{ (\rho - \sigma)^2 } \right) - = \\frac{1}{2} \sum_i | \lambda_i | + = \frac{1}{2} \sum_i | \lambda_i | $$ + :param state: the state to evaluate :type state: QuantumState :param circuit: circuit which generated state Not used for the trace distance evaluation, but argument is provided for API consistency :type circuit: CircuitBase (or subclass of it) + :raises ValueError: if the target state is not in the density matrix representation :return: the trace distance :rtype: float """ @@ -258,111 +290,6 @@ def evaluate(self, state, circuit): return val -class Metrics(MetricBase): - """ - Wraps around one or more metric functions, evaluating each and logging the values - """ - - _all = { # metrics that can be used, which can be specified by the dictionary keys or as a class instance - "Infidelity": Infidelity, - "TraceDistance": TraceDistance, - "CircuitDepth": CircuitDepth, - } - - def __init__( - self, metrics_list: list, metric_weight=None, log_steps=1, *args, **kwargs - ): - """ - Create a Metrics object which acts as a wrapper around Metric functions - - :param metrics_list: metrics to evaluate - :type metrics_list: list of strings (strings should be metric names) OR MetricBase objects - MetricBase objects may be preferable, since we cannot set initial parameters via the list of strings. - :param metric_weight: some representation of how to weigh the different metric results against one another - if None, all metrics provided are weighted equally (by 1) - if a list or ndarray, the metrics are a linear combination weighted by the list/ndarray values - Otherwise, metric_weight is a function, that can make any mathematical function of the individual - metric values. - :type metric_weight: None, numpy.ndarray, list, or Function - :param log_steps: the metric values are computed at every log_steps optimization step - :type log_steps: int - :return: function returns nothing - :rtype: None - """ - super().__init__(log_steps=log_steps, *args, **kwargs) - # pass in either list of either strings or of specific Metric instance (must be an accepted Metric) - _metrics = [] - for metric in metrics_list: - if metric.__class__ in self._all.values(): - _metrics.append(metric) - elif metric in self._all.keys(): - _metrics.append(self._all[metric]()) - else: - raise UserWarning( - f"{metric} is not a recognized metric - it will not be evaluated." - ) - self._metrics = _metrics - - if ( - metric_weight is None - or isinstance(metric_weight, list) - or isinstance(metric_weight, np.ndarray) - ): - if metric_weight is None: - metric_weight = np.ones(len(metrics_list)).flatten() - - elif isinstance(metric_weight, list): - metric_weight = np.array([metric_weight]).flatten() - - else: - metric_weight = metric_weight.flatten() - - def weighting_func(state, circuit): - return np.dot( - metric_weight, - np.array( - [met.evaluate(state, circuit) for met in self._metrics] - ).flatten(), - ) - - self.weighting_func = weighting_func - elif callable(metric_weight): - self.weighting_func = metric_weight - - def evaluate(self, state, circuit): - """ - Evaluate each metric function contained by the Metrics object - - :param state: the state on which to evaluate the metrics - :type state: QuantumState - :param circuit: the circuit on which to evaluate the metrics - :type circuit: CircuitBase (or a subclass of it) - :return: this function returns nothing - :rtype: None - """ - val = self.weighting_func(state, circuit) - self.increment() - - if self._inc % self.log_steps == 0: - self.log.append(val) - - return val - - @property - def per_metric_log(self): - """ - The joint log of all metric functions - - :return: the log itself - :rtype: dict (keys are metric class names, values are the logs) - """ - m = {} - for i, metric in enumerate(self._metrics): - m[metric.__class__.__name__] = metric.log - - return m - - # circuit metrics @@ -433,9 +360,7 @@ def __init__(self, log_steps=1, n_cnot_penalty=None, *args, **kwargs): super().__init__(log_steps=log_steps, *args, **kwargs) self.differentiable = False if n_cnot_penalty is None: - self.n_emitter_penalty = ( - lambda x: x - ) # by default, the number emitters itself + self.n_cnot_penalty = lambda x: x # by default, the number of CNOTs itself else: self.n_cnot_penalty = n_cnot_penalty @@ -467,7 +392,7 @@ def evaluate(self, state, circuit): class CircuitUnitaryCount(MetricBase): """ - A metric which calculates the circuit depth + A metric which calculates the circuit's number of unitary gates """ def __init__(self, log_steps=1, n_unitary_penalty=None, *args, **kwargs): @@ -486,7 +411,7 @@ def __init__(self, log_steps=1, n_unitary_penalty=None, *args, **kwargs): if n_unitary_penalty is None: self.n_unitary_penalty = ( lambda x: x - ) # by default, the number emitters itself + ) # by default, the number of unitaries itself else: self.n_unitary_penalty = n_unitary_penalty @@ -508,8 +433,8 @@ def evaluate(self, state, circuit): n_u = 0 for label in [ "SigmaX", - "SigmaX", - "SigmaX", + "SigmaY", + "SigmaZ", "Phase", "PhaseDagger", "Hadamard", @@ -536,7 +461,7 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -552,14 +477,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter depth. By default, this is the maximum emitter + depth itself :rtype: float or int """ c = circuit.copy() @@ -568,7 +494,7 @@ def evaluate(self, state, circuit): e_depth = {} for e_i in range(c.n_emitters): e_depth[e_i] = len(c.reg_gate_history(reg=e_i)[1]) - 2 - depth = max(e_depth.values()) + depth = max(e_depth.values(), default=0) val = self.depth_penalty(depth) self.increment() @@ -588,7 +514,8 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter reset + depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -604,14 +531,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter reset depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter reset depth. By default, this is the maximum + emitter reset depth itself :rtype: float or int """ c = circuit.copy() @@ -632,7 +560,7 @@ def evaluate(self, state, circuit): m_list[j + 1] - m_list[j] for j in range(len(m_list) - 1) ] reset_depths[e_i] = max(reset_intervals) - depth = max(reset_depths.values()) + depth = max(reset_depths.values(), default=0) val = self.depth_penalty(depth) self.increment() @@ -652,7 +580,8 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param depth_penalty: a function which calculates a "cost"/penalty as a function of circuit depth + :param depth_penalty: a function which calculates a "cost"/penalty as a function of the maximum emitter + effective depth :type depth_penalty: function :return: the function returns nothing :rtype: None @@ -668,14 +597,15 @@ def __init__(self, log_steps=1, depth_penalty=None, *args, **kwargs): def evaluate(self, state, circuit): """ - Calculates a scalar function of the circuit depth + Calculates a scalar function of the circuit's maximum emitter effective depth :param state: state which was created by the circuit. This is not actually used by this metric object, but is nonetheless provided to guarantee a uniform API between Metric-type objects :type state: QuantumState :param circuit: the circuit to evaluate :type circuit: CircuitBase (or a subclass of it) - :return: the scalar penalty resulting from circuit depth. By default, this is the circuit depth itself + :return: the scalar penalty resulting from the maximum emitter effective depth. By default, this is the maximum + emitter effective depth itself :rtype: float or int """ c = circuit.copy() @@ -698,7 +628,7 @@ def evaluate(self, state, circuit): for j in range(len(node_list) - 1) ] eff_depth[e_i] = max(depth_diff) - depth = max(eff_depth.values()) + depth = max(eff_depth.values(), default=0) val = self.depth_penalty(depth) self.increment() @@ -728,7 +658,7 @@ def __init__(self, log_steps=1, m_penalty=None, *args, **kwargs): if m_penalty is None: self.m_penalty = ( lambda x: x - ) # by default, the penalty for depth is the depth itself + ) # by default, the penalty for measurement count is the count itself else: self.m_penalty = m_penalty @@ -763,11 +693,16 @@ class GraphMetric(MetricBase): def __init__(self, graph: nx.Graph, log_steps=1, penalty=None, *args, **kwargs): """ + Create a GraphMetric object + :param graph: the graph on which the metric is calculated + :type graph: nx.Graph :param log_steps: the metric values are computed at every log_steps optimization step :type log_steps: int - :param m_penalty: a function which calculates a "cost"/penalty as a function of number of measurement - :type m_penalty: function + :param penalty: a function which calculates a "cost"/penalty as a function of the graph metric value; + if None, the penalty is the metric value itself + :type penalty: function or None + :raises AssertionError: if graph is not an nx.Graph :return: the function returns nothing :rtype: None """ @@ -778,7 +713,7 @@ def __init__(self, graph: nx.Graph, log_steps=1, penalty=None, *args, **kwargs): if penalty is None: self.penalty = ( lambda x: x - ) # by default, the penalty for depth is the depth itself + ) # by default, the penalty for the graph metric is the metric value itself else: self.penalty = penalty @@ -821,6 +756,127 @@ def evaluate(self, graph_metric: str): return val +class Metrics(MetricBase): + """ + Wraps around one or more metric functions, evaluating each and logging the values + """ + + _all = { # metrics that can be used, which can be specified by the dictionary keys or as a class instance + "Infidelity": Infidelity, + "TraceDistance": TraceDistance, + "CircuitDepth": CircuitDepth, + "CircuitEmitterCount": CircuitEmitterCount, + "CircuitCnotCount": CircuitCnotCount, + "CircuitUnitaryCount": CircuitUnitaryCount, + "CircuitMaxEmitDepth": CircuitMaxEmitDepth, + "CircuitMaxEmitResetDepth": CircuitMaxEmitResetDepth, + "CircuitMaxEmitEffDepth": CircuitMaxEmitEffDepth, + "CircuitMeasureCount": CircuitMeasureCount, + # GraphMetric is deliberately excluded: its evaluate(graph_metric: str) + # signature doesn't match the evaluate(state, circuit) contract every + # other metric here follows, so Metrics can't actually call it. + } + + def __init__( + self, metrics_list: list, metric_weight=None, log_steps=1, *args, **kwargs + ): + """ + Create a Metrics object which acts as a wrapper around Metric functions + + :param metrics_list: metrics to evaluate + :type metrics_list: list of strings (strings should be metric names) OR MetricBase objects + MetricBase objects may be preferable, since we cannot set initial parameters via the list of strings. + :param metric_weight: some representation of how to weigh the different metric results against one another + if None, all metrics provided are weighted equally (by 1) + if a list or ndarray, the metrics are a linear combination weighted by the list/ndarray values + Otherwise, metric_weight is a function, that can make any mathematical function of the individual + metric values. + :type metric_weight: None, numpy.ndarray, list, or Function + :param log_steps: the metric values are computed at every log_steps optimization step + :type log_steps: int + :raises TypeError: if metric_weight is not None, a list, a numpy.ndarray or a callable + :return: function returns nothing + :rtype: None + """ + super().__init__(log_steps=log_steps, *args, **kwargs) + # pass in either list of either strings or of specific Metric instance (must be an accepted Metric) + _metrics = [] + for metric in metrics_list: + if metric.__class__ in self._all.values(): + _metrics.append(metric) + elif metric in self._all.keys(): + _metrics.append(self._all[metric]()) + else: + raise UserWarning( + f"{metric} is not a recognized metric - it will not be evaluated." + ) + self._metrics = _metrics + + if ( + metric_weight is None + or isinstance(metric_weight, list) + or isinstance(metric_weight, np.ndarray) + ): + if metric_weight is None: + metric_weight = np.ones(len(metrics_list)).flatten() + + elif isinstance(metric_weight, list): + metric_weight = np.array([metric_weight]).flatten() + + else: + metric_weight = metric_weight.flatten() + + def weighting_func(state, circuit): + return np.dot( + metric_weight, + np.array( + [met.evaluate(state, circuit) for met in self._metrics] + ).flatten(), + ) + + self.weighting_func = weighting_func + elif callable(metric_weight): + self.weighting_func = metric_weight + else: + raise TypeError( + "metric_weight must be None, a list, a numpy.ndarray or a callable; " + f"got {type(metric_weight).__name__}." + ) + + def evaluate(self, state, circuit): + """ + Evaluate each metric function contained by the Metrics object + + :param state: the state on which to evaluate the metrics + :type state: QuantumState + :param circuit: the circuit on which to evaluate the metrics + :type circuit: CircuitBase (or a subclass of it) + :return: the weighted combination of the individual metric values + :rtype: float + """ + val = self.weighting_func(state, circuit) + self.increment() + + if self._inc % self.log_steps == 0: + self.log.append(val) + + return val + + @property + def per_metric_log(self): + """ + The joint log of all metric functions + + :return: the log itself + :rtype: dict (keys are metric class names, values are the logs) + """ + m = {} + for i, metric in enumerate(self._metrics): + m[metric.__class__.__name__] = metric.log + + return m + + def graph_met_value(graph_metric, g): """ Evaluates the graph metric for the given graph. diff --git a/graphiq/noise/model_parameters.py b/graphiq/noise/model_parameters.py index 0d47eaf5..09260f00 100644 --- a/graphiq/noise/model_parameters.py +++ b/graphiq/noise/model_parameters.py @@ -12,15 +12,26 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Default gate-duration and noise-parameter data consumed by :mod:`graphiq.noise.time_depend_noise`. + +``gate_duration_dict`` gives each operation type it lists a duration of 1 (0 for ``Input`` and ``Output``); types +it does not list, such as ``PhaseDagger`` or ``OneQubitGateWrapper``, have no default duration. +``noise_parameters`` is the default noise-parameter dict, merged into any partial one a caller supplies; +``error_ops`` maps the ``"x"``/``"y"``/``"z"``/``"I"`` noise-type letters to the operation class ``insert_error`` +inserts for each. ``equal_time_dict`` builds a gate-duration dict with one duration for every given operation type. +""" + import graphiq.circuit.ops as ops def equal_time_dict(ops_list, duration): """ - A helper function. Creates a dictionary of operation types and assigns them all an equal time duration. - :param ops_list: list of operations - :type ops_list: list - :param duration: a time duration + Build a gate-duration dict, assigning duration to every operation type in ops_list. + + :param ops_list: operation instances whose types become the dict's keys + :type ops_list: list[ops.OperationBase] + :param duration: the time duration assigned to each operation type :type duration: int or float :return: the gate duration dictionary for the given operations :rtype: dict diff --git a/graphiq/noise/monte_carlo_noise.py b/graphiq/noise/monte_carlo_noise.py index 24d25523..61f143ee 100644 --- a/graphiq/noise/monte_carlo_noise.py +++ b/graphiq/noise/monte_carlo_noise.py @@ -66,12 +66,13 @@ def get_gate_noise(self, reg_type, gate_name): """ A list of tuples containing noise models and their corresponding probability of happening is returned for a certain type of gate in circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str :return: list of tuples (noise models, probabilities) - :rtype: list + :rtype: list[tuple[nm.NoiseBase, float]] """ noise_prob = self.total_noise_prob(reg_type, gate_name) if noise_prob != 0: @@ -83,8 +84,9 @@ def get_gate_noise(self, reg_type, gate_name): def add_gate_noise(self, reg_type, gate_name, noise_list): """ - adds (or replaces if existing before) a noise model to a certain type of gate in the circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + Adds (or replaces if existing before) a noise model to a certain type of gate in the circuit. + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str @@ -92,7 +94,6 @@ def add_gate_noise(self, reg_type, gate_name, noise_list): :type noise_list: list :return: nothing :rtype: None - :return: """ if gate_name in self.mapping[reg_type]: self._mapping[reg_type][gate_name] = [] @@ -101,13 +102,14 @@ def add_gate_noise(self, reg_type, gate_name, noise_list): def add_noise_tuple(self, reg_type, gate_name, noise_tuple): """ - adds a noise model to a certain type of gate in the circuit. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + Adds a noise model to a certain type of gate in the circuit. + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str :param noise_tuple: a tuple with first element being a noise model object and the second element a float < 1. - :type noise_tuple: tuple(NoiseBase, float) + :type noise_tuple: tuple[nm.NoiseBase, float] :return: nothing :rtype: None """ @@ -135,7 +137,8 @@ def add_noise_tuple(self, reg_type, gate_name, noise_tuple): def total_noise_prob(self, reg_type, gate_name): """ Calculates the total probability of the gate not being perfect. - :param reg_type: the register type of the gate chosen from 'e', 'p', 'ep', 'pp' + + :param reg_type: the register type of the gate, one of 'e', 'p', 'ee', 'ep' :type reg_type: str :param gate_name: the name of the gate :type gate_name: str @@ -151,8 +154,8 @@ def total_noise_prob(self, reg_type, gate_name): class MonteCarloNoise: """ - A class to compile a given noise model, possibly tailored specifically for monte-carlo run, for an input circuit and - saving the results. + Compiles a circuit under Monte Carlo noise, drawn from a per-gate noise model, and scores + each noisy sample against the noiseless compilation. """ # TODO: implement save circuits option @@ -160,7 +163,7 @@ def __init__( self, circuit, n_sample: int = 1, - mc_noise_model: dict = None, + mc_noise_model: McNoiseMap = None, compiler=None, seed=None, ): @@ -238,7 +241,9 @@ def seed(self, new_seed): def run(self): """ Calculates the average infidelity for the noisy circuits when n_sample trials are performed. + :return: average score (infidelity) + :rtype: float """ self.all_scores = [] for i in range(self.n_sample): @@ -282,12 +287,18 @@ def assign_noise(self): def _noisy_gates(self): noise_model_map = self.mc_noise_model.mapping circ = self.circuit - seq = circ._slim_seq() + # copy first: the noise is set on the ops in place, and these must not + # be the source circuit's own ops + seq = circ.copy()._slim_seq() noisy_ops = [] for op in seq: is_controlled = False if isinstance(op, ops.OneQubitGateWrapper): - op_type_seq = [type(gate) for gate in op.unwrap()] + if not isinstance(op.noise, list): + # a single model for the whole wrapper is left as it is + noisy_ops.append(op) + continue + op_type_seq = list(op.operations) noise_list = self._find_wrapped_noise(op_type_seq, op.reg_type) op.noise = noise_list noisy_ops.append(op) @@ -359,6 +370,22 @@ def _get_rnd_noise_obj(self, reg_type, gate_name): def parallel_monte_carlo(mc: MonteCarloNoise, n_parallel, n_single): + """ + Run a MonteCarloNoise simulation as n_parallel ray tasks of n_single samples each + + The first task uses ``mc.seed`` (0 if it is None) and the others use n_parallel - 1 distinct seeds drawn from + ``mc.rng`` in [0, 100 * n_parallel). ``mc.n_sample`` is restored afterwards, and ``mc.all_scores`` is set to + every task's scores, pooled in task order. Requires the optional ``ray`` dependency. + + :param mc: the Monte Carlo simulation to run + :type mc: MonteCarloNoise + :param n_parallel: the number of ray tasks + :type n_parallel: int + :param n_single: the number of samples in each task + :type n_single: int + :return: the average infidelity over all n_parallel * n_single samples + :rtype: float + """ import ray rnd_array = mc.rng.choice(n_parallel * 100, n_parallel - 1, replace=False) diff --git a/graphiq/noise/noise_mapping.py b/graphiq/noise/noise_mapping.py new file mode 100644 index 00000000..5f5ba29a --- /dev/null +++ b/graphiq/noise/noise_mapping.py @@ -0,0 +1,110 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +A single noise lookup shared by ``SolverBase._identify_noise`` and +``CircuitDAG._noisy_gates``, so the two callers read a noise-model mapping the same way instead +of maintaining two independently drifting implementations. + +A noise-model mapping is a nested dict: the outer keys are register-type sections (``"e"``, +``"p"``, ``"ee"``, ``"ep"`` in every production mapping), and each section maps an operation's +class name (optionally suffixed ``_control``/``_target`` for a two-legged op) to a noise model. +""" + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm + + +def register_section(op) -> str: + """ + The mapping section an operation instance reads its noise from. + + For a ``ControlledPairOperationBase`` or ``ClassicalControlledPairOperationBase`` + instance this is ``op.control_type + op.target_type`` (e.g. ``"ee"``, ``"ep"``). For every + other op it is ``op.reg_type``. + + Every production mapping carries exactly the four sections ``"e"``, ``"p"``, ``"ee"``, + ``"ep"``. A section this function returns that the mapping does not carry (e.g. a + photon-controlled pair op giving ``"pe"`` or ``"pp"``) is not handled here: the caller's own + lookup raises ``KeyError``, exactly as ``CircuitDAG._noisy_gates`` did before this lookup was + shared. + + :param op: an operation instance + :type op: ops.OperationBase + :return: the section name + :rtype: str + """ + if isinstance( + op, (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase) + ): + return op.control_type + op.target_type + + return op.reg_type + + +def noise_for_op(op, section): + """ + The noise model(s) ``op`` reads out of one mapping section. + + ``op`` may be a class or an instance. For a ``ControlledPairOperationBase`` or + ``ClassicalControlledPairOperationBase`` (control, target), the per-leg keys + ``_control`` / ``_target`` are read first; the bare ```` key is only used as a + fallback when *neither* per-leg key is present, in which case a scalar value is broadcast to + both legs and a list value is returned as-is after checking it has length 2. So a section + that sets a per-leg key for one leg and also sets the bare key does not fall back to the + bare key for the other leg; that leg is ``NoNoise()``. + + For every other op, only the bare ```` key is read; its value (or ``NoNoise()``) is + returned unchanged. + + This matches what ``SolverBase._identify_noise`` returned before it delegated here for + bare scalar keys and for per-leg keys, except in three cases for a pair op. A bare list + value used to come back as a list of two copies of that list; a length-2 list is now + returned as it is, and any other length raises ``ValueError``. A bare key used to reach both + legs when every per-leg key present had the value ``NoNoise()``; a present per-leg key now + wins. + + :param op: an operation, as a class or an instance + :type op: type[ops.OperationBase] or ops.OperationBase + :param section: one section of a noise-model mapping (e.g. ``noise_model_mapping["ee"]``) + :type section: dict + :return: a noise model, or a list of two for a pair op + :rtype: nm.NoiseBase or list[nm.NoiseBase] + :raises ValueError: if a pair op's bare list value does not have length 2 + """ + is_instance = isinstance(op, ops.OperationBase) + cls = type(op) if is_instance else op + op_name = cls.__name__ + + if issubclass( + cls, (ops.ControlledPairOperationBase, ops.ClassicalControlledPairOperationBase) + ): + op_control = op_name + "_control" + op_target = op_name + "_target" + if op_control in section or op_target in section: + control_noise = section.get(op_control, nm.NoNoise()) + target_noise = section.get(op_target, nm.NoNoise()) + return [control_noise, target_noise] + if op_name in section: + value = section[op_name] + if isinstance(value, list): + if len(value) != 2: + raise ValueError( + f"{op_name} noise list must have length 2, got {len(value)}" + ) + return value + return [value, value] + return [nm.NoNoise(), nm.NoNoise()] + + return section.get(op_name, nm.NoNoise()) diff --git a/graphiq/noise/noise_models.py b/graphiq/noise/noise_models.py index dcf0e7f7..ca734c40 100644 --- a/graphiq/noise/noise_models.py +++ b/graphiq/noise/noise_models.py @@ -59,8 +59,9 @@ def __init__(self, noise_parameters=None): """ Initialize a base class for noise model - :param noise_parameters: a dictionary of parameters to describe the noise model - :type noise_parameters: dict + :param noise_parameters: a dictionary of parameters to describe the noise model, or + None for an empty dictionary + :type noise_parameters: dict or None :return: nothing :rtype: None """ @@ -88,10 +89,22 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class AdditionNoiseBase(NoiseBase): """ - Base class for noise added before the operation + Base class for noise added to the operation: after it by default, or before it if + noise_parameters["After gate"] is False """ def __init__(self, noise_parameters=None): + """ + Initialize a base class for noise added before or after a gate + + If ``noise_parameters`` is None or not a dict, it is replaced with ``{"After gate": True}``. Otherwise, an + ``"After gate"`` key set to True is added to it when not already present. + + :param noise_parameters: a dictionary of parameters to describe the noise model + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ if noise_parameters is None or type(noise_parameters) is not dict: noise_parameters = {"After gate": True} else: @@ -123,6 +136,15 @@ class ReplacementNoiseBase(NoiseBase): """ def __init__(self, noise_parameters=None): + """ + Initialize a base class for noisy gate that replaces the original gate + + :param noise_parameters: a dictionary of parameters to describe the noise model, or None for an empty + dictionary + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ super().__init__(noise_parameters) def apply(self, state: QuantumState, n_quantum, reg_list): @@ -155,6 +177,8 @@ def __init__(self): """ Construct a NoNoise object + :return: nothing + :rtype: None """ super().__init__() @@ -190,6 +214,7 @@ def __init__(self, one_qubit_unitary): def apply(self, state: QuantumState, n_quantum, reg_list): """ + Apply the noise to the state :param state: a quantum state to which the noise is applied :type state: QuantumState @@ -208,15 +233,13 @@ def apply(self, state: QuantumState, n_quantum, reg_list): n_quantum, reg_list[0], self.noise_parameters["One-qubit unitary"] ) state_rep.apply_unitary(noisy_gate) - elif isinstance(state_rep, Stabilizer): - gate_list = [] - if isinstance(state_rep, MixedStabilizer): - mixture = state_rep.mixture - for p_i, tableau_i in mixture: - tableau_i.apply_circuit(gate_list) - else: - state_rep.apply_circuit(gate_list) - + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): + # TODO: Implement this for the Stabilizer backend. A general + # one-qubit unitary is not generally Clifford, so it can't always + # be represented exactly in the stabilizer formalism. + raise NotImplementedError( + "OneQubitGateReplacement has not been implemented for stabilizer backend" + ) elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise TypeError( @@ -244,7 +267,7 @@ def __init__( Construct a TwoQubitControlledGateReplacement noise model :param target_unitary: the target gate to be applied to the target qubit if the control qubit is - in :math:`|0\\rangle` state + in :math:`|1\\rangle` state :type target_unitary: numpy.ndarray :param pre_gate_ctr_noise: the noise (unitary) added to the control qubit before the gate :type pre_gate_ctr_noise: numpy.ndarray @@ -303,7 +326,7 @@ def apply(self, state: QuantumState, n_quantum, ctr_reg, target_reg): unitary = post_gate_noise @ cu_gate @ pre_gate_noise state_rep.apply_unitary(unitary) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Implement this for Stabilizer backend raise NotImplementedError( "TwoQubitControlledGateReplacement error not implemented for stabilizer representation" @@ -311,7 +334,7 @@ def apply(self, state: QuantumState, n_quantum, ctr_reg, target_reg): elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise NotImplementedError( - "TwoQubitControlledGateReplacement error not implemented for stabilizer representation" + "TwoQubitControlledGateReplacement error not implemented for graph representation" ) else: raise TypeError("Backend type is not supported.") @@ -379,6 +402,22 @@ def apply(self, state, n_quantum, reg_list): raise ValueError("Wrong description of a Pauli matrix.") state_rep.apply_circuit(gate_list) + elif isinstance(state_rep, MixedStabilizer): + # MixedStabilizer is a sibling of Stabilizer, not a subclass, and it + # is what a noisy (non-Monte-Carlo) stabilizer compile produces. A + # Pauli is a unitary, so it is applied to every branch; the + # MixedStabilizer gate methods do exactly that. + if pauli_error == "X": + state_rep.apply_sigmax(reg_list[0]) + elif pauli_error == "Y": + state_rep.apply_sigmay(reg_list[0]) + elif pauli_error == "Z": + state_rep.apply_sigmaz(reg_list[0]) + elif pauli_error == "I": + pass + else: + raise ValueError("Wrong description of a Pauli matrix.") + elif isinstance(state_rep, Graph): # TODO: Implement this for Graph backend raise NotImplementedError( @@ -456,7 +495,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): else: raise ValueError("Wrong description of a local Clifford gate.") state_rep.apply_unitary(unitary) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Implement this for the Stabilizer backend raise NotImplementedError( "Local Clifford error has not been implemented for stabilizer backend" @@ -464,7 +503,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): elif isinstance(state_rep, Graph): # TODO: Implement this for the Graph backend raise NotImplementedError( - "Local Clifford error has not been implemented for stabilizer backend" + "Local Clifford error has not been implemented for graph backend" ) else: raise TypeError("Backend type is not supported.") @@ -472,7 +511,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class AmplitudeDampingNoise(AdditionNoiseBase): """ - Amplitude damping noise described by a depolarizing probability + Amplitude damping noise described by a damping probability """ @@ -482,6 +521,8 @@ def __init__(self, damping_probability): :param damping_probability: the dampening probability between 0 and 1 :type damping_probability: float + :return: nothing + :rtype: None """ noise_parameters = {"damping_probability": damping_probability} super().__init__(noise_parameters) @@ -505,7 +546,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): if damping_prob == 0.0: return single_qubit_kraus = dmf.amplitude_damping_operators(damping_prob) - kraus_ops_iter = itertools.combinations(single_qubit_kraus, len(reg_list)) + kraus_ops_iter = itertools.product(single_qubit_kraus, repeat=len(reg_list)) kraus_ops = [] for kraus_op in kraus_ops_iter: kraus_ops.append( @@ -514,7 +555,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): state_rep.apply_channel(kraus_ops) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "AmplitudeDamping not implemented for stabilizer representation" @@ -540,6 +581,8 @@ def __init__(self, depolarizing_prob): :param depolarizing_prob: the depolarizing probability :type depolarizing_prob: float + :return: nothing + :rtype: None """ noise_parameters = {"Depolarizing probability": depolarizing_prob} super().__init__(noise_parameters) @@ -592,8 +635,13 @@ def apply(self, state: QuantumState, n_quantum, reg_list): state_rep, MixedStabilizer ): if not isinstance(state_rep, MixedStabilizer): - rep_data = MixedStabilizer([(1.0, state_rep.data)]) - state.rep_data = rep_data + # Rebind the local as well as the state, since everything below + # branches over the mixture. The mixed flag has to be set + # before the assignment, which the state otherwise rejects as + # the wrong datatype. + state_rep = MixedStabilizer([(1.0, state_rep.data)]) + state.mixed = True + state.rep_data = state_rep original_prob = state_rep.probability mixture = [] @@ -674,7 +722,7 @@ class PhasePerturbedError(OneQubitGateReplacement): def __init__(self, theta_pert, phi_pert, lam_pert): """ - Construct a HadamardPerturbedError object + Construct a PhasePerturbedError object :param theta_pert: the perturbation added to the theta angle :type theta_pert: float @@ -703,7 +751,7 @@ class SigmaXPerturbedError(OneQubitGateReplacement): def __init__(self, theta_pert, phi_pert, lam_pert): """ - Construct a HadamardPerturbedError object + Construct a SigmaXPerturbedError object :param theta_pert: the perturbation added to the theta angle :type theta_pert: float @@ -739,6 +787,14 @@ class PhotonLoss(AdditionNoiseBase): """ def __init__(self, loss_rate): + """ + Construct a PhotonLoss object + + :param loss_rate: the probability that a photon is lost while undergoing this gate + :type loss_rate: float + :return: nothing + :rtype: None + """ noise_parameters = {"loss rate": loss_rate} super().__init__(noise_parameters) @@ -803,6 +859,8 @@ def __init__(self, unitaries_list, prob_list): :type unitaries_list: list[numpy.ndarray] :param prob_list: the corresponding probability distribution for the unitaries :type prob_list: list[float] + :return: nothing + :rtype: None """ noise_parameters = {"Unitaries": unitaries_list, "Probabilities": prob_list} super().__init__(noise_parameters) @@ -826,7 +884,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "MixedUnitary not implemented for DensityMatrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "MixedUnitary not implemented for Stabilizer representation" @@ -852,7 +910,7 @@ def __init__(self, unitary): Construct a coherent unitary error described by a single unitary :param unitary: a unitary that specified the error - :type unitary: numpy.nadrray or str + :type unitary: numpy.ndarray or str :return: nothing :rtype: None """ @@ -878,7 +936,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "CoherentUnitaryError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "CoherentUnitaryError not implemented for stabilizer representation" @@ -894,7 +952,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): class MeasurementError(NoiseBase): """ - a measurement error described by a conditional probability distribution + A measurement error described by a conditional probability distribution # TODO: implement this error model """ @@ -931,7 +989,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "MeasurementError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "MeasurementError not implemented for stabilizer representation" @@ -951,7 +1009,8 @@ class GeneralKrausError(AdditionNoiseBase): This error may only work for the DensityMatrix backend. - # TODO: Implement this noise model by figuring out how to pass parameters + The stored operators are single-qubit; each is applied independently on + every register in ``reg_list``. """ def __init__(self, kraus_ops): @@ -977,12 +1036,25 @@ def apply(self, state: QuantumState, n_quantum, reg_list): :param reg_list: a list of registers where non-identity gates are applied :type reg_list: list[int] :return: nothing + :rtype: None """ state_rep = state.rep_data if isinstance(state_rep, DensityMatrix): - kraus_ops = self.get_backend_dependent_noise(state_rep, n_quantum, reg_list) + single_qubit_kraus = self.noise_parameters["Kraus"] + for kraus_op in single_qubit_kraus: + if np.array(kraus_op).shape != (2, 2): + raise ValueError( + "GeneralKrausError only supports single-qubit Kraus " + "operators (shape (2, 2)), applied independently on " + "every register in reg_list." + ) + kraus_ops_iter = itertools.product(single_qubit_kraus, repeat=len(reg_list)) + kraus_ops = [ + dmf.get_multi_qubit_gate(n_quantum, reg_list, kraus_op) + for kraus_op in kraus_ops_iter + ] state_rep.apply_channel(kraus_ops) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): raise NotImplementedError( "GeneralKrausError not implemented (and not compatible) for stabilizer representation" ) @@ -1001,7 +1073,16 @@ class ResetError(NoiseBase): # TODO: implement this error model """ - def __init__(self, noise_parameters={}): + def __init__(self, noise_parameters=None): + """ + Construct a ResetError object + + :param noise_parameters: a dictionary of parameters to describe the noise model, or None for an empty + dictionary + :type noise_parameters: dict or None + :return: nothing + :rtype: None + """ super().__init__(noise_parameters) def apply(self, state: QuantumState, n_quantum, reg_list): @@ -1023,7 +1104,7 @@ def apply(self, state: QuantumState, n_quantum, reg_list): raise NotImplementedError( "ResetError not implemented for density matrix representation" ) - elif isinstance(state_rep, Stabilizer): + elif isinstance(state_rep, (Stabilizer, MixedStabilizer)): # TODO: Find the correct representation for Stabilizer backend raise NotImplementedError( "ResetError not implemented for stabilizer representation" diff --git a/graphiq/noise/time_depend_noise.py b/graphiq/noise/time_depend_noise.py index f9f767ac..841463da 100644 --- a/graphiq/noise/time_depend_noise.py +++ b/graphiq/noise/time_depend_noise.py @@ -12,7 +12,20 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Time-dependent noise as a Poisson process over the gate durations of an ideal circuit. + +``NoisyEnsemble`` partitions each noisy register's operation history into sections (``section_finder``), finds the +probability of each placement of errors across those sections from the section durations and an error rate +(``prob_tree_finder``, using a Poisson distribution -- ``poisson_prob`` -- truncated at a cut-off probability, +``cut_off_counter``), and expresses the result as a weighted list of ``NoiseEvent`` objects (``to_event_tree``), one +event tree per noisy register and noise type, all merged into one (``unwrapper``). ``insert_error`` turns an event +tree into a list of noisy circuits and their probabilities -- a "circuit tree" -- by inserting the corresponding error +operation, from ``graphiq.noise.model_parameters.error_ops``, into a copy of the ideal circuit. +""" + import copy +import math from itertools import combinations_with_replacement import numpy as np @@ -32,8 +45,8 @@ class NoisyEnsemble: """ - NoisyEnsemble class. For a quantum circuit, this class explores all different possibilities to make it noisy given - the noise parameters and list of imperfect registers. + Explores every way to make a quantum circuit noisy, given noise parameters and a list of + imperfect registers. """ def __init__( @@ -41,7 +54,7 @@ def __init__( ): """ Creates a NoisyEnsemble object that allows an ideal circuit to turn into a noisy one by inserting errors to the - set of noisy registers (noisy_regs). The noise/error model and its properties in specified by the + set of noisy registers (noisy_regs). The noise/error model and its properties are specified by the noise_parameters. :param circ: the ideal quantum circuit @@ -52,7 +65,7 @@ def __init__( circuit. :type noise_parameters: dict :param gate_duration_dict: the gate duration dictionary for the operations used in the circuit - :type: dict + :type gate_duration_dict: dict """ if noise_parameters is None: noise_parameters = mp.noise_parameters @@ -92,14 +105,16 @@ def to_event_tree( A helper function that translate the output of the prob_tree_finder function into a list of NoiseEvent objects. We call this list an event tree. - :param prob_tree_found: the list [(error happening at certain (sections indices, ), probability of this event)] - and the list of sections - :type prob_tree_found: list[((, ), float)], list + :param prob_tree_found: a 2-tuple: the list + [(error happening at certain (sections indices, ), probability of this event)], + and the list of sections + :type prob_tree_found: tuple[list[tuple[tuple[int, ...], float]], list] :param reg: the register where noise events are happening :type reg: int :param reg_type: type of the register :type reg_type: str - :param noise_type: str or list[str] + :param noise_type: the noise type to tag each event with + :type noise_type: str :return: list of events :rtype: list[NoiseEvent] """ @@ -135,13 +150,14 @@ def reg_history(circ, reg, reg_type): def all_branches(self): """ This method calculates all possible NoiseEvents that can be applied on the NoisyEnsemble object. It corresponds - to the event tree that is resulted from adding noise to the circuit. An event tree is a list of branches, where - each branch has a probability of happening and corresponds to a series of error gates happening at different - parts of the ideal circuit. The total probability of an event tree is between the cut-off probability, which - can be set in noise parameters, and unity. + to the event tree that is resulted from adding noise to the circuit. An event tree is a list of branches, where + each branch has a probability of happening and corresponds to a series of error gates happening at different + parts of the ideal circuit. The total probability of each event tree built for a noisy register (one per noise + type) is between the cut-off probability, which can be set in noise parameters, and unity; the combined event + tree's total is their product and can be lower. :return: An event tree, which is a list of NoiseEvents - :rtype: list[NoiseEvents] + :rtype: list[NoiseEvent] """ event_tree_list = [] for noisy_reg in self.noisy_regs: @@ -192,15 +208,15 @@ def circuit_tree(self): The list of all possible noisy circuits and their probabilities, resulted by applying noise to the ideal circuit :return: circuit tree; which is a list of circuits and their respective probability of happening in tuples - :rtype: list[(, )] + :rtype: list[tuple[CircuitDAG, float]] """ return insert_error(self.circ, self.all_branches()) def output_state(self, backend="stabilizer"): """ - Calculates the resulting output quantum state once the noisy circuit is complied. + Calculates the resulting output quantum state once the noisy circuit is compiled. - :param backend: the backend in used to compile the noisy circuit. Right now only stabilizer is allowed. + :param backend: the backend used to compile the noisy circuit. Right now only stabilizer is allowed. :type backend: str :return: the noisy quantum state produced by the quantum circuit :rtype: QuantumState @@ -228,11 +244,11 @@ def output_state(self, backend="stabilizer"): def noise_type_list(self, noisy_reg): """ Gets the noisy register as a tuple (reg, reg_type) and returns a list of noise types that must be applied on - that register.Used to find the register specific noise type from noise parameters' dictionary if there exists + that register. Used to find the register specific noise type from noise parameters' dictionary if there exists any. :param noisy_reg: the noisy register and its type (emitter: 'e' or photon: 'p') - :type noisy_reg: tuple(int, str) + :type noisy_reg: tuple[int, str] :return: list of noise types :rtype: list[str] """ @@ -267,7 +283,7 @@ def noise_type_list(self, noisy_reg): class NoiseEvent: """ - The class to represent noise events. Each noise event has its own probability of happening and correspond to + The class to represent noise events. Each noise event has its own probability of happening and corresponds to inserting a series of errors before certain gates in the quantum circuit to make it noisy compared to an ideal circuit. """ @@ -282,7 +298,7 @@ def __init__(self, prob, nodes_and_regs): :type prob: float :param nodes_and_regs: a list [(node, (reg, reg_type), noise_type)] to specify where in the DAG error gate are supposed to be inserted and what type of error is used for that specific location. - :type nodes_and_regs: + :type nodes_and_regs: list[tuple] """ self._prob = prob self._nodes_and_regs = ( @@ -309,13 +325,17 @@ def nodes_and_regs(self, value): def nodes(self): """ - :return: the set of node that participate in this event + The set of nodes that participate in this event. + + :return: the set of nodes that participate in this event :rtype: set """ return set([nod[0] for nod in self.nodes_and_regs]) def noises(self): """ + The set of noise types used in this event. + :return: the set of noise types used in the event :rtype: set """ @@ -327,7 +347,10 @@ def merge_with(self, events): accordingly. :param events: a list of NoiseEvent objects or a single one - :return: None + :type events: list[NoiseEvent] or NoiseEvent + :raises ValueError: if events is not a NoiseEvent or a list of NoiseEvent objects + :return: nothing + :rtype: None """ # events = a list of noise event objects or a single event if isinstance(events, list): @@ -352,7 +375,7 @@ def nodes_of_type(circ, node_list, gate_types): :param node_list: a list of some operation nodes in the DAG :type node_list: list :param gate_types: a single operation type or a list of operation types - :type gate_types: any subclass of OperationBase or a list of them + :type gate_types: type[ops.OperationBase] or list[type[ops.OperationBase]] :return: list of the nodes that are of the given types :rtype: list """ @@ -370,13 +393,14 @@ def nodes_of_type(circ, node_list, gate_types): def multi_reg_nodes(circ, node_list): """ - Given a list of nodes in a circuit DAG, it returns the multi-register nodes. (n-qubit gates, measurements, ect.) + Given a list of nodes in a circuit DAG, it returns the multi-register nodes. (n-qubit gates, measurements, etc.) :param circ: a quantum circuit :type circ: CircuitDAG :param node_list: a list of some operation nodes in the DAG :type node_list: list - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: list of the nodes whose operation class directly subclasses ControlledPairOperationBase or + ClassicalControlledPairOperationBase :rtype: list """ multi_reg_superclass = [ @@ -405,7 +429,7 @@ def reg_as_control( :type reg: int :param reg_type: type of the register :type reg_type: str - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: the nodes in node_list where reg (of type reg_type) is the control register :rtype: list """ multi_nodes = multi_reg_nodes(circ, node_list) @@ -427,7 +451,8 @@ def emission_finder(circ, node_list): :type circ: CircuitDAG :param node_list: a list of some operation nodes in the DAG :type node_list: list - :return: list of the nodes that have more than one input or output edges in the DAG. + :return: the nodes in node_list that are emission events -- an emitter-to-photon CNOT that + is the first gate applied to that photon :rtype: list """ # checks whether the CNOT on an emitter and a photon is the first gate that has ever been applied to the photon. @@ -460,9 +485,9 @@ def section_finder(circ, ordered_path, criteria, reg=0, reg_type="e"): :type reg: int :param reg_type: type of the register :type reg_type: str - :raises: ValueError if the criteria is not valid - :return: list of the nodes that have more than one input or output edges in the DAG. - :rtype: list + :raises ValueError: if the criteria is not valid + :return: the list of sections; each section is itself a list of nodes + :rtype: list[list] """ # (] the last operation=slicer is included in each section: the slicer node itself is in the previous section # criteria='based_on_a list of types or super classes= string or list of types' @@ -480,6 +505,11 @@ def section_finder(circ, ordered_path, criteria, reg=0, reg_type="e"): slice_nodes = reg_as_control(circ, ordered_path, reg, reg_type) elif criteria == "all_gates": slice_nodes = ordered_path + else: + raise ValueError( + "criteria should be either a list of class types in graphiq.ops or one of the strings: measure, " + "emission, multi_reg_gates, reg_as_control, all_gates" + ) elif isinstance(criteria, list): slice_nodes = [ nod for nod in ordered_path if type(circ.dag.nodes[nod]["op"]) in criteria @@ -524,7 +554,7 @@ def section_duration(circ, section_nodes, gate_duration_dict): def seq_time_tuples(circ, list_of_sections, gate_duration_dict): """ - Given a list of sections,this helper function calculates the duration of each section and returns a list of tuples, + Given a list of sections, this helper function calculates the duration of each section and returns a list of tuples, with each tuple including the index of the section in the list and its time duration. :param circ: a quantum circuit @@ -534,7 +564,7 @@ def seq_time_tuples(circ, list_of_sections, gate_duration_dict): :param gate_duration_dict: a dictionary of {gates: their respective duration} :type gate_duration_dict: dict :return: list of the tuples (section index, duration) - :rtype: list + :rtype: list[tuple[int, float]] """ time_list = [] section_index = 0 @@ -559,7 +589,7 @@ def poisson_prob(count, time, rate): :return: the probability of the specified number of events happening :rtype: float """ - return np.exp(-rate * time) * ((rate * time) ** count) / np.math.factorial(count) + return np.exp(-rate * time) * ((rate * time) ** count) / math.factorial(count) def cut_off_counter(cut_off_prob, time, rate): @@ -569,12 +599,13 @@ def cut_off_counter(cut_off_prob, time, rate): :param cut_off_prob: the minimum total probability of all events we want to consider :type cut_off_prob: float + :param time: the time duration :type time: float :param rate: the average rate of events happening per unit time :type rate: float :return: the maximum number of events needed to be considered, and the actual total probability of cases considered with this cut-off count. - :rtype: int + :rtype: tuple[int, float] """ cut_off_count = 0 prob_sum = poisson_prob(cut_off_count, time, rate) @@ -591,7 +622,7 @@ def prob_tree_finder( circ, list_of_sections, error_rate, cut_off_prob, gate_duration_dict ): """ - Given a list of sections, this function calculated all possible needed outcomes in a probability tree, where each + Given a list of sections, this function calculates all possible needed outcomes in a probability tree, where each branch represents having the error gates happening at a certain number of sections. It returns the probability tree and the list of sections used for that tree. The probability tree is a list of tuples where the first element is itself a tuple of section indices (in the list of sections) where error is supposed to happen and the second @@ -607,9 +638,10 @@ def prob_tree_finder( :type cut_off_prob: float :param gate_duration_dict: a dictionary of {gates: their respective duration} :type gate_duration_dict: dict - :return: a list of tuples [(error happening at certain (sections indices, ), probability of this event)] and the - list of sections - :rtype: list[((, ), float)], list + :return: a 2-tuple: a list of tuples + [(error happening at certain (sections indices, ), probability of this event)], + and the list of sections + :rtype: tuple[list[tuple[tuple[int, ...], float]], list] """ # remove sections with zero time duration from the input list of sections. section_times = seq_time_tuples( @@ -657,15 +689,15 @@ def prob_tree_finder( def insert_error(circ, all_branches): """ - Given a circuit and a list noise events, this function returns a list of modified circuits with respect to the list - of noise events. The probability of each modified circuit is also returned . We call this a circuit tree. + Given a circuit and a list of noise events, this function returns a list of modified circuits with respect to the list + of noise events. The probability of each modified circuit is also returned. We call this a circuit tree. :param circ: a quantum circuit :type circ: CircuitDAG :param all_branches: the list of all NoiseEvents that need to be applied on the circuit. :type all_branches: list[NoiseEvent] :return: circuit tree; which is a list of circuits and their respective probability of happening in tuples - :rtype: list[(, )] + :rtype: list[tuple[CircuitDAG, float]] """ # all_branches is the list of all NoiseEvents that need to be applied on circuit. For a single noise event in the # list, we have the node, register and its type, noise_type [(node, (reg, reg_type), noise_type)] and its @@ -693,17 +725,17 @@ def insert_error(circ, all_branches): def unwrapper(event_tree_list, events_to_merge=None, all_events=None, ii=0): """ - This recursive function combines different individual event trees into a single one. Each event tree consist of a + This recursive function combines different individual event trees into a single one. Each event tree consists of a number of branches (list of NoiseEvents) that sum up to probability one. :param event_tree_list: a list of event trees :type event_tree_list: list :param events_to_merge: an internal parameter which is a list to keep branches and need to be merged into a single one. The default value should not be changed when function is called. - :type events_to_merge: list + :type events_to_merge: list or None :param all_events: an internal parameter that corresponds to the final combined event tree as branches are added to it through the runtime. The default value should not be changed when function is called. - :type all_events: list + :type all_events: list or None :param ii: internal parameter used in the recursive algorithm. The default value should not be changed when function is called. :type ii: int diff --git a/graphiq/solvers/__init__.py b/graphiq/solvers/__init__.py index 02a41768..9ecb9b79 100644 --- a/graphiq/solvers/__init__.py +++ b/graphiq/solvers/__init__.py @@ -12,4 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Solvers that search for a circuit generating a target state. +""" + from graphiq.solvers.solver_base import SolverBase, RandomSearchSolver diff --git a/graphiq/solvers/alternate_target_solver.py b/graphiq/solvers/alternate_target_solver.py index 66338178..83f09969 100644 --- a/graphiq/solvers/alternate_target_solver.py +++ b/graphiq/solvers/alternate_target_solver.py @@ -54,6 +54,29 @@ class AlternateTargetSolver: + """ + Searches for circuits generating the target graph by way of alternative target graphs. + + Rather than one fixed target, the solver also tries isomorphic relabellings of the target and + local-Clifford-equivalent graphs of each relabelling, since these can be far cheaper to + generate. Each candidate's circuit comes from TimeReversedSolver, followed by the + local-Clifford gates that convert it to the relabelled target. + + How candidates are scored depends on noise_model_mapping and the setting's monte_carlo flag, + and every path writes to the same ``score`` column of the result: + + - noise simulation off (noise_model_mapping None): no noisy simulation is scored, and every + candidate gets the placeholder score 0.0001. + - noise simulation on, Monte Carlo off: the noisy circuit is compiled on ``noise_compiler`` + and scored with Infidelity against the relabelled target (see noise_score). + - noise simulation on, Monte Carlo on: the circuit is run on the compiler in + ``monte_carlo_params`` (a StabilizerCompiler when that entry is None) and scored against a + noiseless compile of that candidate's own circuit, so it reports sensitivity to noise + rather than agreement with the target, and is not comparable with the other path. + + ``compiler`` and ``metric`` are stored, but no path of solve() uses them. + """ + def __init__( self, target: nx.Graph or QuantumState = None, @@ -66,7 +89,7 @@ def __init__( seed=None, ): """ - Constructor for AlternateGraphSolver + Constructor for AlternateTargetSolver :param target: target graph state :type target: QuantumState or nx.Graph @@ -78,12 +101,18 @@ def __init__( :type noise_compiler: CompilerBase :param io: input/output :type io: IO - :param noise_model_mapping: - :type noise_model_mapping: dict - :param solver_setting: - :type solver_setting: + :param noise_model_mapping: a noise model mapping keyed by register-type section ("e", "p", "ee", "ep"); + the string "depolarizing" uses depolarizing noise at the solver setting's depolarizing_rate, and None + turns noise simulation off + :type noise_model_mapping: dict or str or None + :param solver_setting: the solver setting; if None, a default setting is created + :type solver_setting: AlternateTargetSolverSetting or None :param seed: a random seed :type seed: int + :raises TypeError: if noise_model_mapping is not exactly a dict (subclasses are rejected), the string + "depolarizing", or None + :raises ValueError: if target is a mixed QuantumState, or a QuantumState whose rep_type is not + "s", "dm", or "g" """ if solver_setting is None: solver_setting = AlternateTargetSolverSetting( @@ -154,26 +183,36 @@ def solve(self): alternative isomorphic and LC-equivalent graphs. Notice that the returned circuits generate the target state up to relabeling if this feature is enabled in the setting. Otherwise, user's exact target graph is produced. - :return: a dictionary where keys are the circuits and values are themselves dictionaries containing the LC graph - used as the intermediate states and relabeling map between the actual target and the graph the circuit - generates. {circuit: {'g':graph, 'map': relabel_map}} - :rtype: dict + :return: a list of (circuit, properties) tuples, where properties is a dictionary containing the LC graph + used as the intermediate state, the circuit's score, and the relabeling map between the actual target and + the graph the circuit generates: [(circuit, {'g': graph, 'score': score, 'map': relabel_map}), ...]. + The same data is also stored in self.result as a SolverResult + :rtype: list[tuple] """ setting = self.solver_setting n_iso = setting.n_iso_graphs n_lc = setting.n_lc_graphs adj_matrix = nx.to_numpy_array(self.target_graph) - iso_adjs = iso_finder( - adj_matrix, - n_iso, - rel_inc_thresh=setting.rel_inc_thresh, - allow_exhaustive=setting.allow_exhaustive, - sort_emit=setting.sort_emitter, - label_map=setting.label_map, - thresh=setting.iso_thresh, - seed=self.seed, - ) + if setting.allow_relabel: + iso_adjs = iso_finder( + adj_matrix, + n_iso, + rel_inc_thresh=setting.rel_inc_thresh, + allow_exhaustive=setting.allow_exhaustive, + sort_emit=setting.sort_emitter, + label_map=setting.label_map, + thresh=setting.iso_thresh, + seed=self.seed, + ) + # with label_map set, iso_finder returns (adjacency matrices, label maps); the + # maps are not needed here, since each graph's relabel map is computed below + # with get_relabel_map + if isinstance(iso_adjs, tuple): + iso_adjs = iso_adjs[0] + else: + # only the labelling given + iso_adjs = [adj_matrix] results_list = [] mc_list = [] # repeater graph state check @@ -184,7 +223,10 @@ def solve(self): # list of tuples [(circuit, dict={'g': graph used to find circuit, 'map': relabel map with target})] iso_graphs = [nx.from_numpy_array(adj) for adj in iso_adjs] for iso_graph in iso_graphs: - if setting.lc_method == "rgs": + if not setting.allow_lc: + # each isomorphic graph is its own only candidate + lc_graphs = [iso_graph] + elif setting.lc_method == "rgs": lc_graphs = rgs_orbit_finder(iso_graph)[:n_lc] elif setting.lc_method == "linear": lc_graphs = linear_partial_orbit(iso_graph)[:n_lc] @@ -345,20 +387,21 @@ def solve(self): def noise_score(self, circuit, relabel_map): """ - - - :param circuit: - :type circuit: - :param relabel_map: - :type relabel_map: - :return: - :rtype: + Score a circuit on the noise compiler against the target graph relabeled by relabel_map. + Emitter qubits are traced out before scoring, and a score that is close to, but not exactly, 0 is + replaced by 1e-05. + + :param circuit: the circuit to score + :type circuit: CircuitDAG + :param relabel_map: the relabeling map from the target graph's node labels to the new labels + :type relabel_map: dict + :return: the infidelity of the compiled state with respect to the relabeled target + :rtype: float """ graph = self.target_graph old_adj = nx.to_numpy_array(graph) - n = graph.number_of_nodes() # relabel the target graph - new_labels = np.array([relabel_map[i] for i in range(n)]) + new_labels = np.array([relabel_map[v] for v in graph.nodes()]) new_adj = relabel(old_adj, new_labels) graph = nx.from_numpy_array(new_adj) @@ -384,10 +427,12 @@ def noise_score(self, circuit, relabel_map): def depol_noise_map(self): """ + Returns a noise model mapping with depolarizing noise at the rate depolarizing_rate on the one-qubit + emitter gates and on the emitter-emitter and emitter-photon CNOT gates. One-qubit photonic gates are + noiseless. - - :return: - :rtype: + :return: the noise model mapping, keyed by register-type section + :rtype: dict """ rate = self.depolarizing_rate @@ -461,18 +506,27 @@ def graph_to_circ(graph, noise_model_mapping=None, show=False): :param graph: The graph to generate :type graph: networkx.Graph - :param noise_model_mapping: - :type noise_model_mapping: dict + :param noise_model_mapping: a noise model mapping passed to TimeReversedSolver, or None for no noise + :type noise_model_mapping: dict or None :param show: If true draws the corresponding circuit :type show: bool :return: A circuit corresponding to the input graph :rtype: CircuitDAG + :raises ValueError: if the graph has an isolated vertex """ if not isinstance(graph, nx.Graph): graph = nx.from_numpy_array(graph) assert isinstance( graph, nx.Graph ), "input must be a networkx graph object or a numpy adjacency matrix" + n_isolated = nx.number_of_isolates(graph) + if n_isolated: + raise ValueError( + f"graph has an isolated vertex ({n_isolated} in total); an isolated " + "vertex is a photon entangled with no other qubit, which " + "TimeReversedSolver cannot absorb into an emitter, so graph_to_circ " + "does not support it" + ) n = graph.number_of_nodes() c_tableau = get_clifford_tableau_from_graph(graph) ideal_state = QuantumState(c_tableau, rep_type="s") @@ -517,11 +571,75 @@ def __init__( monte_carlo=False, monte_carlo_params=None, graph_metric=pre.graph_metric_lists[0], - lc_method="max edge", + lc_method=None, verbose=False, save_openqasm: str = "none", - callback_func: dict = {}, + callback_func: dict = None, ): + """ + Create an AlternateTargetSolverSetting object + + :param allow_relabel: whether isomorphic relabellings of the target are searched. + When False, only the labelling given is used, and n_iso_graphs is ignored + :type allow_relabel: bool + :param n_iso_graphs: the number of isomorphic relabellings to look for, including the + original graph + :type n_iso_graphs: int + :param rel_inc_thresh: threshold between 0 and 1 on the relative increase in how many + distinct isomorphic graphs are found as the label pool grows; the search stops once + that ratio falls below it, so a value closer to 0 searches closer to exhaustively + :type rel_inc_thresh: float + :param allow_exhaustive: whether the relabelling search may enumerate every permutation + rather than sampling; the runtime can be very long if it does + :type allow_exhaustive: bool + :param sort_emit: whether the graphs found are sorted, after the search, by the number + of emitters needed to generate each + :type sort_emit: bool + :param label_map: passed to iso_finder, which then also returns a label map per graph. + solve() keeps only the adjacency matrices from that tuple and computes each relabel + map itself, so this does not change the result + :type label_map: bool + :param iso_thresh: how many random label trials to attempt while looking for new + permutations; None uses five times the number of labellings sought + :type iso_thresh: int or None + :param allow_lc: whether local-Clifford-equivalent graphs are tried at all. When + False, each isomorphic graph is its own only candidate and lc_method is not + consulted + :type allow_lc: bool + :param n_lc_graphs: the number of local-Clifford-equivalent graphs to try, or None for + no cap + :type n_lc_graphs: int or None + :param lc_orbit_depth: how far into the local-complementation orbit to search, or None + to continue until enough graphs are found or no new ones appear; the "rgs", "linear" + and "depth_first" methods ignore it + :type lc_orbit_depth: int or None + :param depolarizing_rate: the depolarizing rate used to build the noise map when the + solver's noise_model_mapping is "depolarizing" + :type depolarizing_rate: float + :param monte_carlo: whether candidates are scored with Monte Carlo noise; only consulted + when noise simulation is on + :type monte_carlo: bool + :param monte_carlo_params: overrides merged over the Monte Carlo defaults (n_sample, + map, compiler, seed, n_parallel, n_single); None takes the defaults unchanged + :type monte_carlo_params: dict or None + :param graph_metric: a function scoring a graph, called as graph_metric(graph). Stored + for callers; nothing in the solver reads it + :type graph_metric: function + :param lc_method: which local-complementation search to use -- one of "rgs", "linear", + "depth_first", "lc_with_iso", "random", "random_with_iso", "random_with_rep", or + None, the default, which searches the orbit without a specialised strategy. Any + other value makes solve() raise ValueError when allow_lc is True + :type lc_method: str or None + :param verbose: stored for callers; AlternateTargetSolver never prints progress + :type verbose: bool + :param save_openqasm: stored for callers; AlternateTargetSolver never writes openQASM + :type save_openqasm: str + :param callback_func: stored for callers, no callback is ever invoked; an empty dict + is built when None + :type callback_func: dict or None + :return: nothing + :rtype: None + """ self.allow_relabel = allow_relabel self.allow_lc = allow_lc self._n_iso_graphs = n_iso_graphs @@ -535,7 +653,8 @@ def __init__( self.sort_emitter = sort_emit self.label_map = label_map self.iso_thresh = iso_thresh - self.callback_func = callback_func + # built per instance rather than shared through a mutable default + self.callback_func = {} if callback_func is None else callback_func self.lc_orbit_depth = lc_orbit_depth self.depolarizing_rate = depolarizing_rate self.monte_carlo = monte_carlo diff --git a/graphiq/solvers/evolutionary_solver.py b/graphiq/solvers/evolutionary_solver.py index 8abbbe66..3cc87317 100644 --- a/graphiq/solvers/evolutionary_solver.py +++ b/graphiq/solvers/evolutionary_solver.py @@ -50,6 +50,29 @@ def __init__( verbose=False, save_openqasm: str = "none", ): + """ + Create an EvolutionarySolverSetting object + + :param n_hof: the number of best circuits kept in the hall of fame + :type n_hof: int + :param n_stop: the number of iterations to run before stopping + :type n_stop: int + :param n_pop: the number of candidate circuits held per iteration + :type n_pop: int + :param tournament_k: the number of candidates entered in each selection tournament + :type tournament_k: int + :param selection_active: whether tournament selection is applied between iterations + :type selection_active: bool + :param use_adapt_probability: whether the transition probabilities adapt during the search + :type use_adapt_probability: bool + :param verbose: whether the solver prints the best score after each iteration + :type verbose: bool + :param save_openqasm: which circuits are saved as openQASM each iteration when the solver + has an IO: "hof", "pop" or "both"; "none" (or any other value) saves nothing + :type save_openqasm: str + :return: nothing + :rtype: None + """ super().__init__(n_hof=n_hof, n_stop=n_stop, n_pop=n_pop) self._tournament_k = tournament_k self._selection_active = selection_active @@ -130,7 +153,7 @@ def __init__( io: IO = None, n_emitter=1, n_photon=1, - solver_setting=EvolutionarySolverSetting(), + solver_setting=None, noise_model_mapping=None, *args, **kwargs, @@ -153,9 +176,15 @@ def __init__( :type n_emitter: int :param n_photon: number of photon registers to maintain in the circuit :type n_photon: int + :param solver_setting: the solver's settings; a fresh default is built when None + :type solver_setting: EvolutionarySolverSetting or None :param noise_model_mapping: a dictionary that associates each operation type to a noise model :type noise_model_mapping: dict """ + # built per instance: a default instance in the signature would be + # shared (and mutated) by every solver constructed without one + if solver_setting is None: + solver_setting = EvolutionarySolverSetting() super().__init__( target=target, metric=metric, @@ -277,20 +306,24 @@ def adapt_probabilities(self): :rtype: None """ # TODO: check whether the input iteration is needed. - self.trans_probs[self.add_emitter_one_qubit_op] = max( - self.trans_probs[self.add_emitter_one_qubit_op] - - 1 / self.setting.n_stop / 3, - 0.01, - ) - self.trans_probs[self.replace_photon_one_qubit_op] = max( - self.trans_probs[self.replace_photon_one_qubit_op] - - 1 / self.setting.n_stop / 3, - 0.01, - ) - self.trans_probs[self.remove_op] = min( - self.trans_probs[self.remove_op] + 1 / self.setting.n_stop, 0.99 - ) - if self.n_emitter > 1: + # trans_probs may not contain every transformation; adapt only those present + if self.add_emitter_one_qubit_op in self.trans_probs: + self.trans_probs[self.add_emitter_one_qubit_op] = max( + self.trans_probs[self.add_emitter_one_qubit_op] + - 1 / self.setting.n_stop / 3, + 0.01, + ) + if self.replace_photon_one_qubit_op in self.trans_probs: + self.trans_probs[self.replace_photon_one_qubit_op] = max( + self.trans_probs[self.replace_photon_one_qubit_op] + - 1 / self.setting.n_stop / 3, + 0.01, + ) + if self.remove_op in self.trans_probs: + self.trans_probs[self.remove_op] = min( + self.trans_probs[self.remove_op] + 1 / self.setting.n_stop, 0.99 + ) + if self.n_emitter > 1 and self.add_emitter_cnot in self.trans_probs: self.trans_probs[self.add_emitter_cnot] = max( self.trans_probs[self.add_emitter_cnot] - 1 / self.setting.n_stop / 3, 0.01, @@ -467,14 +500,19 @@ def update_logs(self, population: list, iteration: int): :type population: list :param iteration: iteration integer, from 0 to n_stop-1 :type iteration: int - :return: + :return: nothing + :rtype: None """ # get the scores from the population/hof as a list scores_pop = list(zip(*population))[0] - scores_hof = list(zip(*self.hof))[0] + # skip unfilled hall-of-fame slots here too; a real candidate scored inf + # still fills a slot and counts + scores_hof = [score for (score, circuit) in self.hof if circuit is not None] depth_pop = [circuit.depth for (_, circuit) in population] - depth_hof = [circuit.depth for (_, circuit) in self.hof] + # self.hof can still contain unfilled (np.inf, None) placeholder + # entries (e.g. whenever n_pop < n_hof), which have no circuit/depth + depth_hof = [circuit.depth for (_, circuit) in self.hof if circuit is not None] self.logs["population"].append( dict( @@ -509,9 +547,13 @@ def save_circuits(self, population: list, hof: list, iteration: int = -1): Saves the population and/or the HoF circuits over iterations as openQASM strings. :param population: list of (score, circuit) pairs + :type population: list :param hof: list of (score, circuit) pairs + :type hof: list :param iteration: integer step in the solver algorithm - :return: + :type iteration: int + :return: nothing + :rtype: None """ if self.io is None or self.setting.save_openqasm == "none": return @@ -528,12 +570,37 @@ def save_circuits(self, population: list, hof: list, iteration: int = -1): return def _save_pop(self, pop, iteration): + """ + Write every circuit of one iteration's population out as openQASM, one file per circuit + under "pop/", through the solver's IO. + + :param pop: the population, as (score, circuit) pairs + :type pop: list[tuple] + :param iteration: the iteration number, used in each file name + :type iteration: int + :return: nothing + :rtype: None + """ for i, (score, circuit) in enumerate(pop): name = f"pop/iteration{iteration}_pop{i}.txt" self.io.save_json(circuit.to_openqasm(), name) def _save_hof(self, hof, iteration): + """ + Write every circuit of the hall of fame out as openQASM, one file per circuit under "hof/", + through the solver's IO. A slot no candidate has filled yet, (np.inf, None), is skipped. + + :param hof: the hall of fame, as (score, circuit) pairs + :type hof: list[tuple] + :param iteration: the iteration number, used in each file name + :type iteration: int + :return: nothing + :rtype: None + """ for i, (score, circuit) in enumerate(hof): + # a slot no candidate has filled yet holds (np.inf, None) + if circuit is None: + continue name = f"hof/iteration{iteration}_hof{i}.txt" self.io.save_json(circuit.to_openqasm(), name) @@ -568,7 +635,10 @@ def replace_photon_one_qubit_op(self, circuit): op = self.one_qubit_ops[ind] noise = self._wrap_noise(op, self.noise_model_mapping["p"]) gate = ops.OneQubitGateWrapper(op, reg_type="p", register=reg, noise=noise) - gate.add_labels("Fixed") + # keep the protection of the gate being replaced: initialization's photon gates + # stay out of remove_op's reach, gates without "Fixed" stay removable + if "Fixed" in old_op.labels: + gate.add_labels("Fixed") circuit.replace_op(node, gate) def replace_emitter_one_qubit_op(self, circuit): @@ -771,10 +841,11 @@ def _select_possible_cnot_position(circuit): ] for edge in edges: - possible_edges = set(edges) - circuit.find_incompatible_edges(edge) + incompatible = circuit.find_incompatible_edges(edge) - for another_edge in possible_edges: - edge_pair.append((edge, another_edge)) + for another_edge in edges: + if another_edge not in incompatible: + edge_pair.append((edge, another_edge)) return edge_pair @@ -808,10 +879,11 @@ def _select_possible_measurement_position(circuit): ] for edge in e_edges: - possible_edges = set(p_edges) - circuit.find_incompatible_edges(edge) + incompatible = circuit.find_incompatible_edges(edge) - for another_edge in possible_edges: - edge_pair.append((edge, another_edge)) + for another_edge in p_edges: + if another_edge not in incompatible: + edge_pair.append((edge, another_edge)) return edge_pair diff --git a/graphiq/solvers/gradient_descent_solver.py b/graphiq/solvers/gradient_descent_solver.py index 976f108b..f195ca7d 100644 --- a/graphiq/solvers/gradient_descent_solver.py +++ b/graphiq/solvers/gradient_descent_solver.py @@ -37,6 +37,14 @@ class GradientDescentSolver(SolverBase): A solver class based on gradient descent algorithms. Requires the use of `jax` as the numerical backend. The optimizer routine is provided by the `optax` package. + + Not supported in this version: setting `graphiq.DENSITY_MATRIX_ARRAY_LIBRARY` to `jax` after + `import graphiq` only lifts this class's own check; the density-matrix backend keeps numpy. With + `jax` selected (by editing `DENSITY_MATRIX_ARRAY_LIBRARY` in `graphiq/__init__.py`), no + density-matrix QuantumState can be built from a matrix (QuantumState rejects jax arrays and + DensityMatrix rejects numpy arrays), and the density-matrix arm of Infidelity cannot be + differentiated. With `jax` selected that way, GradientDescentSolver runs with states built from a + number of qubits and a jax-differentiable metric of the user's own. """ name = "gradient-descent-solver" @@ -92,7 +100,7 @@ def compute_cost( def solve(self, initial_params=None): """ - Main gradient descent algorithm, performing `n_steps` updates with the provided optimizer. + Main gradient descent algorithm, performing `n_step` updates with the provided optimizer. :param initial_params: initial parameter dictionary :type initial_params: dict diff --git a/graphiq/solvers/hybrid_solvers.py b/graphiq/solvers/hybrid_solvers.py index b7754273..0ed6d06b 100644 --- a/graphiq/solvers/hybrid_solvers.py +++ b/graphiq/solvers/hybrid_solvers.py @@ -44,7 +44,7 @@ def __init__( metric: MetricBase, compiler: CompilerBase, io: IO = None, - solver_setting=EvolutionarySolverSetting(), + solver_setting=None, noise_model_mapping=None, *args, **kwargs, @@ -60,17 +60,13 @@ def __init__( :type compiler: CompilerBase :param io: input/output object for saving logs, intermediate results, circuits, etc. :type io: IO - :param n_hof: the size of the hall of fame (hof) - :type n_hof: int - :param selection_active: use selection in the evolutionary algorithm - :type selection_active: bool - :param use_adapt_probability: use adapted probability in the evolutionary algorithm - :type use_adapt_probability: bool - :param save_openqasm: save population, hof, or both to openQASM strings (options: None, "hof", "pop", "both") - :type save_openqasm: str, None + :param solver_setting: the solver's settings; a fresh default is built when None + :type solver_setting: EvolutionarySolverSetting or None :param noise_model_mapping: a dictionary that associates each operation type to a noise model - :type noise_model_mapping: dict + :type noise_model_mapping: dict or None """ + if solver_setting is None: + solver_setting = EvolutionarySolverSetting() if target.rep_type != "s": tmp_target = target.copy() tmp_target.convert_representation("s") @@ -151,15 +147,24 @@ def population_initialization(self): :return: an initial population :rtype: list """ + # TimeReversedSolver converts its target to "s" in place, which would move a + # metric sharing a "dm" target onto its stabilizer arm, unable to score the + # non-graph-state density matrices this compiler produces during the search + target = self.target + if target.rep_type == "dm" and self.compiler.name == "dm": + target = target.copy() + target.convert_representation("s") deterministic_solver = TimeReversedSolver( - target=self.target, + target=target, metric=self.metric, compiler=self.compiler, noise_model_mapping=self.noise_model_mapping, ) deterministic_solver.noise_simulation = False deterministic_solver.solve() - self.compiler.noise_simulation = True + # the deterministic solve set the shared compiler's flag to False; restore + # the one solve() chose from noise_model_mapping for the search + self.compiler.noise_simulation = self.noise_simulation _, ideal_circuit = deterministic_solver.result population = [] for j in range(self.setting.n_pop): diff --git a/graphiq/solvers/solver_base.py b/graphiq/solvers/solver_base.py index 29affdb4..4af6cde1 100644 --- a/graphiq/solvers/solver_base.py +++ b/graphiq/solvers/solver_base.py @@ -29,6 +29,7 @@ from graphiq.circuit.circuit_base import CircuitBase, ops from graphiq.io import IO from graphiq.metrics import MetricBase +from graphiq.noise.noise_mapping import noise_for_op class SolverBase(ABC): @@ -47,6 +48,10 @@ class SolverBase(ABC): ops.CNOT, ] + # the last seed passed to seed(); a class attribute because seed() sets the + # process-global numpy/random seeds and records it on SolverBase itself + last_seed = None + def __init__( self, target, @@ -56,6 +61,24 @@ def __init__( io: IO = None, solver_setting=None, ): + """ + Create a SolverBase object + + :param target: the target state the solver searches for a circuit to generate + :type target: QuantumState + :param metric: the objective the solver minimizes + :type metric: MetricBase + :param compiler: the compiler used to simulate each candidate circuit + :type compiler: CompilerBase + :param circuit: an initial circuit to start from, or None + :type circuit: CircuitBase or None + :param io: an IO object used to save results, or None for no saving + :type io: IO or None + :param solver_setting: the solver's own settings object, or None + :type solver_setting: object or None + :return: nothing + :rtype: None + """ self.target = target self.metric = metric self.compiler = compiler @@ -63,12 +86,18 @@ def __init__( self.circuit = circuit self.solver_setting = solver_setting - self.last_seed = None self.logs = {} self.result = None @abstractmethod def solve(self, *args): + """ + Run the search. Each concrete solver implements its own. + + :raises NotImplementedError: always, on this base class + :return: nothing + :rtype: None + """ raise NotImplementedError( "Base Solver class, solver method is not implemented." ) @@ -79,6 +108,15 @@ def seed(seed=None): Sets the seed for both the numpy.random and random packages. Accessible by any Solver subclass + The seed is recorded in last_seed, a class-level attribute on SolverBase (reported as + solver_info["seed"] by the solvers that log it). Like the numpy.random and random seeds it + sets, it is shared by the whole process: a solver reports the seed most recently set by any + solver, not necessarily its own. Measurement outcomes in the stabilizer and density matrix + backends are drawn from the global numpy.random state, so this seed does govern them. + It does not reach components that build their own unseeded np.random.default_rng() + (for example, AlternateTargetSolver's Monte Carlo seeding when a seed is missing, and the + random graph generators in benchmarks/graph_states.py when no np_rng is given). + :param seed: a random number seed :type seed: int or None :return: nothing @@ -89,6 +127,14 @@ def seed(seed=None): @property def solver_info(self): + """ + Solver-specific information to record alongside a result. + + Empty on the base class; a subclass overrides it with whatever describes its own search. + + :return: a dictionary of solver information + :rtype: dict + """ return {} def _wrap_noise(self, op, noise_model_mapping): @@ -114,55 +160,49 @@ def _wrap_noise(self, op, noise_model_mapping): def _identify_noise(self, op, noise_model_mapping): """ - A helper function to identify the noise model for an operation + A helper function to identify the noise model for an operation. Delegates to + ``graphiq.noise.noise_mapping.noise_for_op``, which also backs + ``CircuitDAG._noisy_gates``, so both callers read a noise-model mapping the same way. :param op: an operation :type op: ops.OperationBase :param noise_model_mapping: a dictionary that stores the mapping between an operation - and its associated noise model + and its associated noise model. A two-legged op is looked up by its per-leg keys, + ``_control`` and ``_target``, first; the bare ```` key is only used as a + fallback when neither per-leg key is present, in which case a single model is + returned for both legs and a list of two is returned as it is :type noise_model_mapping: dict - :return: a noise model - :rtype: nm.NoiseBase + :raises ValueError: if a two-legged op's bare list value does not have length 2 + :return: a noise model, or a list of two (control, target) for a two-legged op + :rtype: nm.NoiseBase or list[nm.NoiseBase] """ - # first make sure whether the passed operations are instances (of a class) or classes. - if isinstance(op, ops.OperationBase): - # if op is an instance, turn it into the class itself. - op = type(op) - if isinstance(op, ops.ControlledPairOperationBase) or isinstance( - op, ops.ClassicalControlledPairOperationBase - ): - op_name = op.__name__ - op_control = op.__name__ + "_control" - op_target = op.__name__ + "_target" - if op_control in noise_model_mapping.keys(): - control_noise = noise_model_mapping[op_control] - else: - control_noise = nm.NoNoise() - if op_target in noise_model_mapping.keys(): - target_noise = noise_model_mapping[op_target] - else: - target_noise = nm.NoNoise() - if op_name in noise_model_mapping.keys() and type( - target_noise - ) == nm.NoNoise == type(control_noise): - return [noise_model_mapping[op_name], noise_model_mapping[op_name]] - else: - return [control_noise, target_noise] - else: - op_name = op.__name__ - if op_name in noise_model_mapping.keys(): - return noise_model_mapping[op_name] - else: - return nm.NoNoise() + return noise_for_op(op, noise_model_mapping) class RandomSearchSolverSetting(ABC): + """ + The settings every random-search-style solver shares: hall-of-fame size, how long to run, + and how many candidates to hold per iteration. + """ + def __init__( self, n_hof=5, n_stop=50, n_pop=50, ): + """ + Create a RandomSearchSolverSetting object + + :param n_hof: the number of best circuits kept in the hall of fame + :type n_hof: int + :param n_stop: the number of iterations to run before stopping + :type n_stop: int + :param n_pop: the number of candidate circuits held per iteration + :type n_pop: int + :return: nothing + :rtype: None + """ self._n_hof = n_hof self._n_stop = n_stop self._n_pop = n_pop @@ -216,12 +256,36 @@ def __init__( compiler: CompilerBase, circuit: CircuitBase = None, io: IO = None, - solver_setting=RandomSearchSolverSetting(), + solver_setting=None, *args, **kwargs, ): + """ + Create a RandomSearchSolver object + + :param target: the target state the solver searches for a circuit to generate + :type target: QuantumState + :param metric: the objective the solver minimizes + :type metric: MetricBase + :param compiler: the compiler used to simulate each candidate circuit + :type compiler: CompilerBase + :param circuit: an initial circuit to start from, or None + :type circuit: CircuitBase or None + :param io: an IO object used to save results, or None for no saving + :type io: IO or None + :param solver_setting: the hall-of-fame size, stopping point and population size, stored as + self.setting; a fresh default is built when None + :type solver_setting: RandomSearchSolverSetting or None + :return: nothing + :rtype: None + """ super().__init__(target, metric, compiler, circuit, io) + # built per instance: a default instance in the signature would be + # shared (and mutated) by every solver constructed without one + if solver_setting is None: + solver_setting = RandomSearchSolverSetting() + # hof stores the best circuits and their scores in the form of: (scores, circuits) self.setting = solver_setting self.hof = [(np.inf, None) for _ in range(self.setting.n_hof)] @@ -240,7 +304,12 @@ def update_hof(self, population): for score, circuit in population: for i in range(self.setting.n_hof): if np.isclose(score, self.hof[i][0]): - if len(circuit.dag.nodes) < len(self.hof[i][1].dag.nodes): + # self.hof[i][1] is None for a still-unfilled placeholder + # slot (initialized to (np.inf, None)); any real circuit + # is preferred over an empty slot. + if self.hof[i][1] is None or len(circuit.dag.nodes) < len( + self.hof[i][1].dag.nodes + ): self.hof.insert(i, (score, circuit.copy())) self.hof.pop() break @@ -278,6 +347,14 @@ def tournament_selection(self, population, k=2): return population_new def solve(self, *args): + """ + Run the search. Not implemented on RandomSearchSolver; a subclass such as + EvolutionarySolver overrides it. + + :raises NotImplementedError: always, on this class + :return: nothing + :rtype: None + """ raise NotImplementedError( "Base Solver class, solver method is not implemented." ) diff --git a/graphiq/solvers/solver_result.py b/graphiq/solvers/solver_result.py index 0c32b230..0f18e6a3 100644 --- a/graphiq/solvers/solver_result.py +++ b/graphiq/solvers/solver_result.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Tabular container for a solver's output circuits and their per-circuit properties. +""" + import json import warnings @@ -29,6 +33,20 @@ class SolverResult: """ def __init__(self, circuit_list, properties=None): + """ + Create a SolverResult object + + Each named property becomes a column of the same length as circuit_list, filled with + None until a value is assigned. + + :param circuit_list: the circuits this result describes, one per row + :type circuit_list: list[CircuitBase] + :param properties: the names of the property columns to create, or None for none + :type properties: list[str] or None + :raises AssertionError: if properties is neither a list nor None + :return: nothing + :rtype: None + """ self._data = { "circuit": circuit_list, "circuit_id": [f"c{i}" for i in range(len(circuit_list))], @@ -86,7 +104,7 @@ def __setitem__(self, key, value): :return: nothing :rtype: None """ - if isinstance(type, (list, np.ndarray)): + if not isinstance(value, (list, np.ndarray)): raise TypeError(f"Data should be a list or numpy array") else: if len(value) != len(self._data["circuit"]): @@ -98,11 +116,13 @@ def __setitem__(self, key, value): def add_properties(self, new_property): """ + Add an empty property column (filled with None), if it is not already present - :param new_property: - :type new_property: - :return: - :rtype: + :param new_property: the name of the property to add + :type new_property: str + :raises AssertionError: if new_property is not a str + :return: nothing + :rtype: None """ assert isinstance(new_property, str) if new_property not in self._properties: @@ -188,6 +208,20 @@ def to_df(self): return df def save2json(self, path, filename): + """ + Write the result to a json file. + + Circuits are serialized as openQASM and the "g" column as edge lists, since neither is + json-serializable as it stands. + + :param path: the directory to write into + :type path: str + :param filename: the file name, without the .json suffix + :type filename: str + :raises KeyError: if the result has no "g" column + :return: nothing + :rtype: None + """ data_dict = self._data.copy() data_dict["g"] = [list(nx.to_edgelist(g)) for g in self._data["g"]] data_dict["circuit"] = [c.to_openqasm() for c in self._data["circuit"]] @@ -195,6 +229,21 @@ def save2json(self, path, filename): json.dump(data_dict, f) def load_json(self, path, filename): + """ + Load a json file's contents into this result, overwriting the columns the file + carries. Columns already held but absent from the file are left in place, so this + merges rather than replaces. + + The "g" column is rebuilt from edge lists into graphs; every other column is loaded + as stored, so a circuit column arrives as openQASM strings rather than circuits. + + :param path: the directory to read from + :type path: str + :param filename: the file name, without the .json suffix + :type filename: str + :return: nothing + :rtype: None + """ with open(f"{path}/{filename}.json", "r") as f: loaded_dict = json.load(f) for properties in loaded_dict.keys(): @@ -206,7 +255,7 @@ def load_json(self, path, filename): "The circuits in the loaded result object are now openqasm strings!" ) diff = ( - set(self.properties) - set(loaded_dict.keys()) - {"circuit", "circuit_id"} + set(self._properties) - set(loaded_dict.keys()) - {"circuit", "circuit_id"} ) if diff: warnings.warn("") diff --git a/graphiq/solvers/time_reversed_solver.py b/graphiq/solvers/time_reversed_solver.py index fa8f4bee..59f8ffe9 100644 --- a/graphiq/solvers/time_reversed_solver.py +++ b/graphiq/solvers/time_reversed_solver.py @@ -329,11 +329,18 @@ def _change_pauli_type(self, tableau, row, column, result="z"): :type row: int :param column: the qubit index in the tableau :type column: int - :param result: the Pauli type to be transformed to + :param result: which Pauli to transform to: "x", "y" or "z", case-insensitively :type result: str + :raises ValueError: if result is not one of "x", "y" or "z" :return: a list of one-qubit gates to be added to the circuit :rtype: list[ops.OperationBase] """ + if result.lower() not in ("x", "y", "z"): + raise ValueError( + f"result must be one of 'x', 'y' or 'z' (case-insensitive), got {result!r}" + ) + # An identity entry (x=0, z=0) falls through every block below and + # returns no gates; callers rely on that for registers holding identity. gate_list = [] if tableau.x_matrix[row, column] == 1 and tableau.z_matrix[row, column] == 0: @@ -540,6 +547,10 @@ def _add_emitter_photon_cnot(self, circuit, emitter_index, photon_index): :param circuit: a quantum circuit :type circuit: CircuitDAG + :param emitter_index: the emitter's index relative to the emitter block + :type emitter_index: int + :param photon_index: the photon's index + :type photon_index: int :return: nothing :rtype: None """ @@ -614,11 +625,15 @@ def _single_out_emitter(self, circuit, tableau, generator_index, emitter_index): def _add_measurement_cnot_and_reset(self, circuit, emitter_index, photon_index): """ - Add a MeasurementCNOTandReset operation from an emitter qubit to a photonic qubit such that no consecutive - MeasurementCNOTReset is allowed. This operation cannot be added before the photonic qubit is initialized. + Add a MeasurementCNOTandReset operation from an emitter qubit to a photonic qubit. The operation is inserted + right after the input nodes of both qubits, i.e. at the start of the circuit; no ordering rules are checked. :param circuit: a quantum circuit :type circuit: CircuitDAG + :param emitter_index: the emitter's index relative to the emitter block + :type emitter_index: int + :param photon_index: the photon's index + :type photon_index: int :return: nothing :rtype: None """ diff --git a/graphiq/state.py b/graphiq/state.py index 223197bf..43ee87b0 100644 --- a/graphiq/state.py +++ b/graphiq/state.py @@ -18,9 +18,7 @@ State representations that we support are: 1. Density matrix 2. Stabilizer - -State representations that we intend to support in the near future are: - 1. Graph representation (only state representation, but no compiler at the moment) + 3. Graph (state representation only; no compiler at the moment) """ @@ -42,6 +40,46 @@ STABILIZER_MIXED_REPRESENTATION = "branch" +def _reject_unrepresentable_i_phase(tableau): + """ + Raise if a Clifford tableau carries a factor of i, which a density matrix cannot be built from. + + The stabilizer-to-density-matrix conversion reads each generator as a Pauli string with a +1/-1 + sign, so a tableau carrying an i phase would silently convert to a plausible but wrong density + matrix. No correct gate or measurement produces one: a stabilizer group consists of commuting + Hermitian Paulis with +1/-1 signs, and the Z-basis measurement copies a stabilizer row's own + (zero) i phase into the destabilizer half. + + :param tableau: the tableau about to be converted + :type tableau: CliffordTableau + :raises ValueError: if any entry of the tableau's i-phase vector is non-zero + :return: nothing + :rtype: None + """ + if np.any(tableau.iphase): + raise ValueError( + f"Cannot convert to a density matrix: the tableau carries a non-zero i phase: " + f"{tableau.iphase}" + ) + + +def _adjacency_to_graph(adjacency): + """ + Build a plain networkx graph from a 0/1 adjacency matrix, keeping isolated nodes. + + nx.from_numpy_array alone would put a weight attribute on every edge. + + :param adjacency: the adjacency matrix + :type adjacency: numpy.ndarray + :return: the graph + :rtype: networkx.Graph + """ + graph = nx.Graph() + graph.add_nodes_from(range(adjacency.shape[0])) + graph.add_edges_from(nx.from_numpy_array(np.real(adjacency)).edges()) + return graph + + class QuantumState: """ The QuantumState class is the unified API for accessing state representation backends. @@ -54,37 +92,49 @@ def __init__(self, data, rep_type=None, mixed=False): :param data: valid data input for "rep_type". Density matrices representations support np.ndarray or int inputs - Stabilizer representations take int or StabilizerTableau + Stabilizer representations take int or CliffordTableau Graph representations take networkx.Graph - :type data: list OR numpy.ndarray OR Graph OR nx.Graph or CliffordTableau + :type data: int OR list OR numpy.ndarray OR nx.Graph or CliffordTableau :param rep_type: selected representation to initialize; - if not specified, the default choice is the density matrix if the number of qubits is - less than the threshold value or stabilizer otherwise. + if not specified, the density matrix is chosen for int or numpy.ndarray data if the number + of qubits is less than the threshold value, otherwise the stabilizer for int or + CliffordTableau data, otherwise the graph for nx.Graph data. :type rep_type: str :param mixed: boolean flag to initialize as a mixed state or not (mainly used for Stabilizer rep_type) :type mixed: boolean + :raises TypeError: if validate_data rejects data + :raises AssertionError: if data is a non-square numpy.ndarray, or if the initialized + representation does not have the number of qubits validate_data reports for data + :raises ValueError: if rep_type is not a supported representation name, or if rep_type is None + and no representation can be chosen for data (including a numpy.ndarray with too many qubits + for the density matrix) :return: nothing :rtype: None """ self.mixed = mixed self._rep_type = self._get_rep_type_name(rep_type) - valid, self.n_qubits = self.validate_data(data) + valid, n_qubits = self.validate_data(data) if not valid: raise TypeError( f"Data's type is incorrect. Input data is {data}, which is of type {type(data)}" ) if rep_type is None: - if ( - self.n_qubits < DENSITY_MATRIX_QUBIT_THRESH - and DensityMatrix.valid_datatype(data) + # record the detected representation's name: show, + # convert_representation and the metrics and solvers read rep_type + # to dispatch + if n_qubits < DENSITY_MATRIX_QUBIT_THRESH and DensityMatrix.valid_datatype( + data ): - self._rep_data = self._initialize_dm(data) + self._rep_data = self._initialize_dm(data, n_qubits) + self._rep_type = "dm" elif Stabilizer.valid_datatype(data): - self._rep_data = self._initialize_stabilizer(data) + self._rep_data = self._initialize_stabilizer(data, n_qubits) + self._rep_type = "s" elif Graph.valid_datatype(data): - self._rep_data = self._initialize_graph(data) + self._rep_data = self._initialize_graph(data, n_qubits) + self._rep_type = "g" elif DensityMatrix.valid_datatype(data): raise ValueError( "Data's type is correct for density matrix representation, but state size exceeds the " @@ -96,7 +146,7 @@ def __init__(self, data, rep_type=None, mixed=False): ) elif isinstance(rep_type, str): - self._rep_data = self._initialize_representation(rep_type, data) + self._rep_data = self._initialize_representation(rep_type, data, n_qubits) else: raise ValueError("passed rep_type argument must be a String") @@ -107,19 +157,45 @@ def rep_data(self): Representation data :return: representation data - :rtype: DensityMatrix or Stabilizer or Graph + :rtype: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph """ return self._rep_data @rep_data.setter def rep_data(self, data): - self._rep_data = self._initialize_representation(self._rep_type, data) + """ + Replace the representation data, rebuilding it as the current rep_type + + The new data must have the state's current width, n_qubits, which is read off the + representation being replaced: a density matrix, graph or newly built stabilizer + representation of a different size raises AssertionError. An existing Stabilizer (or, + when the state is mixed, MixedStabilizer) object is adopted as is, without that check, + and its width becomes the state's width. + + :param data: data valid for the current rep_type + :type data: int or numpy.ndarray or CliffordTableau or list[(float, CliffordTableau)] or nx.Graph + or Graph (for "dm") or Stabilizer or MixedStabilizer + :raises AssertionError: if the new representation does not have n_qubits qubits, or if a + mixture list is malformed + :raises TypeError: if data is not a valid type for the current rep_type + :raises ValueError: if the data is not a valid density matrix, or if rep_type is None + :return: nothing + :rtype: None + """ + self._rep_data = self._initialize_representation( + self._rep_type, data, self.n_qubits + ) @property def rep_type(self): """ Representation type + Assigning to this only renames the representation; it does not convert the data. + Use `convert_representation` to change the representation, or assign `rep_data` + right after renaming, with input valid for the new type and the same number of + qubits. + :return: representation type :rtype: str """ @@ -129,6 +205,17 @@ def rep_type(self): def rep_type(self, new_rep_type): self._rep_type = self._get_rep_type_name(new_rep_type) + @property + def n_qubits(self): + """ + Number of qubits in the state, derived from the current representation, so it follows + any change of width, such as a partial_trace or a resize made through rep_data + + :return: the number of qubits + :rtype: int + """ + return self._rep_data.n_qubits + def partial_trace(self, keep, dims): r""" Calculates the partial trace on all state representations which are currently defined @@ -142,14 +229,18 @@ def partial_trace(self, keep, dims): :type dims: list OR numpy.ndarray :return: nothing :rtype: None + :raises NotImplementedError: if the representation object has no partial_trace """ - if self._rep_type == "g": + # Asked of the representation object, not of _rep_type: the rep_type + # setter renames without converting, so the name need not match the + # object held. + if not hasattr(self._rep_data, "partial_trace"): raise NotImplementedError( - "Partial trace not yet implemented on graph state" + f"Partial trace not yet implemented on the " + f"{type(self._rep_data).__name__} representation" ) - else: - self._rep_data.partial_trace(keep, dims) + self._rep_data.partial_trace(keep, dims) def show(self, show=True, ax=None): """ @@ -157,8 +248,11 @@ def show(self, show=True, ax=None): :param show: if True, the state representation is plotted. Otherwise, it is drawn but not plotted :type show: bool - :param ax: axis/axes on which to plot the state representation + :param ax: axis/axes on which to plot the state representation; ignored for the density matrix + representation :type ax: matplotlib.Axis + :raises NotImplementedError: for the stabilizer representation or a mixed graph state + :raises ValueError: for an unknown representation type :return: fig, ax (the figure and axes on which data was plotted) :rtype: matplotlib.Figure, matplotlib.Axis """ @@ -166,6 +260,10 @@ def show(self, show=True, ax=None): if self._rep_type == "dm": fig, ax = self._rep_data.draw(show=show, style="heat") elif self._rep_type == "g": + if isinstance(self._rep_data, MixedGraph): + raise NotImplementedError( + "No visualization tool is implemented for a mixed graph state." + ) fig, ax = self._rep_data.draw(show=show, ax=ax, with_labels=True) elif self._rep_type == "s": raise NotImplementedError( @@ -176,43 +274,57 @@ def show(self, show=True, ax=None): return fig, ax - def _initialize_dm(self, data): + def _initialize_dm(self, data, n_qubits): """ Initializes a density matrix based on the data :param data: either a graph or ndarray matrix :type data: Graph OR nx.Graph OR numpy.ndarray - :raises AssertionError: if the density matrix being initialized does not have self.n_qubits + :param n_qubits: the number of qubits the density matrix must have + :type n_qubits: int + :raises AssertionError: if the density matrix being initialized does not have n_qubits + :return: the density matrix representation + :rtype: DensityMatrix """ if isinstance(data, Graph) or isinstance(data, nx.Graph): dm = DensityMatrix.from_graph(data) else: dm = DensityMatrix(data) - assert dm.data.shape[0] == dm.data.shape[1] == 2**self.n_qubits + assert dm.data.shape[0] == dm.data.shape[1] == 2**n_qubits return dm - def _initialize_graph(self, data): + def _initialize_graph(self, data, n_qubits): """ Initializes a graph state based on the data :param data: data to construct the Graph representation :type data: networkX.Graph - :raises AssertionError: if the graph being initialized does not have self.n_qubits + :param n_qubits: the number of qubits the graph must have + :type n_qubits: int + :raises AssertionError: if the graph being initialized does not have n_qubits + :return: the graph representation + :rtype: Graph """ graph = Graph(data) - assert graph.n_qubits == self.n_qubits, ( - f"Expected {self.n_qubits} qubits, " f"graph rep_type has {graph.n_qubits}" + assert graph.n_qubits == n_qubits, ( + f"Expected {n_qubits} qubits, " f"graph rep_type has {graph.n_qubits}" ) return graph - def _initialize_stabilizer(self, data): + def _initialize_stabilizer(self, data, n_qubits): """ Initializes a stabilizer state based on the data :param data: data to construct the stabilizer state representation :type data: Stabilizer or int or CliffordTableau or MixedStabilizer or list[(float, CliffordTableau)] + :param n_qubits: the number of qubits a newly built stabilizer must have; an existing + Stabilizer or MixedStabilizer object is returned as is, without that check + :type n_qubits: int + :raises AssertionError: if the stabilizer being initialized does not have n_qubits + :return: a MixedStabilizer if self.mixed is True, a Stabilizer otherwise + :rtype: Stabilizer or MixedStabilizer """ if not self.mixed: if isinstance(data, Stabilizer): @@ -225,34 +337,38 @@ def _initialize_stabilizer(self, data): else: stabilizer = MixedStabilizer(data) - assert stabilizer.n_qubits == self.n_qubits, ( - f"Expected {self.n_qubits} qubits, " + assert stabilizer.n_qubits == n_qubits, ( + f"Expected {n_qubits} qubits, " f"Stabilizer representation has {stabilizer.n_qubits}" ) return stabilizer - def _initialize_representation(self, rep_type, data): + def _initialize_representation(self, rep_type, data, n_qubits): """ Helper function to initialize any given representation :param rep_type: representation type to initialize :type rep_type: str :param data: data with which the rep_type should be initialized - :type data: int OR Graph OR nx.Graph OR numpy.ndarray + :type data: int OR Graph OR nx.Graph OR numpy.ndarray OR CliffordTableau OR Stabilizer OR MixedStabilizer + :param n_qubits: the number of qubits the representation must have + :type n_qubits: int :raises ValueError: if rep_type is invalid + :return: the initialized representation + :rtype: DensityMatrix or Graph or Stabilizer or MixedStabilizer """ if rep_type in ("dm", "density matrix"): - if self.n_qubits > DENSITY_MATRIX_QUBIT_THRESH: + if n_qubits > DENSITY_MATRIX_QUBIT_THRESH: warnings.warn( UserWarning( "Density matrix is not recommended for a state of this size" ) ) - return self._initialize_dm(data) + return self._initialize_dm(data, n_qubits) elif rep_type in ("g", "graph"): - return self._initialize_graph(data) + return self._initialize_graph(data, n_qubits) elif rep_type in ("s", "stab", "stabilizer"): - return self._initialize_stabilizer(data) + return self._initialize_stabilizer(data, n_qubits) else: raise ValueError("Passed rep_type is invalid") @@ -262,14 +378,32 @@ def _density_to_graph(self, rep): :param rep: density matrix representation :type rep: DensityMatrix + :raises ValueError: if the state is mixed, or its trace is not close to 1, but self.mixed is False + :raises AssertionError: if the density matrix is not a graph state :return: graph representation :rtype: Graph or MixedGraph """ new_data = rc.density_to_graph(rep.data) if isinstance(new_data, list): - new_rep = MixedGraph(new_data) + if not self.mixed: + raise ValueError( + "Cannot convert a mixed density matrix, or one whose trace is not close " + "to 1, to a graph state with mixed=False; create the QuantumState with " + "mixed=True" + ) + # a zero-weight eigenvector is an arbitrary null-space vector, not part of + # the mixture + new_rep = MixedGraph( + [ + (p_i, Graph(_adjacency_to_graph(adj_i))) + for p_i, adj_i in new_data + if p_i > 1e-9 + ] + ) else: - new_rep = Graph(new_data) + graph = Graph(_adjacency_to_graph(new_data)) + # a mixed state holds a MixedGraph, as _stabilizer_to_graph gives it + new_rep = MixedGraph(graph) if self.mixed else graph return new_rep def _density_to_stabilizer(self, rep): @@ -278,6 +412,7 @@ def _density_to_stabilizer(self, rep): :param rep: density matrix representation :type rep: DensityMatrix + :raises ValueError: if the state is mixed, or its trace is not close to 1, but self.mixed is False :return: stabilizer representation :rtype: Stabilizer or MixedStabilizer """ @@ -290,6 +425,12 @@ def _density_to_stabilizer(self, rep): new_rep = MixedStabilizer(new_tab_list) else: + if len(new_data) != 1 or not np.isclose(new_data[0][0], 1.0): + raise ValueError( + "Cannot convert a mixed density matrix, or one whose trace is not close " + "to 1, to a stabilizer state with mixed=False; create the QuantumState " + "with mixed=True" + ) new_tableau = CliffordTableau(new_data[0][1]) new_rep = Stabilizer(new_tableau) return new_rep @@ -300,6 +441,7 @@ def _stabilizer_to_density(self, rep): :param rep: initial representation :type rep: Stabilizer or MixedStabilizer + :raises ValueError: if a tableau carries a non-zero i phase :return: a density matrix :rtype: DensityMatrix """ @@ -307,9 +449,12 @@ def _stabilizer_to_density(self, rep): data_list = [] assert isinstance(rep, MixedStabilizer) for p_i, t_i in rep.mixture: + # checked here: to_stabilizer() drops the i-phase vector + _reject_unrepresentable_i_phase(t_i) data_list.append((p_i, t_i.to_stabilizer())) rho = rc.stabilizer_to_density(data_list) else: + _reject_unrepresentable_i_phase(rep.data) rho = rc.stabilizer_to_density(rep.data.to_stabilizer()) return DensityMatrix(rho) @@ -319,6 +464,7 @@ def _stabilizer_to_graph(self, rep): :param rep: stabilizer representation :type rep: Stabilizer or MixedStabilizer + :raises AssertionError: if the state is not a graph state :return: graph representation :rtype: Graph or MixedGraph """ @@ -328,9 +474,12 @@ def _stabilizer_to_graph(self, rep): for p_i, t_i in rep.mixture: data_list.append((p_i, t_i.to_stabilizer())) graph_list = rc.stabilizer_to_graph(data_list) - return MixedGraph(graph_list) + # MixedGraph expects graphiq Graph objects, not bare nx.Graphs + return MixedGraph([(p_i, Graph(g_i)) for p_i, g_i in graph_list]) else: - graph_list = rc.stabilizer_to_graph(rep.data) + # stabilizer_to_graph takes a StabilizerTableau, not the + # CliffordTableau the Stabilizer holds + graph_list = rc.stabilizer_to_graph(rep.data.to_stabilizer()) return Graph(graph_list[0][1]) def _graph_to_density(self, rep): @@ -342,8 +491,9 @@ def _graph_to_density(self, rep): :return: density matrix representation :rtype: DensityMatrix """ - if self.mixed: - assert isinstance(rep, MixedGraph) + # a mixed state may hold a plain Graph (_initialize_graph never builds a + # MixedGraph) + if self.mixed and isinstance(rep, MixedGraph): rho = 0 for p_i, t_i in rep.mixture: rho += p_i * rc.graph_to_density(t_i.data) @@ -360,7 +510,15 @@ def _graph_to_stabilizer(self, rep): :return: stabilizer representation :rtype: Stabilizer or MixedStabilizer """ - new_data = rc.graph_to_stabilizer(rep.data) + # a mixed state may hold a plain Graph (_initialize_graph never builds a + # MixedGraph); a MixedGraph's mixture holds graphiq Graphs, and + # rc.graph_to_stabilizer takes networkx graphs + if self.mixed and isinstance(rep, MixedGraph): + new_data = rc.graph_to_stabilizer( + [(p_i, g_i.data) for p_i, g_i in rep.mixture] + ) + else: + new_data = rc.graph_to_stabilizer(rep.data) # new_data is of type list[(float, StabilizerTableau)] if self.mixed: new_tab_list = [] @@ -374,10 +532,34 @@ def _graph_to_stabilizer(self, rep): return new_rep def _identity_fun(self, rep): + """ + Helper function. Return a representation unchanged. + + Sits on the diagonal of the conversion table, where source and target representation + are the same. ``convert_representation`` does nothing when they match, so these + entries are never reached. + + :param rep: the representation to pass through + :type rep: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph + :return: the same representation, not a copy + :rtype: DensityMatrix or Stabilizer or MixedStabilizer or Graph or MixedGraph + """ return rep @staticmethod def _get_rep_type_name(rep_type): + """ + Normalize a representation-type alias to its canonical short name. + + Maps "s", "stab" and "stabilizer" to "s", "dm" and "density matrix" to "dm", and + "g" and "graph" to "g". None passes through as None. + + :param rep_type: a representation type or one of its aliases + :type rep_type: str or None + :raises ValueError: if rep_type is neither None nor a supported representation + :return: the canonical short name, or None + :rtype: str or None + """ if rep_type in ("s", "stab", "stabilizer"): return "s" elif rep_type in ("dm", "density matrix"): @@ -435,9 +617,10 @@ def validate_data(cls, data): Validate data type for input data :param data: input data - :type data: int or np.ndarray or CliffordTableau or nx.Graph - :return: True and the number of qubits if the data type is valid - :rtype: bool, int + :type data: int or np.ndarray or CliffordTableau or nx.Graph or list[(float, CliffordTableau)] + :raises AssertionError: if data is a numpy.ndarray that is not a square matrix + :return: True and the number of qubits if the data type is valid; False and None for an unsupported data type + :rtype: bool, int or None """ valid = True if isinstance(data, int): @@ -452,9 +635,7 @@ def validate_data(cls, data): elif isinstance(data, nx.Graph): n_qubits = data.number_of_nodes() elif isinstance(data, list): - if isinstance(data[0][1], CliffordTableau) or isinstance( - data[0][1], nx.Graph - ): + if isinstance(data[0][1], CliffordTableau): n_qubits = data[0][1].n_qubits else: return False, None diff --git a/graphiq/utils/circuit_comparison.py b/graphiq/utils/circuit_comparison.py index 6bf66fb6..5b3703c6 100644 --- a/graphiq/utils/circuit_comparison.py +++ b/graphiq/utils/circuit_comparison.py @@ -120,7 +120,8 @@ def ged_adaptive(circuit1, circuit2, threshold=30): :type circuit2: CircuitDAG :param threshold: threshold :type threshold: int - :return: exact/approximated GED between circuits(cost needed to transform self.dag to circuit_compare.dag) + :return: whether the (exact or approximate) GED between the two circuits is zero (exact when both have fewer than + threshold nodes) :rtype: bool """ @@ -249,7 +250,8 @@ def edge_match(e1, e2): def _create_edge_control_target_attr(operation, reg_type, reg): """ Helper function that return the correct control_target attribute for the edge, base on reg_type, reg, and operation. - If the operation is ControlledPairOperationBase return either 'c' or 't' else return None. + If the operation is a ControlledPairOperationBase or a ClassicalControlledPairOperationBase, return 'c' or 't' + for its control and target registers, else None. :param operation: operation :type operation: OperationBase @@ -260,7 +262,9 @@ def _create_edge_control_target_attr(operation, reg_type, reg): :return: control_target attribute. Can be 'c', 't' or None :rtype: str or nothing """ - if isinstance(operation, ControlledPairOperationBase): + if isinstance( + operation, (ControlledPairOperationBase, ClassicalControlledPairOperationBase) + ): if reg_type == operation.control_type and reg == operation.control: return "c" if reg_type == operation.target_type and reg == operation.target: @@ -339,6 +343,17 @@ def remove_redundant_circuits(circuit_list): def check_redundant_circuit(circuit1, circuit2): + """ + Check whether two circuits are redundant (the same) using the direct comparison method, + after unwrapping wrapped nodes and removing identity operations on copies of both. + + :param circuit1: circuit that to be compared + :type circuit1: CircuitDAG + :param circuit2: circuit that to be compared + :type circuit2: CircuitDAG + :return: whether the two circuits are the same + :rtype: bool + """ circuit1_copy = circuit1.copy() circuit2_copy = circuit2.copy() @@ -357,6 +372,18 @@ class CircuitStorage: """ def __init__(self, check_function=None, disable_circuit_comparison=False): + """ + Construct a CircuitStorage. + + :param check_function: function used to check whether a new circuit is redundant with + one already stored; defaults to check_redundant_circuit when not given + :type check_function: function or None + :param disable_circuit_comparison: if True, skip the redundancy check and always add + new circuits + :type disable_circuit_comparison: bool + :return: nothing + :rtype: None + """ self.circuit_list = [] self.disable_circuit_comparison = disable_circuit_comparison diff --git a/graphiq/utils/draw.py b/graphiq/utils/draw.py index 0657b734..7eea671b 100644 --- a/graphiq/utils/draw.py +++ b/graphiq/utils/draw.py @@ -12,6 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Circuit visualization data for an external web app: Painter builds per-operation drawing +information, through its own drawing API or from an OpenQASM 2.0 string, with Columns +managing the column layout, and posts the result as json to a web app on localhost. +""" + import json import requests @@ -74,7 +80,7 @@ def update_col_width(self, index, new_width): :param index: index of the column that need to update the width :type index: int :param new_width: new width of the column - :type: new_width: int + :type new_width: int :return: nothing :rtype: None """ @@ -101,14 +107,14 @@ def expand_cols(self, size: int = 1): def set_all_col_element(self, index: int, value: int = -1): """ - Set all the value in every column from the first column to the index column. + Set the value at position index (a register row) in every column, wherever it is still 0. This function is used when adding barriers, since after adding barriers, new operations will be added after the barriers. - :param index: The index of the column + :param index: the position (register row) within each column to set :type index: int - :param value: The value that all column value will be set to if current value is not 0 + :param value: The value that each column's entry will be set to if its current value is 0 :type value: int :return: nothing :rtype: None @@ -235,7 +241,7 @@ def add_register(self, reg_name, size, reg_type="qreg"): the html figure, with a position mapping to that label. :param reg_name: register name - :type reg_type: str + :type reg_name: str :param size: size of the register :type size: int :param reg_type: type of the register (can only be "qreg" or "creg") @@ -272,13 +278,15 @@ def add_gate( :param gate_name: gate name :type gate_name: str - :param qargs: qargs of thge gate + :param qargs: qargs of the gate :type qargs: list :param params: params of the gate :type params: dict :param controls: gate controls for gate like CX, it will specify where the control at + :type controls: list + :raises ValueError: if qargs contains more than one entry :return: visualization info of the gate - rtype: dict + :rtype: dict """ if len(qargs) > 1: raise ValueError("Gate that act on multi-qargs is not supported yet.") @@ -518,7 +526,7 @@ def load_openqasm_str(self, openqasm_str): self.add_gate(gate_name="CX", qargs=[qargs], controls=[controls]) if node["type"] == "measure": creg_size = parser.get_register_size(node["creg"]["name"], "creg") - if hasattr(node, "index"): + if "index" in node["qreg"]: qreg = self.to_reg_label( node["qreg"]["name"], node["qreg"]["index"] ) @@ -534,7 +542,7 @@ def load_openqasm_str(self, openqasm_str): creg = self.to_reg_label(node["creg"]["name"], creg_size) self.add_measurement(qreg=qreg, creg=creg, cbit=i) if node["type"] == "reset": - if hasattr(node, "index"): + if "index" in node: qreg = self.to_reg_label(node["name"], node["index"]) self.add_reset(qreg) else: diff --git a/graphiq/utils/openqasm_lib.py b/graphiq/utils/openqasm_lib.py index ef756e11..87e580cd 100644 --- a/graphiq/utils/openqasm_lib.py +++ b/graphiq/utils/openqasm_lib.py @@ -50,6 +50,10 @@ def __init__( :type definitions: str or list :param usage: A function which creates a gate string given q_reg, q_reg_type, c_reg :type usage: function + :param multi_comp: True if the operation is made of multiple components (visual blocks); when converting a + circuit to openQASM, barriers are inserted to keep such an operation separate from its + neighbours + :type multi_comp: bool :return: function returns nothing :rtype: None """ @@ -159,6 +163,12 @@ def register_initialization_string(e_reg, p_reg, c_reg): def cnot_info(): + """ + OpenQASM info for the CNOT (CX) gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "" @@ -169,6 +179,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_x_info(): + """ + OpenQASM info for the Pauli X gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate x a { U(pi, 0, pi) a; }" @@ -179,6 +195,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_y_info(): + """ + OpenQASM info for the Pauli Y gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate y a { U(pi, pi / 2, pi / 2) a; }" @@ -189,6 +211,12 @@ def usage(q_reg, q_reg_type, c_reg): def sigma_z_info(): + """ + OpenQASM info for the Pauli Z gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate z a { U(0, pi/2, pi/2) a; }" @@ -199,6 +227,12 @@ def usage(q_reg, q_reg_type, c_reg): def hadamard_info(): + """ + OpenQASM info for the Hadamard gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate h a { U(pi/2, 0, pi) a; }" @@ -209,6 +243,12 @@ def usage(q_reg, q_reg_type, c_reg): def phase_info(): + """ + OpenQASM info for the phase (S) gate. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # TODO: investigate why the code fails if we name this gate "p" instead... imports = [] definition = "gate s a { U(0, pi/2, 0) a; }" @@ -220,6 +260,13 @@ def usage(q_reg, q_reg_type, c_reg): def phase_dagger_info(): # this is equal to applying P gate 3 times + """ + OpenQASM info for the adjoint phase (S-dagger) gate, equal to applying the phase (S) gate + three times. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate sdg a { U(0, -pi/2, 0) a; }" @@ -232,6 +279,13 @@ def usage(q_reg, q_reg_type, c_reg): def parameterized_info(): + """ + OpenQASM info for the general single-qubit rotation gate U(theta, phi, lambda), applied + with three parameters supplied through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # todo: update to the default rotation in Qiskit plotting imports = [] definition = "gate u(theta, phi, lambda) a { U(theta, phi, lambda) a; }" @@ -245,6 +299,14 @@ def usage(q_reg, q_reg_type, c_reg, params): def cparameterized_info(): + """ + OpenQASM info for the controlled general single-qubit rotation gate CU3(theta, phi, + lambda), applied with three parameters supplied through the ``params`` keyword of + ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ # todo: update to the default rotation in Qiskit plotting imports = [] definition = """ @@ -266,6 +328,13 @@ def usage(q_reg, q_reg_type, c_reg, params): def ry_info(): + """ + OpenQASM info for the RY(theta) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate ry(theta) a { U(theta,0,0) a; }" @@ -276,6 +345,13 @@ def usage(q_reg, q_reg_type, c_reg, params): def rx_info(): + """ + OpenQASM info for the RX(theta) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate rx(theta) a { U(theta, -pi/2,pi/2) a; }" @@ -286,8 +362,15 @@ def usage(q_reg, q_reg_type, c_reg, params): def rz_info(): + """ + OpenQASM info for the RZ(phi) rotation gate, applied with the rotation angle supplied + through the ``params`` keyword of ``use_gate``. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] - definition = " gate rz(phi) a { U(phi) a; }" + definition = " gate rz(phi) a { U(0,0,phi) a; }" def usage(q_reg, q_reg_type, c_reg, params): return f"rz({params[0]}) {q_reg_type[0]}{q_reg[0]}[0];" @@ -299,7 +382,8 @@ def single_qubit_wrapper_info(op_list): """ This function combines the openqasm info from multiple base Operations, to create a composed block of multiple operations - :param op_list: list of operation classes which will be applied by the Operation block + :param op_list: list of operation classes which will be applied by the Operation block, + in OneQubitGateWrapper order: the last entry is applied first :type op_list: list :return: OpenQASMInfo object corresponding to the composed gate :rtype: OpenQASMInfo @@ -323,7 +407,9 @@ def single_qubit_wrapper_info(op_list): definitions = definitions + oq_info.define_gate gate_name += oq_info.gate_name gate_symbol += oq_info.gate_symbol - def_usage += f"{oq_info.gate_name} a;\n" + # a OneQubitGateWrapper applies its operations last entry first, so the + # definition body lists them in reverse + def_usage = f"{oq_info.gate_name} a;\n" + def_usage if gate_name in gate_name_dict: # i.e. gate is already somehow defined return gate_name_dict[gate_name] @@ -339,6 +425,13 @@ def usage(q_reg, q_reg_type, c_reg): def cz_info(): + """ + OpenQASM info for the controlled-Z (CZ) gate, implemented as a Hadamard on the target, a + CNOT, then a Hadamard on the target. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "gate cz a, b { U(pi/2, 0, pi) b; CX a, b; U(pi/2, 0, pi) b; }" # H on target, CX, H on target @@ -349,6 +442,14 @@ def usage(q_reg, q_reg_type, c_reg): def classical_cnot_info(): + """ + OpenQASM info for a measurement-controlled CNOT: the control qubit is measured into a + classical register, and an X gate is applied to the target qubit conditioned on that + measurement outcome being 1. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_x_info().definitions[0] @@ -362,6 +463,14 @@ def usage(q_reg, q_reg_type, c_reg): def classical_cz_info(): + """ + OpenQASM info for a measurement-controlled CZ: the control qubit is measured into a + classical register, and a Z gate is applied to the target qubit conditioned on that + measurement outcome being 1. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_z_info().definitions[0] @@ -375,6 +484,14 @@ def usage(q_reg, q_reg_type, c_reg): def measurement_cnot_and_reset(): + """ + OpenQASM info for a measurement-controlled CNOT followed by resetting the control qubit: + the control qubit is measured into a classical register, an X gate is applied to the + target qubit conditioned on that outcome being 1, then the control qubit is reset to 0. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = sigma_x_info().definitions[0] @@ -390,6 +507,12 @@ def usage(q_reg, q_reg_type, c_reg): def z_measurement_info(): + """ + OpenQASM info for a Z-basis measurement, storing the outcome into a classical register. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ imports = [] definition = "" @@ -400,4 +523,11 @@ def usage(q_reg, q_reg_type, c_reg): def empty_info(): + """ + Empty OpenQASM info, shared by the identity gate and the input/output placeholder + operations: no imports, an empty definition string, and a usage string that is always empty. + + :return: the OpenQASM info object + :rtype: OpenQASMInfo + """ return OpenQASMInfo("", [], "", lambda x, y, z: "", False) diff --git a/graphiq/utils/openqasm_parser.py b/graphiq/utils/openqasm_parser.py index eb5607e7..82de2a4e 100644 --- a/graphiq/utils/openqasm_parser.py +++ b/graphiq/utils/openqasm_parser.py @@ -29,7 +29,7 @@ def __init__(self, openqasm_str: str): between strings and parse functions. :param openqasm_str: OpenQASM 2.0 script string - :type: str + :type openqasm_str: str """ self.openqasm = openqasm_str @@ -88,8 +88,8 @@ class to parse the script to a list of node. Then, from there, the parser will c Note: For faster data processing time, the yield syntax is used here, which will return the parse info immediately after each node parsed. - :return: parsed node info - :rtype: dict + :return: a generator yielding the parsed info of each node + :rtype: generator (of dict) """ parser = Qasm(data=self.openqasm).parse() def_arr = ["qreg", "creg", "gate"] @@ -288,7 +288,7 @@ def _parse_measure(self, measure_node): "name": creg_node.name, } if hasattr(creg_node, "index"): - measure_info["creg"]["index"] = qreg_node.index + measure_info["creg"]["index"] = creg_node.index return measure_info diff --git a/graphiq/utils/photon_loss.py b/graphiq/utils/photon_loss.py index 47792338..4a51ff56 100644 --- a/graphiq/utils/photon_loss.py +++ b/graphiq/utils/photon_loss.py @@ -47,9 +47,13 @@ def photon_survival_rate(circuit): if edge[2] == f"p{reg}" ] node = out_edge[0][1] - op_noise = circuit.dag.nodes[node]["op"].noise + op = circuit.dag.nodes[node]["op"] + op_noise = op.noise if isinstance(op_noise, list): - op_noise = op_noise[1] + # a two-qubit operation keeps one noise model per leg, in + # (control, target) order + on_control = op.q_registers_type[0] == "p" and op.q_registers[0] == reg + op_noise = op_noise[0] if on_control else op_noise[1] if isinstance(op_noise, PhotonLoss): survive[reg] = (1 - op_noise.noise_parameters["loss rate"]) * survive[ diff --git a/graphiq/utils/preprocessing.py b/graphiq/utils/preprocessing.py index 24a9b603..000fb326 100644 --- a/graphiq/utils/preprocessing.py +++ b/graphiq/utils/preprocessing.py @@ -12,6 +12,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Graph-level metrics and local-complementation search helpers used to rank candidate graph +states -- e.g. for greedy selection among the graphs reachable by local complementation. +""" + +import heapq + import networkx as nx import numpy as np @@ -27,12 +34,13 @@ def graph_circuit_depth(graph): """ - Return max of depth of emitter registers. + Find a circuit that generates the graph state with TimeReversedSolver, and return the maximum depth of + its emitter registers. - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph representing the target graph state + :type graph: nx.Graph + :return: the maximum emitter register depth of the circuit + :rtype: int """ target_tableau = get_clifford_tableau_from_graph(graph) target_state = QuantumState(target_tableau, rep_type="s") @@ -49,12 +57,14 @@ def graph_circuit_depth(graph): def graph_max_eccentricity(graph): """ - Return the maximum value of node eccentricity - - :param graph: - :type graph: - :return: - :rtype: + Return the negated maximum value of node eccentricity, so that a lower value corresponds to a larger + eccentricity + + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the negative of the maximum node eccentricity + :rtype: int """ node_eccentricity = nx.eccentricity(graph) @@ -71,10 +81,11 @@ def graph_max_centrality(graph): """ Return the maximum of node degree centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum degree centrality over all nodes + :rtype: float """ degree_centrality = nx.degree_centrality(graph) degree_centrality_list = degree_centrality.values() @@ -88,11 +99,13 @@ def graph_max_centrality(graph): def graph_betweenness_centrality(graph): """ + Return the maximum of node betweenness centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum betweenness centrality over all nodes + :rtype: float """ betweenness_centrality = nx.betweenness_centrality(graph) betweenness_centrality_list = betweenness_centrality.values() @@ -106,11 +119,13 @@ def graph_betweenness_centrality(graph): def graph_closeness_centrality(graph): """ + Return the maximum of node closeness centrality - :param graph: - :type graph: - :return: - :rtype: + :param graph: the graph to score + :type graph: nx.Graph + :raises ValueError: if the graph has no nodes + :return: the maximum closeness centrality over all nodes + :rtype: float """ closeness_centrality = nx.closeness_centrality(graph) closeness_centrality_list = closeness_centrality.values() @@ -124,43 +139,48 @@ def graph_closeness_centrality(graph): def _select_graphs(candidate_graphs, new_graph, limit, metric_value): """ - Helper function to select graphs - - :param candidate_graphs: - :type candidate_graphs: - :param new_graph: - :type new_graph: - :param limit: - :type limit: - :param metric_value: - :type metric_value: - :return: - :rtype: + Helper function to select graphs: add new_graph to the candidates and keep + the `limit` entries with the lowest metric value + + :param candidate_graphs: the current candidates, as (metric value, graph) pairs + :type candidate_graphs: list[(float, networkx.Graph)] + :param new_graph: the graph to consider adding + :type new_graph: networkx.Graph + :param limit: the maximum number of candidates to keep + :type limit: int + :param metric_value: the metric value of new_graph + :type metric_value: float + :return: the updated candidates, sorted by ascending metric value + :rtype: list[(float, networkx.Graph)] """ - if len(candidate_graphs) < limit: - candidate_graphs.append((metric_value, new_graph)) - else: - for i in range(len(candidate_graphs)): - if metric_value < candidate_graphs[i][0]: - candidate_graphs.insert(i, (metric_value, new_graph)) - candidate_graphs.pop() - break - return candidate_graphs + # keep the `limit` lowest-metric entries; the list was previously only + # sorted once full, so the best candidate could be evicted. key= avoids + # comparing the graphs themselves on ties. + return heapq.nsmallest( + limit, + candidate_graphs + [(metric_value, new_graph)], + key=lambda pair: pair[0], + ) def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): """ - - :param graph: - :type graph: - :param n_graphs: - :type n_graphs: - :param graph_metric: - :type graph_metric: - :param n_trial: - :type n_trial: - :return: - :rtype: + Search the local-complementation orbit of a graph: for n_trial rounds, locally complement each candidate + graph at every node with the maximum number of edges (degree), score each resulting graph with graph_metric, + and keep the n_graphs candidates with the lowest score. + + :param graph: the graph to search from + :type graph: nx.Graph or numpy.ndarray + :param n_graphs: the maximum number of candidate graphs to keep + :type n_graphs: int + :param graph_metric: a function scoring a graph, called as graph_metric(graph) + :type graph_metric: function + :param n_trial: the number of local-complementation rounds; defaults to 5 + :type n_trial: int + :raises TypeError: if the type of graph is not exactly nx.Graph or numpy.ndarray (subclasses such as + nx.DiGraph or nx.MultiGraph are rejected) + :return: at most n_graphs (score, graph) tuples, sorted by ascending score + :rtype: list[(float, nx.Graph)] """ if type(graph) == np.ndarray: graph = nx.from_numpy_array(graph) @@ -189,6 +209,14 @@ def get_lc_graph_by_max_edge(graph, n_graphs, graph_metric, n_trial=5): def _count_n_neighbor_edges(adj_matrix): + """ + Count, for each node, the number of edges among that node's neighbors. + + :param adj_matrix: adjacency matrix of the graph + :type adj_matrix: numpy.ndarray + :return: array giving, per node, the number of edges among its neighbors + :rtype: numpy.ndarray + """ n_node = adj_matrix.shape[0] edges_count_list = np.zeros(n_node) for index in range(n_node): @@ -199,6 +227,14 @@ def _count_n_neighbor_edges(adj_matrix): def _count_n_neighbor(adj_matrix): + """ + Count, for each node, its number of neighbors (degree). + + :param adj_matrix: adjacency matrix of the graph + :type adj_matrix: numpy.ndarray + :return: array giving, per node, its number of neighbors + :rtype: numpy.ndarray + """ n_node = adj_matrix.shape[0] neighbor_count_list = np.zeros(n_node) for index in range(n_node): @@ -209,17 +245,22 @@ def _count_n_neighbor(adj_matrix): def get_lc_graph_by_max_neighbor_edge(graph, n_graphs, graph_metric, n_trial=5): """ - - :param graph: - :type graph: - :param n_graphs: - :type n_graphs: - :param graph_metric: - :type graph_metric: - :param n_trial: - :type n_trial: - :return: - :rtype: + Search the local-complementation orbit of a graph: for n_trial rounds, locally complement each candidate + graph at every node whose neighbors have the maximum number of edges among themselves, score each resulting + graph with graph_metric, and keep the n_graphs candidates with the lowest score. + + :param graph: the graph to search from + :type graph: nx.Graph or numpy.ndarray + :param n_graphs: the maximum number of candidate graphs to keep + :type n_graphs: int + :param graph_metric: a function scoring a graph, called as graph_metric(graph) + :type graph_metric: function + :param n_trial: the number of local-complementation rounds; defaults to 5 + :type n_trial: int + :raises TypeError: if the type of graph is not exactly nx.Graph or numpy.ndarray (subclasses such as + nx.DiGraph or nx.MultiGraph are rejected) + :return: at most n_graphs (score, graph) tuples, sorted by ascending score + :rtype: list[(float, nx.Graph)] """ if type(graph) == np.ndarray: diff --git a/graphiq/utils/relabel_module.py b/graphiq/utils/relabel_module.py index 121c77f1..8b7b82b2 100644 --- a/graphiq/utils/relabel_module.py +++ b/graphiq/utils/relabel_module.py @@ -16,6 +16,7 @@ Utility functionality for creating isomorphic graphs to explore alternative circuits. """ +import math import warnings from itertools import permutations @@ -40,11 +41,12 @@ def iso_finder( ): """ The function permutes the labels of the vertices of a graph to get n_iso distinct isomorphic graphs. The original - graph will also be returned as the first element of the list and counted toward the number of found cases. + graph will also be returned as the first element of the list (unless sort_emit reorders the list) and counted + toward the number of found cases. The maximum number of possible distinct cases may be less than n_iso. The graph G with the nodes relabeled using consecutive integers. - :param adj_matrix: initial adjacency matrix or graph + :param adj_matrix: initial adjacency matrix :type adj_matrix: numpy.ndarray :param n_iso: number of the isomorphic graphs to look for (including the original graph) :type n_iso: int @@ -70,13 +72,12 @@ def iso_finder( :rtype: numpy.ndarray or tuple (numpy.ndarray, list[dict]) """ n_node = adj_matrix.shape[0] - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) n_label = n_iso labels_arr = _label_finder(n_label, n_node, seed=seed, thresh=thresh) adj_arr = automorph_check(adj_matrix, labels_arr) if len(adj_arr) >= n_iso: - adj_arr = adj_arr[:n_iso] - return adj_arr + return _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map) else: rel_inc = 1 # relative increase ratio of the number of non-redundant cases as the number of labeling increase all_checked = False @@ -92,7 +93,9 @@ def iso_finder( f"Only {len(adj_arr)} isomorphic graphs were found due to high symmetry." f"This may not be the maximum value. Allow for exhaustive search to check" ) - return adj_arr + return _finish_iso_output( + adj_matrix, adj_arr, n_iso, sort_emit, label_map + ) # update the seed used in _add_labels to get new values when we repeat it in the loop seed = seed + 1 if (seed is not None) else None @@ -119,14 +122,35 @@ def iso_finder( ) else: pass - if sort_emit: - adj_arr = np.array([x[0] for x in emitter_sorted(adj_arr[:n_iso])]) - if label_map: - mapping = [] - for new_adj in adj_arr: - mapping.append(get_relabel_map(adj_matrix, new_adj)) - return adj_arr[:n_iso], mapping - return adj_arr[:n_iso] + return _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map) + + +def _finish_iso_output(adj_matrix, adj_arr, n_iso, sort_emit, label_map): + """ + Truncates the adjacency matrices found by to n_iso, then applies its sort_emit and label_map + options, so that every return path of treats them the same way. + + :param adj_matrix: the initial adjacency matrix given to + :type adj_matrix: numpy.ndarray + :param adj_arr: the distinct relabeled adjacency matrices found + :type adj_arr: numpy.ndarray + :param n_iso: number of the isomorphic graphs to return at most + :type n_iso: int + :param sort_emit: if True the outcome is sorted by the number of emitters needed to generate each graph + :type sort_emit: bool + :param label_map: if True, a list of label maps between the initial graph and each returned one is returned too + :type label_map: bool + :return: the adjacency matrices, or a tuple of the adjacency matrices and the list of label maps if label_map + is True + :rtype: numpy.ndarray or tuple (numpy.ndarray, list[dict]) + """ + adj_arr = adj_arr[:n_iso] + if sort_emit: + adj_arr = np.array([x[0] for x in emitter_sorted(adj_arr)]) + if label_map: + mapping = [get_relabel_map(adj_matrix, new_adj) for new_adj in adj_arr] + return adj_arr, mapping + return adj_arr def emitter_sorted(adj_arr): @@ -187,16 +211,17 @@ def _label_finder( n_label, n_node, new_label_set=None, exhaustive=False, seed=None, thresh=None ): """ - Finds n_label number of permutations for the sequence {0, 1, ..., n_node-1}. If exhaustive search is enabled the - process happens through sampling from the total set of all possible permutations (size = n_node factorial). - Otherwise, new permutations are generated randomly until the required number of distinct cases are found, or - the threshold for number of random trials is reached. + Finds n_label number of permutations for the sequence {0, 1, ..., n_node-1}. If exhaustive search is enabled, or + n_node is less than 8, the process happens through sampling from the total set of all possible permutations + (size = n_node factorial). Otherwise, new permutations are generated randomly until the required number of distinct + cases are found, or the threshold for number of random trials is reached. :param n_label: number of permutations needed :type n_label: int :param n_node: length of the initial sequence {0, 1, ..., n_node-1} :type n_node: int - :param new_label_set: a given set of permutations that the function will add the new cases to. + :param new_label_set: a given set of permutations that the function will add the new cases to; ignored when n_node + is less than 8 or exhaustive is True. :type new_label_set: set :param exhaustive: If true the permutation will be sampled from the set of all possible permutations. There might be a significant increase in runtime for large sequences. @@ -209,7 +234,7 @@ def _label_finder( :rtype: numpy.ndarray """ rng = np.random.default_rng(seed) - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) if thresh is None: thresh = 5 * n_label elif thresh < n_label: @@ -219,10 +244,20 @@ def _label_finder( ), f"The input number of permutations is more than the maximum possible" if n_node < 8 or exhaustive: perm = list(permutations([*range(n_node)])) - initial_perm = np.array([[*range(n_node)]]) - labels_list = rng.choice(perm[1:], n_label - 1) - labels_list = np.concatenate((initial_perm, labels_list), axis=0) - return labels_list + # the draw is with replacement: keep the first occurrence of each permutation, + # then top up without replacement from the permutations not drawn + drawn = rng.choice(perm[1:], n_label - 1) + labels_list = [perm[0]] + seen = {perm[0]} + for label in map(tuple, drawn): + if label not in seen: + seen.add(label) + labels_list.append(label) + if len(labels_list) < n_label: + unused = [label for label in perm if label not in seen] + top_up = rng.choice(len(unused), n_label - len(labels_list), replace=False) + labels_list += [unused[i] for i in sorted(top_up)] + return np.array(labels_list) else: if new_label_set is None: new_label_set = set() @@ -258,7 +293,7 @@ def _add_labels(labels_arr, add_n, exhaustive=False, seed=None, thresh=None): """ n_node = labels_arr[0].shape[0] n_label = len(labels_arr) - n_max = np.math.factorial(n_node) + n_max = math.factorial(n_node) n_total = add_n + n_label if add_n + n_label < n_max else n_max label_set = set([tuple(labels) for labels in labels_arr]) return _label_finder( @@ -309,7 +344,8 @@ def get_relabel_map(g1, g2): :type g1: nx.Graph or np.ndarray :param g2: networkx graph or adjacency matrix :type g2: nx.Graph or np.ndarray - :return: a dictionary mapping each node of g1 to a node of g2. The map may not be unique. + :return: a dictionary mapping each node of g1 to a node of g2. The map may not be unique. If g1 and g2 have the same + adjacency matrix, the dictionary also has the entry -1: "self". :rtype: dict """ # g1 and g2 can be adj matrices or nx graphs. @@ -342,18 +378,24 @@ def lc_orbit_finder( come up with new distinct graphs in the orbit of the input graph. The comp_depth determines the maximum depth of the orbit explored. - :param graph: original graph + :param graph: original graph; its nodes must be labelled 0..n-1 in iteration order, since node labels are also + used as adjacency-matrix indices :type graph: nx.Graph :param comp_depth: the maximum length of the sequence of local-complementations applied on the graph; if None, continue till the required number of graphs are found, or no new graphs are found. :type comp_depth: int :param orbit_size_thresh: sets a limit on the maximum number of orbit graphs to look for :type orbit_size_thresh: int - :param with_iso: if true, isomorph graphs will be kept in the orbit + :param with_iso: if true, isomorphic graphs will be kept in the orbit :type with_iso: bool - :param rep_allowed: if true the orbit finder does not check for iso or auto morphism; useful for large graphs. + :param rand: if true, the search starts from a graph obtained by applying random local complementations to + the original graph, instead of from the original graph itself, and the nodes are tried in a random order; + the returned list then starts with that graph instead of the original one :type rand: bool - :return: list of distinct graphs in the orbit of original graph + :param rep_allowed: if true the orbit finder does not check for iso or auto morphism; useful for large graphs. + :type rep_allowed: bool + :return: list of graphs in the orbit of original graph, starting with the graph the search starts from; the + graphs are distinct unless rep_allowed is true, in which case repeats may occur :rtype: list[nx.Graph] """ orbit_list = [graph.copy()] @@ -410,9 +452,11 @@ def rgs_orbit_finder(graph: nx.Graph): """ Takes a repeater graph state, and returns the full list of distinct graphs in the orbit. The first graph in the list is the original graph state. - :param graph: original graph + :param graph: original graph; its nodes must be labelled 0..n-1 in iteration order, since node labels are + also used as adjacency-matrix indices by local_comp_graph :type graph: nx.Graph - :return: a full list of graphs in the LC orbit of the graph state + :return: a full list of graphs in the LC orbit of the graph state, each after the first labelled 0..n-1 by + position :rtype: list """ n = len(graph) @@ -443,9 +487,14 @@ def linear_partial_orbit(graph: nx.Graph): """ Takes a linear cluster state, and returns a list of distinct graphs in the orbit. Not exhaustive. The first graph in the list is the original graph state. - :param graph: original graph + :param graph: original graph, a path (linear graph) whose nodes may come in any iteration order and carry any + labels; the local complementations are applied along the path, from whichever end comes first in graph's + node iteration order :type graph: nx.Graph - :return: a list of graphs in the LC orbit of the linear cluster state + :raises AssertionError: if graph is not a path on at least 3 nodes + :return: a list of graphs in the LC orbit of the linear cluster state, labelled 0..n-1 by position in graph's + iteration order, so the first is the original graph relabelled that way (literally the original graph when + graph's labels are already 0..n-1 in iteration order) :rtype: list """ orbit_list = [] @@ -454,17 +503,40 @@ def linear_partial_orbit(graph: nx.Graph): # check that graph is linear degrees = [degree for _, degree in g.degree()] assert ( - n - 1 == graph.size() and max(degrees) == 2 + n - 1 == graph.size() and max(degrees) == 2 and nx.is_connected(g) ), "input graph is not a linear graph" + # _partial_orbit indexes nodes along the path, but local_comp_graph reads positions in + # iteration order, so map each path index to the position of that node, walking the path + # from the end that comes first in iteration order + position = {node: i for i, node in enumerate(g)} + start = next(node for node in g if g.degree(node) == 1) + path = [position[node] for node in nx.dfs_preorder_nodes(g, start)] for lc_ops in _partial_orbit(n): new_g = g for x in lc_ops: - new_g = local_comp_graph(new_g, x) + new_g = local_comp_graph(new_g, path[x]) orbit_list.append(new_g) return orbit_list def depth_first_orbit(graph: nx.Graph): + """ + Find pairwise non-isomorphic representatives from the local-complementation orbit of a + graph, via a depth-first search over local-complementation sequences on nodes 0 to n-1 + (see :func:`_depth_first`), then build every graph reached along the way by re-applying, + from scratch, every prefix of every path the search recorded. + + This is far smaller than the labelled orbit, because :func:`_depth_first` prunes against + every graph found in any branch: the 5-cycle returns 3 graphs where its labelled orbit has + 132, and the 4-path returns 4 where the orbit has 11. A caller that needs specific + labellings rather than one representative per isomorphism class cannot use this. + + :param graph: original graph + :type graph: nx.Graph + :return: one graph per isomorphism class reached, including the original graph, in no + particular order + :rtype: list[nx.Graph] + """ n = len(graph) g = graph.copy() _, _, _, path_list = _depth_first(g, n) @@ -529,9 +601,14 @@ def check_isomorphism(graph, g_list, _only_auto=False): def _equal_graphs(g1, g2): """ + Check whether two graphs have the same adjacency matrix, with the nodes of each graph taken in its iteration + order and edge weights ignored. This compares labelled graphs; it is not an isomorphism test. + :param g1: a graph :type g1: nx.Graph - :return: given two graphs this function return true if they have the same adjacency matrix. + :param g2: a graph + :type g2: nx.Graph + :return: True if the two graphs have the same adjacency matrix, False otherwise :rtype: bool """ adj1 = (nx.to_numpy_array(g1)).astype(bool) @@ -541,7 +618,21 @@ def _equal_graphs(g1, g2): def _compare_graphs_visual(g, new_g, new_labels): """ - Visual demonstration of initial graph, the relabeled one. + Visual demonstration of initial graph, the relabeled one. It relabels g by mapping node i to new_labels[i], + and draws g, the relabeled graph, and new_g in three stacked panels. g is also first drawn on the current + axes. + + :param g: the initial graph, with nodes labelled 0..n-1 + :type g: nx.Graph + :param new_g: a graph to draw next to the relabeled one; every node of new_g must be one of new_labels, + since it is drawn with the relabeled graph's layout + :type new_g: nx.Graph + :param new_labels: the new labels, where new_labels[i] is the new label of node i + :type new_labels: list or numpy.ndarray + :raises AssertionError: if the relabeled graph's nodes, in order, are not new_labels, e.g. for repeated + labels, a label list of the wrong length, or a g whose nodes do not iterate as 0..n-1 + :return: nothing + :rtype: None """ nx.draw_networkx(g, with_labels=True, pos=nx.kamada_kawai_layout(g)) fig, (ax1, ax2, ax3) = plt.subplots(3, figsize=(15, 25)) @@ -549,7 +640,7 @@ def _compare_graphs_visual(g, new_g, new_labels): n = len(new_labels) relabel_map_dict = dict(zip([*range(n)], new_labels)) g_relabeled = nx.relabel_nodes(g, relabel_map_dict) - assert list(new_labels) == g_relabeled.nodes() + assert list(new_labels) == list(g_relabeled.nodes()) nx.draw_networkx( g_relabeled, @@ -581,6 +672,22 @@ def _compare_graphs_visual(g, new_g, new_labels): def _retrieve_seq(n, seq_dict=None): + """ + Recursively build the sequence of node indices to locally-complement, in order, to reach + every new graph obtainable by local-complementation operations on n nodes -- an + intermediate step used by :func:`_full_seq` to build the full sequence for larger node + counts. The entries run from 2 up to n inclusive and never include 0 or 1: for example + ``_retrieve_seq(3)`` is ``[3, 2, 3, 2, 2]``. + + :param n: the node count this sub-sequence is built for + :type n: int + :param seq_dict: cache of previously computed sequences, keyed by node count; a cached + entry is reused instead of recomputed, but this function does not write new results + back into it + :type seq_dict: dict or None + :return: the sequence of node indices + :rtype: list[int] + """ # an intermediate step finder to complete the full sequence of nodes eligible for generation of new graphs by LC ops if seq_dict is not None: if n in seq_dict: @@ -605,6 +712,19 @@ def _retrieve_seq(n, seq_dict=None): def _full_seq(n): + """ + Build the sequence of node indices to locally-complement, in order, that yields every new + graph reachable via local-complementation operations confined to nodes 0 to n - 1, + intended for use with node counts large compared to n (at least 2 * n). A 0 is inserted + after every entry built from :func:`_retrieve_seq`, including the last, and [0, 1] is + prepended -- ``_full_seq(3)`` is ``[0, 1, 2, 0, 2, 0]``. For n of 1 or less the result is + just ``[0]``, with no prefix. + + :param n: upper node index (exclusive) the sequence's operations are confined to + :type n: int + :return: the sequence of node indices + :rtype: list[int] + """ # for infinite (or at least larger than 2*n) find sequence of ndes for LC operations that result in new graphs # obtained by operations on nodes upto 'n'. seq = [] @@ -625,6 +745,18 @@ def _full_seq(n): def _partial_orbit(n): + """ + Build, for a linear graph (path graph) on n nodes, the sequence of node indices to + locally-complement that reaches a partial (non-exhaustive) sample of its + local-complementation orbit, and return every prefix of that sequence. + + :param n: number of nodes in the linear graph + :type n: int + :return: prefixes of the node-index sequence; applying each prefix in order as successive + local complementations, starting from the original linear graph, yields one member of + the sampled orbit + :rtype: list[list[int]] + """ m = int(n / 2) + 1 if n % 2 == 0: seq1 = _full_seq(m - 1) @@ -648,6 +780,39 @@ def _partial_orbit(n): def _depth_first( g0, n, g_orbit=None, path=None, path_list=None, orbit_list=None, exact=False ): + """ + Recursively depth-first search local-complementation sequences on nodes 0 to n - 1, + starting from g0. At each node visited that produces a graph not isomorphic (via + ``nx.vf2pp_is_isomorphic``) to any graph recorded so far -- in every branch explored + already, not merely along the current path -- the search recurses from that new graph. + Because the check is global, the search keeps one representative per isomorphism class + rather than every labelling. The maximal node-index paths explored, together with the + graphs visited along each, are recorded into path_list and orbit_list. Call with only g0, + n and (optionally) exact -- the remaining keyword arguments initialize the search state on + that first call and are threaded through by the recursion afterwards. + + :param g0: graph to search from at this recursion step + :type g0: nx.Graph + :param n: number of nodes to try local complementation on at each step + :type n: int + :param g_orbit: graphs visited along the current path, seeded with [g0] on the top-level + call + :type g_orbit: list[nx.Graph] or None + :param path: sequence of node indices local-complemented so far to reach g0 + :type path: list[int] or None + :param path_list: accumulator of the maximal paths explored; a path that is a prefix of one + already recorded is skipped, so callers wanting the prefixes must rebuild them + :type path_list: list[list[int]] or None + :param orbit_list: accumulator of the graphs visited along each path in path_list + :type orbit_list: list[list[nx.Graph]] or None + :param exact: if True, isomorphism is checked on the subgraphs induced by + ``range(n + 1)`` rather than on the whole graphs. On a graph labelled 0 to n - 1 that + range covers every node, so the flag has no effect there; no caller sets it + :type exact: bool + :return: the updated (g_orbit, orbit_list, path, path_list), with the branch taken at this + recursion step backtracked out of g_orbit and path + :rtype: tuple[list[nx.Graph], list[list[nx.Graph]], list[int], list[list[int]]] + """ if g_orbit is None and path is None and path_list is None and orbit_list is None: g_orbit = [g0] orbit_list = [] diff --git a/graphiq/visualizers/__init__.py b/graphiq/visualizers/__init__.py index a5ef896b..e5f7c6e9 100644 --- a/graphiq/visualizers/__init__.py +++ b/graphiq/visualizers/__init__.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Matplotlib-based visualization helpers: density-matrix heatmaps and bar plots, graph drawing, solver-log plots, +and openqasm-driven circuit diagrams. +""" + from .density_matrix import density_matrix_bars, density_matrix_heatmap from .graph import * from .solver_logs import * diff --git a/graphiq/visualizers/density_matrix.py b/graphiq/visualizers/density_matrix.py index c4931073..4a446d5e 100644 --- a/graphiq/visualizers/density_matrix.py +++ b/graphiq/visualizers/density_matrix.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Plots a density matrix's real and imaginary parts side by side, either as 2D heatmaps or as 3D bar plots. +""" + import matplotlib.pyplot as plt import numpy as np import seaborn as sns @@ -26,15 +30,17 @@ def density_matrix_heatmap(rho, axs=None): :param rho: a complex numpy array representing the density matrix :type rho: numpy.ndarray - :param axs: axis to plot on - :type axs: plt.Axis - :return: fig (figure handle), axs (list of axes handles) - :rtype: matplotlib.Figure, matplotlib.Axes + :param axs: two axes to plot on, the real part on axs[0] and the imaginary part on axs[1] (optional); + when given, the returned figure is axs[0]'s root figure + :type axs: sequence of two matplotlib.Axes + :return: fig (figure handle), axs (the axes plotted on) + :rtype: matplotlib.Figure, sequence of two matplotlib.Axes """ if axs is None: fig, axs = plt.subplots(nrows=1, ncols=2, figsize=[10, 4]) else: - fig = None + # axs[0].figure is a SubFigure when the axes sit in one; its .figure is the root Figure. + fig = axs[0].figure.figure kwargs = dict(vmin=-1, vmax=1, cmap=cmap_div, linewidths=0.5, square=True) axs[0].set(title="Real") @@ -53,7 +59,7 @@ def density_matrix_bars(rho): :param rho: a complex numpy array representing the density matrix :type rho: numpy.ndarray :return: fig (figure handle), axs (list of axes handles) - :rtype: matplotlib.Figure, matplotlib.Axes + :rtype: matplotlib.Figure, list[matplotlib.Axes] """ def bar_plot(deltaz, ax): @@ -82,7 +88,8 @@ def bar_plot(deltaz, ax): deltaz = deltaz.flatten() colors = cmap_div( - deltaz.ravel() * 0.8, alpha=1 - 0.5 * (np.abs(deltaz.ravel()) / max_height) + 0.5 + 0.5 * deltaz.ravel() / max_height, + alpha=1 - 0.5 * (np.abs(deltaz.ravel()) / max_height), ) ax.bar3d(x, y, z, dx, dy, deltaz, color=colors) ax.set(zlim=zlim) diff --git a/graphiq/visualizers/graph.py b/graphiq/visualizers/graph.py index 0c01f4c7..eba74ec4 100644 --- a/graphiq/visualizers/graph.py +++ b/graphiq/visualizers/graph.py @@ -44,6 +44,7 @@ def draw_graph(state_graph, show=False, ax=None, with_labels=True): ) if show: plt.show() - fig = plt.gcf() - ax = plt.gca() - return fig, ax + if ax is None: + return plt.gcf(), plt.gca() + # ax.figure is a SubFigure when ax sits in one; its .figure is the root Figure. + return ax.figure.figure, ax diff --git a/graphiq/visualizers/openqasm_visualization.py b/graphiq/visualizers/openqasm_visualization.py index 8f623645..e2b8dad5 100644 --- a/graphiq/visualizers/openqasm_visualization.py +++ b/graphiq/visualizers/openqasm_visualization.py @@ -12,6 +12,11 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Renders an openqasm 2 circuit description as a circuit diagram via Qiskit's circuit drawer. +:meth:`graphiq.circuit.circuit_base.CircuitBase.draw_circuit` uses :func:`draw_openqasm` to draw circuits. +""" + import matplotlib.pyplot as plt from qiskit import QuantumCircuit diff --git a/graphiq/visualizers/solver_logs.py b/graphiq/visualizers/solver_logs.py index 061e9d04..c8175f27 100644 --- a/graphiq/visualizers/solver_logs.py +++ b/graphiq/visualizers/solver_logs.py @@ -12,6 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Plots a solver's per-iteration logs (cost value and circuit depth) as a 2x2 grid of subplots. +""" + import matplotlib.pyplot as plt @@ -21,7 +25,9 @@ def plot_solver_logs(logs: dict): solver iteration. :param logs: dictionary containing a log (pd.DataFrame) for population and hof - :return: + :type logs: dict + :return: fig (figure handle), axs (2x2 array of axes handles) + :rtype: matplotlib.Figure, numpy.ndarray """ fig, axs = plt.subplots(nrows=2, ncols=2, sharey="row", sharex="col") colors = ["teal", "orange"] diff --git a/pyproject.toml b/pyproject.toml index 9fcc7c8f..a722a523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,13 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -py-modules = ["graphiq"] - [tool.setuptools.packages.find] where = ["."] +include = ["graphiq*"] [project] name = "graphiq" -version = "0.1.1" +version = "0.1.2" requires-python = ">3.8, <4" dependencies = [ @@ -25,6 +23,7 @@ dependencies = [ "pandas", "psutil", "py-cpuinfo", + "requests", # "qiskit", "qiskit ~= 0.38.0", "pylatexenc" @@ -46,7 +45,7 @@ classifiers = [ "Development Status :: 4 - Beta", # Indicate who your project is intended for - "Intended Audience :: Science/Research ", + "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Physics", "Topic :: Security :: Cryptography", "Topic :: Scientific/Engineering :: Information Analysis", diff --git a/requirements.txt b/requirements.txt index 94a59939..9268c4b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,10 @@ numpy~=1.24.4 matplotlib~=3.7.5 scipy~=1.10.1 networkx~=3.1 -autograd pytest~=7.1.3 -genbadge coverage pandas~=1.5.0 seaborn~=0.12.0 -defusedxml qiskit~=0.38.0 pylatexenc ray[default] @@ -17,5 +14,3 @@ psutil jax optax tqdm -networkx -matplotlib \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..179722a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Force a non-interactive matplotlib backend for the whole test suite, so that +# tests exercising draw_circuit()/plt.show() etc. don't pop up plot windows. +# This must run before matplotlib.pyplot is imported anywhere else, which is +# why it lives in the top-level conftest.py: pytest loads it before collecting +# any test module (and before their own conftest.py files import graphiq). +import matplotlib +import pytest + +matplotlib.use("Agg") + + +@pytest.fixture(autouse=True) +def _mixture_fold_flag_is_restored(): + """ + Fail the test that leaves ``REDUCE_STABILIZER_MIXTURE`` flipped. + + The flag is a module global read on every depolarizing noise application; a test that + sets it False without restoring it turns folding off for every later test in the + process, and noisy solver runs then grow their mixtures without bound. Autouse so this + is set up before ``monkeypatch`` and torn down after it. + """ + yield + import graphiq.noise.noise_models as nm + + assert nm.REDUCE_STABILIZER_MIXTURE is True, ( + "this test left graphiq.noise.noise_models.REDUCE_STABILIZER_MIXTURE = " + f"{nm.REDUCE_STABILIZER_MIXTURE!r}; restore it with monkeypatch.setattr" + ) diff --git a/tests/test_backends/__init__.py b/tests/test_backends/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_backends/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_backends/test_density_matrix_array_library.py b/tests/test_backends/test_density_matrix_array_library.py new file mode 100644 index 00000000..1d2a5d81 --- /dev/null +++ b/tests/test_backends/test_density_matrix_array_library.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Selecting the jax array library for the density-matrix backend. +""" + +import importlib +import sys +import types + +import numpy as np +import pytest + +import graphiq +import graphiq.backends.density_matrix as dm_package + + +def _fake_jax(calls): + # current jax: `jax.config` is the Config object, and there is no + # `jax.config` submodule to import from + jax = types.ModuleType("jax") + jax.config = types.SimpleNamespace(update=lambda *args: calls.append(args)) + jax_numpy = types.ModuleType("jax.numpy") + jax_numpy_linalg = types.ModuleType("jax.numpy.linalg") + jax_numpy_linalg.eig = object() + jax_scipy = types.ModuleType("jax.scipy") + jax_scipy_linalg = types.ModuleType("jax.scipy.linalg") + jax_scipy_linalg.eigh = object() + jax.numpy, jax_numpy.linalg = jax_numpy, jax_numpy_linalg + jax.scipy, jax_scipy.linalg = jax_scipy, jax_scipy_linalg + return { + "jax": jax, + "jax.numpy": jax_numpy, + "jax.numpy.linalg": jax_numpy_linalg, + "jax.scipy": jax_scipy, + "jax.scipy.linalg": jax_scipy_linalg, + } + + +def test_jax_array_library_import_uses_the_jax_config_attribute(monkeypatch): + calls = [] + modules = _fake_jax(calls) + try: + with monkeypatch.context() as m: + for name, module in modules.items(): + m.setitem(sys.modules, name, module) + m.setattr(graphiq, "DENSITY_MATRIX_ARRAY_LIBRARY", "jax") + importlib.reload(dm_package) + assert calls == [("jax_enable_x64", True)] + assert dm_package.numpy is modules["jax.numpy"] + assert dm_package.eig is modules["jax.numpy.linalg"].eig + assert dm_package.eigh is modules["jax.scipy.linalg"].eigh + finally: + # the context has restored the flag and sys.modules; a reload keeps + # old attributes, so drop the stand-in jax before rebinding numpy + dm_package.__dict__.pop("jax", None) + importlib.reload(dm_package) + assert dm_package.numpy is np + assert "jax" not in vars(dm_package) + + +def test_jax_array_library_imports_with_real_jax(monkeypatch): + jax = pytest.importorskip("jax") + try: + with monkeypatch.context() as m: + m.setattr(graphiq, "DENSITY_MATRIX_ARRAY_LIBRARY", "jax") + importlib.reload(dm_package) + assert dm_package.numpy is jax.numpy + assert jax.config.jax_enable_x64 + finally: + importlib.reload(dm_package) + assert dm_package.numpy is np diff --git a/tests/test_backends/test_measurement_cnot_and_reset_parity.py b/tests/test_backends/test_measurement_cnot_and_reset_parity.py new file mode 100644 index 00000000..0fb5732b --- /dev/null +++ b/tests/test_backends/test_measurement_cnot_and_reset_parity.py @@ -0,0 +1,239 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Parity between the density-matrix and stabilizer backends for +`MeasurementCNOTandReset`, and the related classically-controlled family +(`ClassicalCNOT`, `ClassicalCZ`) on `MixedStabilizer`. + +Every measurement-outcome assertion here pins `measurement_determinism = 1`. +At `determinism = 0` the emitter is projected to |0> anyway and the recorded +outcome is 0.0, so a missing reset or a missing register write would go +unseen. The default is "probabilistic", so a test that does not pin +determinism would catch either only about half the time. +""" + +import numpy as np +import pytest + +import graphiq.backends.density_matrix.functions as dmf +import graphiq.backends.state_rep_conversion as rc +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + +# n_photon=1, n_emitter=1: with ordering photons-then-emitters +# (CompilerBase.reg_to_index_func), the emitter sits at qubit index 1. +_EMITTER_INDEX = 1 + + +def _h_cnot_reset_circuit(n_classical=1): + """H(e0), CNOT(e0->p0), MeasurementCNOTandReset(e0->p0, c_register=0). + + The Hadamard leaves the control in an X eigenstate, so a Z-basis + measurement has p(1) != 0, and `measurement_determinism = 1`'s "default to + 1 unless p(1) is 0" rule forces the outcome to 1 -- the branch where a + missing reset is observable. + """ + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=n_classical) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + return circuit + + +def _record_classical_registers(compiler): + """ + Make ``compiler.classical_registers`` refer to the classical-register array + that ``compile`` allocates and passes to every ``compile_one_gate`` call. + """ + compile_one_gate = compiler.compile_one_gate + + def recording(state, op, n_quantum, q_index, classical_registers): + compiler.classical_registers = classical_registers + return compile_one_gate(state, op, n_quantum, q_index, classical_registers) + + compiler.compile_one_gate = recording + return compiler + + +def _compile_dm(circuit): + compiler = DensityMatrixCompiler() + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +def _compile_stabilizer(circuit): + compiler = StabilizerCompiler() + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +def _stabilizer_qubit_density(state, qubit, n_qubits): + """The reduced density matrix of one qubit of a (non-mixed) Stabilizer. + + Converts the whole tableau to a density matrix via + `state_rep_conversion.stabilizer_to_density` -- the same utility + `test_stabilizer_compiler.py` uses -- and traces the rest away with + `dmf.partial_trace`, so the index arithmetic is never hand-rolled. + """ + tableau = state.rep_data.tableau.to_stabilizer() + rho = rc.stabilizer_to_density(tableau) + return dmf.partial_trace(rho, keep=[qubit], dims=n_qubits * [2]) + + +# --------------------------------------------------------------------------- +# The emitter is |0> after MeasurementCNOTandReset, on both backends, pinned +# at determinism = 1. +# --------------------------------------------------------------------------- + + +def test_dm_emitter_is_reset_after_measurement_cnot_and_reset(): + circuit = _h_cnot_reset_circuit() + state, _ = _compile_dm(circuit) + reduced = dmf.partial_trace(state.rep_data.data, keep=[_EMITTER_INDEX], dims=[2, 2]) + assert np.allclose(reduced, [[1.0, 0.0], [0.0, 0.0]]) + + +def test_stabilizer_emitter_is_reset_after_measurement_cnot_and_reset(): + circuit = _h_cnot_reset_circuit() + state, _ = _compile_stabilizer(circuit) + reduced = _stabilizer_qubit_density(state, _EMITTER_INDEX, n_qubits=2) + assert np.allclose(reduced, [[1.0, 0.0], [0.0, 0.0]]) + + +# --------------------------------------------------------------------------- +# classical_registers[c_register] holds the outcome on both backends, pinned +# at determinism = 1, where the outcome is 1.0. +# --------------------------------------------------------------------------- + + +def test_dm_creg_records_the_outcome(): + circuit = _h_cnot_reset_circuit() + _, compiler = _compile_dm(circuit) + assert compiler.classical_registers[0] == 1.0 + + +def test_stabilizer_creg_records_the_outcome(): + circuit = _h_cnot_reset_circuit() + _, compiler = _compile_stabilizer(circuit) + assert compiler.classical_registers[0] == 1.0 + + +# --------------------------------------------------------------------------- +# A circuit that re-uses the emitter after the reset, as feed-forward work +# that reads the emitter after a measurement does. The tests above check the +# state right after the op; these check what a later op sees. +# +# A second op (MeasurementZ, not another MeasurementCNOTandReset) reads the +# emitter directly, so this depends only on the reset, not on +# MeasurementCNOTandReset's own register write. +# --------------------------------------------------------------------------- + + +def _reuse_circuit(): + circuit = _h_cnot_reset_circuit(n_classical=2) + circuit.add(ops.MeasurementZ(register=0, reg_type="e", c_register=1)) + return circuit + + +def test_dm_emitter_reuse_after_reset(): + circuit = _reuse_circuit() + _, compiler = _compile_dm(circuit) + # First outcome: the H'd control gives p(1) != 0, forced to 1. + assert compiler.classical_registers[0] == 1.0 + # Second outcome: if the control was actually reset to |0>, p(1) == 0, so + # determinism = 1's "default to 1 unless p(1) is 0" rule forces 0. A + # not-reset emitter (still |1> from the first measurement) would force 1. + assert compiler.classical_registers[1] == 0.0 + + +def test_stabilizer_emitter_reuse_after_reset(): + circuit = _reuse_circuit() + _, compiler = _compile_stabilizer(circuit) + assert compiler.classical_registers[0] == 1.0 + assert compiler.classical_registers[1] == 0.0 + + +# --------------------------------------------------------------------------- +# MixedStabilizer writes a scalar, not a list, for MeasurementCNOTandReset, +# ClassicalCNOT and ClassicalCZ. The outcome has one entry per mixture +# branch, and a classical_registers slot holds one value, so writing the +# whole list would raise ValueError. +# +# `noise_simulation = True` with every op carrying NoNoise still routes +# through compile_one_gate (CompilerBase.compile's no_noise branch), but now +# against a MixedStabilizer state -- the same construction +# test_noise_placement.py already relies on for this backend. +# --------------------------------------------------------------------------- + + +def _mixed_stabilizer_circuit(op_cls): + """SigmaX(e0) forces the control to |1> outcome deterministically, then + op_cls(e0->p0) under noise_simulation=True so the compiler builds a + MixedStabilizer. + """ + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + op_cls( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=nm.NoNoise(), + ) + ) + return circuit + + +def _compile_mixed_stabilizer(circuit): + compiler = StabilizerCompiler() + compiler.noise_simulation = True + compiler.measurement_determinism = 1 + state = _record_classical_registers(compiler).compile(circuit) + return state, compiler + + +@pytest.mark.parametrize( + "op_cls", [ops.MeasurementCNOTandReset, ops.ClassicalCNOT, ops.ClassicalCZ] +) +def test_mixed_stabilizer_writes_a_scalar_not_a_list(op_cls): + circuit = _mixed_stabilizer_circuit(op_cls) + _, compiler = _compile_mixed_stabilizer(circuit) + + value = compiler.classical_registers[0] + assert not isinstance(value, (list, np.ndarray)) + assert value == 1.0 + + +# --------------------------------------------------------------------------- +# No stray stdout from a MixedStabilizer ClassicalCNOT compile. +# --------------------------------------------------------------------------- + + +def test_mixed_stabilizer_classical_cnot_compiles_silently(capsys): + circuit = _mixed_stabilizer_circuit(ops.ClassicalCNOT) + _compile_mixed_stabilizer(circuit) + captured = capsys.readouterr() + assert captured.out == "" diff --git a/tests/test_benchmarking/test_benchmarking.py b/tests/test_benchmarking/test_benchmarking.py index a5db34a0..8ca1dca4 100644 --- a/tests/test_benchmarking/test_benchmarking.py +++ b/tests/test_benchmarking/test_benchmarking.py @@ -12,46 +12,75 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import functools +import json +import runpy + from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.benchmarks.benchmark_utils import benchmark_data from graphiq.io import IO from graphiq.metrics import Infidelity -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.benchmarks.circuits import ghz3_state_circuit -def test_one_benchmarking_run(): - # define a single run by creating an instance of each object - _, target = ghz3_state_circuit() +def _only_benchmark_folder(path): + (folder,) = path.joinpath("benchmarks").glob("*_solver-benchmarks_*") + return folder + + +def test_benchmark_data_saves_its_results(tmp_path, monkeypatch): + monkeypatch.setattr(IO, "default_path", tmp_path) + circuit, state = ghz3_state_circuit() + target = { + "name": "ghz3", + "dm": state, + "n_emitters": circuit.n_emitters, + "n_photons": circuit.n_photons, + } compiler = DensityMatrixCompiler() - metric = Infidelity(target=target) + compiler.measurement_determinism = 1 + setting = EvolutionarySolverSetting(n_stop=2, n_pop=4) - solver1 = EvolutionarySolver( - target=target, metric=metric, compiler=compiler, n_photon=3, n_emitter=1 + solver_data, _, hof_circuits = benchmark_data( + functools.partial(EvolutionarySolver, solver_setting=setting), + [(circuit, target)], + Infidelity, + compiler, + seed_offset=3, + save_directory="benchmarks", ) - solver1.n_stop = 10 - solver1.n_pop = 5 - - example_run1 = { - "solver": solver1, - "target": target, - "metric": metric, - "compiler": compiler, - } - # put all runs into a dict, with a descriptive name - runs = { - "example_run1": example_run1, + folder = _only_benchmark_folder(tmp_path) + with open(folder.joinpath("solver_info.json")) as file: + solver_info = json.load(file) + assert solver_info["solver setting"] == str(setting) + others = { + key: value for key, value in solver_data.items() if key != "solver setting" } + assert { + key: value for key, value in solver_info.items() if key != "solver setting" + } == json.loads(json.dumps(others)) - io = IO.new_directory( - folder="test_benchmarks", - include_date=False, - include_time=False, - include_id=False, - ) - # df = benchmark(runs=runs, io=io, remote=False) - # print(df) + with open(folder.joinpath("circuit_target0_retry0.json")) as file: + circuit_info = json.load(file) + assert set(circuit_info) == set(hof_circuits[0]) + assert circuit_info["name"] == "ghz3" + assert circuit_info["seed"] == 3 + assert circuit_info["Circuit description"] == hof_circuits[0]["Circuit description"] + + # the returned solver info still holds the setting object + assert solver_data["solver setting"] is setting + + +def test_the_benchmark_utils_script_runs(tmp_path, monkeypatch): + monkeypatch.setattr(IO, "default_path", tmp_path) + runpy.run_module("graphiq.benchmarks.benchmark_utils", run_name="__main__") -if __name__ == "__main__": - test_one_benchmarking_run() + folder = _only_benchmark_folder(tmp_path) + assert folder.joinpath("solver_info.json").is_file() + assert folder.joinpath("circuit_target0_retry0.json").is_file() diff --git a/tests/test_benchmarking/test_graph_states.py b/tests/test_benchmarking/test_graph_states.py new file mode 100644 index 00000000..2d3a1296 --- /dev/null +++ b/tests/test_benchmarking/test_graph_states.py @@ -0,0 +1,160 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +from graphiq.backends.graph.state import Graph +from graphiq.backends.lc_equivalence_check import is_lc_equivalent +from graphiq.benchmarks import graph_states as gs + + +def _build_reference_bi_repeater_graph(n): + """ + Hand-built reference for the biclique repeater graph state: a complete + bipartite graph (biclique) between n "left" inner qubits and n "right" + inner qubits, with exactly one pendant leaf qubit attached to each inner + qubit. Node labels are independent of the ones used by + gs.bi_repeater_graph_states; only the graph structure is compared. + """ + left = [f"left_{i}" for i in range(n)] + right = [f"right_{i}" for i in range(n)] + left_leaves = [f"left_leaf_{i}" for i in range(n)] + right_leaves = [f"right_leaf_{i}" for i in range(n)] + graph = nx.Graph() + graph.add_edges_from((left[i], left_leaves[i]) for i in range(n)) + graph.add_edges_from((right[i], right_leaves[i]) for i in range(n)) + graph.add_edges_from((left[i], right[j]) for i in range(n) for j in range(n)) + return graph + + +def test_bi_repeater_graph_states(): + n = 3 + graph = gs.bi_repeater_graph_states(n) + reference = _build_reference_bi_repeater_graph(n) + assert nx.is_connected(graph) + assert nx.is_isomorphic(graph, reference) + + +@pytest.mark.parametrize("n_list", [[2, 3], [2, 3, 2], [3, 3, 3], [1, 2, 1]]) +def test_crazy(n_list): + graph = gs.crazy(n_list) + assert graph.number_of_nodes() == sum(n_list) + + # consecutive layers are complete bipartite and there are no intra-layer edges + layers = [] + start = 0 + for n in n_list: + layers.append(list(range(start, start + n))) + start += n + for layer in layers: + for u in layer: + for v in layer: + if u != v: + assert not graph.has_edge(u, v) + for layer_a, layer_b in zip(layers, layers[1:]): + for u in layer_a: + for v in layer_b: + assert graph.has_edge(u, v) + + alternate_graph = gs.crazy(n_list, alternate_order=True) + assert nx.is_isomorphic(graph, alternate_graph) + + +@pytest.mark.parametrize("n_list", [[1, 2], [2, 3], [2, 3, 1], [3, 1, 2, 2], [3, 3, 3]]) +def test_crazy_alternate_order_positions_put_each_edge_between_adjacent_columns( + n_list, +): + graph, pos = gs.crazy(n_list, pos=True, alternate_order=True) + assert sorted(pos) == list(range(sum(n_list))) + # no two nodes are drawn at the same place + assert len({tuple(xy) for xy in pos.values()}) == len(pos) + for x, n in enumerate(n_list): + assert sum(1 for xy in pos.values() if xy[0] == x) == n + # a crazy graph only has edges between consecutive columns + for u, v in graph.edges(): + assert abs(pos[u][0] - pos[v][0]) == 1 + + +def test_crazy_alternate_order_keeps_its_graph_labelling(): + # pins the existing labelling of the alternate-order graph, which positions + # must follow, not an intended emission order + def edges(graph): + return sorted(tuple(sorted(e)) for e in graph.edges()) + + graph, _ = gs.crazy([2, 3], pos=True, alternate_order=True) + assert edges(graph) == [(0, 2), (0, 3), (1, 2), (1, 3), (2, 4), (3, 4)] + graph, _ = gs.crazy([1, 2], pos=True, alternate_order=True) + assert edges(graph) == [(0, 1), (1, 2)] + + +def test_crazy_alternate_order_positions_for_equal_column_pairs_are_unchanged(): + _, pos = gs.crazy([2, 2], pos=True, alternate_order=True) + assert {k: tuple(v) for k, v in pos.items()} == { + 0: (1, 0), + 1: (1, -1), + 2: (0, 0), + 3: (0, -1), + } + + +@pytest.mark.parametrize("row_col", [(2, 2), (2, 3), (3, 3), (3, 4)]) +def test_two_d_cluster(row_col): + graph = gs.two_d_cluster(row_col) + reference = nx.grid_2d_graph(*row_col) + assert nx.is_isomorphic(graph, reference) + + +@pytest.mark.parametrize("row_col_depth", [(2, 2, 2), (2, 3, 2), (3, 3, 3), (2, 3, 4)]) +def test_three_d_cluster(row_col_depth): + graph = gs.three_d_cluster(row_col_depth) + row, col, depth = row_col_depth + reference = nx.grid_graph([depth, col, row]) + assert nx.is_isomorphic(graph, reference) + + +@pytest.mark.parametrize("b_vector", [[2], [2, 3], [3, 2, 1]]) +def test_branching_tree(b_vector): + graph = gs.branching_tree(b_vector) + expected_nodes = 1 + running_product = 1 + for b in b_vector: + running_product *= b + expected_nodes += running_product + assert graph.number_of_nodes() == expected_nodes + assert nx.is_tree(graph) + + +def test_random_graph_state(): + state = gs.random_graph_state(6, 0.5, np_rng=np.random.default_rng(seed=42)) + assert isinstance(state, Graph) + graph = state.data + assert graph.number_of_nodes() == 6 + assert nx.is_connected(graph) + + state_again = gs.random_graph_state(6, 0.5, np_rng=np.random.default_rng(seed=42)) + assert nx.utils.graphs_equal(graph, state_again.data) + + +def test_random_lc_equivalent(): + start_graph = Graph(nx.path_graph(5)) + new_graphs, _ = gs.random_lc_equivalent( + start_graph, 4, 3, np_rng=np.random.default_rng(seed=7) + ) + start_adj = nx.to_numpy_array(start_graph.data).astype(int) + for new_graph in new_graphs: + new_adj = nx.to_numpy_array(new_graph.data).astype(int) + equivalent, _ = is_lc_equivalent(start_adj, new_adj, mode="deterministic") + assert equivalent diff --git a/tests/test_benchmarking/test_lc_equivalence_demos.py b/tests/test_benchmarking/test_lc_equivalence_demos.py new file mode 100644 index 00000000..9b5a995c --- /dev/null +++ b/tests/test_benchmarking/test_lc_equivalence_demos.py @@ -0,0 +1,137 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Regression tests for the graph-picking behaviour of the three heuristic demos in +graphiq.benchmarks.lc_equivalence. Each demo is supposed to draw and search a graph, then +draw and search a second graph reached from it by local complementation; this module guards +that the second graph is actually distinct from the first, rather than the input repeated. +""" + +import networkx as nx +import pytest + +from graphiq.benchmarks import lc_equivalence +from graphiq.utils.preprocessing import get_lc_graph_by_max_edge, graph_metric_lists + + +def _spy_on_search_and_draw(monkeypatch): + calls = [] + + def fake_exemplary_run(graph, *args, **kwargs): + calls.append(graph) + return None + + monkeypatch.setattr(lc_equivalence, "exemplary_run", fake_exemplary_run) + monkeypatch.setattr( + lc_equivalence, "report_alternate_circuits", lambda result: None + ) + monkeypatch.setattr(nx, "draw", lambda *args, **kwargs: None) + return calls + + +def test_heuristic_example1_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example1() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + + +def test_heuristic_example2_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example2() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + # Edge count alone pins the class of the second searched graph, not just its distinctness + # from the first: the unfixed heuristic pick (index 0, i.e. K(3, 3) itself) complemented at + # node 3 has 12 edges; the fixed pick complemented at node 0 undoes the heuristic's + # complementation and returns K(3, 3) itself, 9 edges; only the fixed pick complemented at + # node 3 gives this 8-edge graph. + assert calls[1].number_of_edges() == 8 + + +def test_heuristic_example3_searches_a_distinct_second_graph(monkeypatch): + calls = _spy_on_search_and_draw(monkeypatch) + + lc_equivalence.heuristic_example3() + + assert len(calls) == 2 + first_edges = set(map(frozenset, calls[0].edges())) + second_edges = set(map(frozenset, calls[1].edges())) + assert first_edges != second_edges + + +def test_first_distinct_graph_raises_on_k1(): + graph = nx.complete_graph(1) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + with pytest.raises(ValueError): + lc_equivalence._first_distinct_graph(candidates, graph) + + +def test_first_distinct_graph_raises_on_k2(): + graph = nx.complete_graph(2) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + with pytest.raises(ValueError): + lc_equivalence._first_distinct_graph(candidates, graph) + + +def test_first_distinct_graph_does_not_raise_on_k3(): + graph = nx.complete_graph(3) + candidates = get_lc_graph_by_max_edge(graph, 3, graph_metric_lists[0]) + + result = lc_equivalence._first_distinct_graph(candidates, graph) + + assert isinstance(result, nx.Graph) + reference_edges = set(map(frozenset, graph.edges())) + result_edges = set(map(frozenset, result.edges())) + assert result_edges != reference_edges + + +@pytest.mark.parametrize( + ("demo", "args", "expected"), + [ + (lc_equivalence.linear_4_example, (), 3), + (lc_equivalence.square_4_example, (), 4), + (lc_equivalence.repeater_graph_example, (4,), 2), + (lc_equivalence.star_graph_example, (7,), 2), + (lc_equivalence.biclique_example, (3,), 3), + ], + ids=[ + "linear_4_example", + "square_4_example", + "repeater_graph_example", + "star_graph_example", + "biclique_example", + ], +) +def test_run_multiple_graphs_demos_search_distinct_graphs( + demo, args, expected, monkeypatch +): + calls = _spy_on_search_and_draw(monkeypatch) + + demo(*args) + + assert len(calls) == expected + edge_sets = {frozenset(map(frozenset, g.edges())) for g in calls} + assert len(edge_sets) == len(calls) diff --git a/tests/test_benchmarking/test_pipeline.py b/tests/test_benchmarking/test_pipeline.py index 12df04a5..fe48e241 100644 --- a/tests/test_benchmarking/test_pipeline.py +++ b/tests/test_benchmarking/test_pipeline.py @@ -12,7 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import json + import graphiq.noise.monte_carlo_noise as mcn +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, @@ -23,12 +26,17 @@ AlternateTargetSolverSetting, AlternateTargetSolver, ) +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.state import QuantumState +from graphiq.benchmarks.circuits import linear_cluster_3qubit_circuit from graphiq.benchmarks.graph_states import linear_cluster_state -from graphiq.benchmarks.pipeline import benchmark_run_graph_search_solver +from graphiq.benchmarks.pipeline import benchmark_run, benchmark_run_graph_search_solver -def test_benchmark_run_graph_search_solver(): +def test_benchmark_run_graph_search_solver(tmp_path): target_graph = linear_cluster_state(4) target_graph = target_graph.data target_tableau = get_clifford_tableau_from_graph(target_graph) @@ -74,8 +82,73 @@ def test_benchmark_run_graph_search_solver(): "compiler": compiler, } io = IO.new_directory( - folder="benchmarks", include_date=True, include_time=True, include_id=False + path=tmp_path, + folder="benchmarks", + include_date=True, + include_time=True, + include_id=False, ) data = benchmark_run_graph_search_solver(example_run, "test_pipeline", io) assert type(data) == dict + # the run saves its results under tmp_path, not the default data/ folder + assert (tmp_path / io.path.name / "solver_result.csv").is_file() + + +def test_benchmark_run_records_metric_name(monkeypatch, tmp_path): + import graphiq.benchmarks.pipeline as pipeline + + class _Circuit: + depth = 1 + + class _Solver: + logs = {} + hof = [(0.0, _Circuit())] + + def solve(self): + pass + + class _IO: + path = tmp_path + + def save_figure(self, fig, filename): + pass + + monkeypatch.setattr(pipeline, "plot_solver_logs", lambda logs: (None, None)) + run = dict( + solver=_Solver(), + compiler=StabilizerCompiler(), + metric=Infidelity(target=QuantumState(1, rep_type="s")), + ) + + d = pipeline.benchmark_run(run, "run0", _IO()) + + assert d["compiler"] == "StabilizerCompiler" + assert d["metric"] == "Infidelity" + + +def test_benchmark_run_saves_the_population_and_hof_as_openqasm(tmp_path): + circuit, target = linear_cluster_3qubit_circuit() + compiler = DensityMatrixCompiler() + metric = Infidelity(target) + # n_pop >= n_hof, so every hall-of-fame slot is filled from the first iteration + setting = EvolutionarySolverSetting(n_stop=2, n_pop=3, n_hof=2) + solver = EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=circuit.n_emitters, + n_photon=circuit.n_photons, + solver_setting=setting, + ) + solver.seed(0) + run = dict(solver=solver, target=target, compiler=compiler, metric=metric) + + benchmark_run(run, "run0", IO(path=tmp_path, verbose=False)) + + saved = {p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*.txt")} + assert saved == { + f"pop/iteration{i}_pop{j}.txt" for i in range(2) for j in range(3) + } | {f"hof/iteration{i}_hof{j}.txt" for i in range(2) for j in range(2)} + best = json.loads((tmp_path / "hof" / "iteration1_hof0.txt").read_text()) + assert best == solver.hof[0][1].to_openqasm() diff --git a/tests/test_circuits/test_add_register_return_value.py b/tests/test_circuits/test_add_register_return_value.py new file mode 100644 index 00000000..3319dd82 --- /dev/null +++ b/tests/test_circuits/test_add_register_return_value.py @@ -0,0 +1,41 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphiq.circuit.circuit_dag import CircuitDAG + + +def test_add_emitter_register_returns_index(): + dag = CircuitDAG(n_emitter=0, n_photon=0, n_classical=0) + + assert dag.add_emitter_register() == 0 + assert dag.add_emitter_register() == 1 + assert dag.add_emitter_register() == 2 + + +def test_add_photonic_and_classical_registers_number_independently(): + dag = CircuitDAG(n_emitter=0, n_photon=0, n_classical=0) + + assert dag.add_emitter_register() == 0 + assert dag.add_emitter_register() == 1 + + assert dag.add_photonic_register() == 0 + assert dag.add_photonic_register() == 1 + assert dag.add_photonic_register() == 2 + + assert dag.add_classical_register() == 0 + assert dag.add_classical_register() == 1 + + # Adding another emitter register continues the emitter numbering, + # unaffected by the photonic/classical registers added above. + assert dag.add_emitter_register() == 2 diff --git a/tests/test_circuits/test_assign_noise.py b/tests/test_circuits/test_assign_noise.py new file mode 100644 index 00000000..28f8cf2c --- /dev/null +++ b/tests/test_circuits/test_assign_noise.py @@ -0,0 +1,395 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import numpy as np +import pytest + +import graphiq.benchmarks.circuits as circ +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.benchmarks.alternate_circuits import ( + noise_model_loss_and_depolarizing, + noise_model_pure_loss, +) +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise +from graphiq.solvers.solver_base import SolverBase + + +def _assert_every_leg_is_no_noise(op): + if isinstance(op.noise, list): + assert all(isinstance(leg, nm.NoNoise) for leg in op.noise) + else: + assert isinstance(op.noise, nm.NoNoise) + + +def test_assign_noise_leaves_the_source_circuit_alone(): + hadamard_noise = nm.DepolarizingNoise(0.1) + wrapper_noise = nm.DepolarizingNoise(0.2) + pair_noise = nm.DepolarizingNoise(0.3) + + circuit = CircuitDAG(n_emitter=1, n_photon=2, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.OneQubitGateWrapper([ops.Phase, ops.Identity], register=0, reg_type="e") + ) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + + mapping = { + "e": { + "Hadamard": hadamard_noise, + "Phase": wrapper_noise, + "Identity": wrapper_noise, + }, + "p": {}, + "ee": {}, + "ep": {"CNOT": pair_noise, "MeasurementCNOTandReset": pair_noise}, + } + + circuit.assign_noise(mapping) + + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + _assert_every_leg_is_no_noise(op) + + +def test_assign_noise_shares_no_op_with_the_source(): + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + + mapping = { + "e": {"Hadamard": nm.DepolarizingNoise(0.1)}, + "p": {}, + "ee": {}, + "ep": {"CNOT": nm.DepolarizingNoise(0.2)}, + } + + noisy = circuit.assign_noise(mapping) + + source_ids = { + id(op) + for op in circuit.sequence() + if not isinstance(op, (ops.Input, ops.Output)) + } + result_ids = { + id(op) for op in noisy.sequence() if not isinstance(op, (ops.Input, ops.Output)) + } + assert source_ids.isdisjoint(result_ids) + + +def test_assign_noise_twice_with_different_mappings_is_independent(): + noise_a = nm.DepolarizingNoise(0.1) + noise_b = nm.DepolarizingNoise(0.9) + + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + + mapping_a = {"e": {"Hadamard": noise_a}, "p": {}, "ee": {}, "ep": {}} + mapping_b = {"e": {"Hadamard": noise_b}, "p": {}, "ee": {}, "ep": {}} + + result_a = circuit.assign_noise(mapping_a) + circuit.assign_noise(mapping_b) + + hadamards_a = [op for op in result_a.sequence() if isinstance(op, ops.Hadamard)] + assert len(hadamards_a) == 1 + assert hadamards_a[0].noise is noise_a + + +def test_monte_carlo_trials_leave_the_source_circuit_alone(): + circuit, _ = circ.ghz4_state_circuit() + + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("X"), 1.0)]) + + mc = MonteCarloNoise(circuit, mc_noise_model=mc_noise_model, seed=7) + mc.one_run() + mc.one_run() + + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + if isinstance(op.noise, list): + assert all(isinstance(leg, nm.NoNoise) for leg in op.noise) + else: + assert isinstance(op.noise, nm.NoNoise) + + +def _one_emitter_wrapper_circuit(operations, noise=nm.NoNoise()): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper(operations, register=0, reg_type="e", noise=noise) + ) + return circuit + + +def _emitter_only_mapping(emitter_section): + return {"e": emitter_section, "p": {}, "ee": {}, "ep": {}} + + +def _the_wrapper(circuit): + wrappers = [ + op for op in circuit.sequence() if isinstance(op, ops.OneQubitGateWrapper) + ] + assert len(wrappers) == 1 + return wrappers[0] + + +def _noisy_state(circuit): + compiler = DensityMatrixCompiler() + compiler.noise_simulation = True + return compiler.compile(circuit=circuit).rep_data.data + + +def test_assign_noise_pairs_wrapper_noise_with_its_sub_gate(): + # a wrapper pairs noise[i] with operations[i], so the model mapped to Hadamard must end up + # on the Hadamard sub-gate, not on the sub-gate at the mirrored position in unwrap() order + hadamard_noise = nm.DepolarizingNoise(0.1) + circuit = _one_emitter_wrapper_circuit([ops.Hadamard, ops.Phase]) + + noisy = circuit.assign_noise(_emitter_only_mapping({"Hadamard": hadamard_noise})) + + noise_by_gate = { + type(gate).__name__: gate.noise for gate in _the_wrapper(noisy).unwrap() + } + assert noise_by_gate["Hadamard"] is hadamard_noise + assert isinstance(noise_by_gate["Phase"], nm.NoNoise) + + +def test_assign_noise_wrapper_noise_reaches_the_right_sub_gate_in_the_compiled_state(): + # [Hadamard, SigmaX] applies SigmaX first, then Hadamard; a Z error on the Hadamard gives a + # different state from a Z error on the SigmaX, so the compiled state shows which gate got it + circuit = _one_emitter_wrapper_circuit([ops.Hadamard, ops.SigmaX]) + noisy = circuit.assign_noise( + _emitter_only_mapping({"Hadamard": nm.PauliError("Z")}) + ) + + reference = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + reference.add(ops.SigmaX(register=0, reg_type="e")) + reference.add(ops.Hadamard(register=0, reg_type="e", noise=nm.PauliError("Z"))) + control = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + control.add(ops.SigmaX(register=0, reg_type="e", noise=nm.PauliError("Z"))) + control.add(ops.Hadamard(register=0, reg_type="e")) + + state = _noisy_state(noisy) + reference_state = _noisy_state(reference) + control_state = _noisy_state(control) + + assert not np.allclose(reference_state, control_state) + assert np.allclose(state, reference_state) + assert not np.allclose(state, control_state) + + +def test_assign_noise_leaves_a_whole_wrapper_model_in_place(): + # a wrapper carrying one model for the whole wrapper keeps it; the per-gate entries of the + # mapping are not applied to it + wrapper_noise = nm.PauliError("X") + circuit = _one_emitter_wrapper_circuit( + [ops.Hadamard, ops.SigmaX], noise=wrapper_noise + ) + + noisy = circuit.assign_noise( + _emitter_only_mapping({"Hadamard": nm.DepolarizingNoise(0.1)}) + ) + + wrapper = _the_wrapper(noisy) + assert isinstance(wrapper.noise, nm.PauliError) + assert wrapper.noise.noise_parameters == wrapper_noise.noise_parameters + gates = wrapper.unwrap() + assert len(gates) == 3 + assert {type(gate).__name__ for gate in gates} == { + "Hadamard", + "SigmaX", + "Identity", + } + + +class _MinimalSolver(SolverBase): + """A concrete SolverBase subclass with no search behaviour, so that ``_identify_noise`` can be called + without constructing a full solver.""" + + def solve(self, *args): + raise NotImplementedError + + +def test_assign_noise_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + + mapping = { + "e": {}, + "p": {}, + "ee": {"CNOT_control": control_noise}, + "ep": {}, + } + noisy = circuit.assign_noise(mapping) + + cnots = [op for op in noisy.sequence() if isinstance(op, ops.CNOT)] + assert len(cnots) == 1 + assert cnots[0].noise[0] is control_noise + assert isinstance(cnots[0].noise[1], nm.NoNoise) + + +def test_assign_noise_and_identify_noise_agree(): + ee_control_noise = nm.DepolarizingNoise(0.1) + ee_target_noise = nm.DepolarizingNoise(0.2) + ep_noise = nm.DepolarizingNoise(0.3) + hadamard_noise = nm.DepolarizingNoise(0.4) + + mapping = { + "e": {"Hadamard": hadamard_noise}, + "p": {}, + "ee": { + "CNOT_control": ee_control_noise, + "CNOT_target": ee_target_noise, + }, + "ep": {"MeasurementCNOTandReset": ep_noise}, + } + + circuit = CircuitDAG(n_emitter=2, n_photon=1, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + + noisy = circuit.assign_noise(mapping) + solver = _MinimalSolver(target=None, metric=None, compiler=None) + # each op's section, picked by hand + sections = {ops.Hadamard: "e", ops.CNOT: "ee", ops.MeasurementCNOTandReset: "ep"} + + checked = 0 + for op in noisy.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + expected = solver._identify_noise(type(op), mapping[sections[type(op)]]) + if isinstance(expected, list): + assert len(op.noise) == len(expected) + for actual_leg, expected_leg in zip(op.noise, expected): + assert actual_leg is expected_leg + else: + assert op.noise is expected + checked += 1 + assert checked == 3 + + +def test_assign_noise_with_the_loss_and_depolarizing_benchmark_mapping_reaches_the_cnot_legs(): + circuit = CircuitDAG(n_emitter=2, n_photon=1, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + + noisy = circuit.assign_noise(noise_model_loss_and_depolarizing(0.1, 0.05)) + + ee_cnot, ep_cnot = [op for op in noisy.sequence() if isinstance(op, ops.CNOT)] + assert ee_cnot.target_type == "e" and ep_cnot.target_type == "p" + assert isinstance(ee_cnot.noise[0], nm.DepolarizingNoise) + assert isinstance(ee_cnot.noise[1], nm.DepolarizingNoise) + assert isinstance(ep_cnot.noise[0], nm.DepolarizingNoise) + assert isinstance(ep_cnot.noise[1], nm.NoNoise) + + +# the five-gate photon wrapper graph_to_circ emits +_FIVE_GATE_WRAPPER = [ops.Hadamard, ops.Phase, ops.Hadamard, ops.Phase, ops.SigmaX] + + +def _one_photon_wrapper_circuit(operations): + circuit = CircuitDAG(n_emitter=0, n_photon=1, n_classical=0) + circuit.add(ops.OneQubitGateWrapper(operations, register=0, reg_type="p")) + return circuit + + +def _photon_only_mapping(photon_section): + return {"e": {}, "p": photon_section, "ee": {}, "ep": {}} + + +def test_assign_noise_gives_a_wrapper_the_section_wrapper_key_model(): + gate_noise = nm.DepolarizingNoise(0.1) + wrapper_noise = nm.PhotonLoss(0.1) + section = { + "Hadamard": gate_noise, + "Phase": gate_noise, + "SigmaX": gate_noise, + "OneQubitGateWrapper": wrapper_noise, + } + circuit = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER) + + noisy = circuit.assign_noise(_photon_only_mapping(section)) + + assert _the_wrapper(noisy).noise is wrapper_noise + + +@pytest.mark.parametrize( + "wrapper_noise", + [nm.PhotonLoss(0.1), [nm.PhotonLoss(0.1)] + 4 * [nm.NoNoise()]], + ids=["scalar", "list"], +) +def test_assign_noise_wrapper_key_agrees_with_the_solvers(wrapper_noise): + section = { + "Hadamard": nm.DepolarizingNoise(0.1), + "OneQubitGateWrapper": wrapper_noise, + } + solver = _MinimalSolver(target=None, metric=None, compiler=None) + solver_built = ops.OneQubitGateWrapper( + _FIVE_GATE_WRAPPER, + register=0, + reg_type="p", + noise=solver._wrap_noise(_FIVE_GATE_WRAPPER, section), + ) + + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + _photon_only_mapping(section) + ) + + assert _the_wrapper(noisy).noise is solver_built.noise + assert solver_built.noise is wrapper_noise + + +def test_assign_noise_no_noise_wrapper_key_gives_every_wrapped_gate_no_noise(): + section = { + "Hadamard": nm.DepolarizingNoise(0.1), + "OneQubitGateWrapper": nm.NoNoise(), + } + + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + _photon_only_mapping(section) + ) + + noise = _the_wrapper(noisy).noise + assert isinstance(noise, list) + assert len(noise) == len(_FIVE_GATE_WRAPPER) + assert all(isinstance(model, nm.NoNoise) for model in noise) + + +def test_assign_noise_wrapper_key_loss_is_applied_once_per_wrapper(): + noisy = _one_photon_wrapper_circuit(_FIVE_GATE_WRAPPER).assign_noise( + noise_model_pure_loss(0.1) + ) + + compiler = StabilizerCompiler() + compiler.noise_simulation = True + state = compiler.compile(circuit=noisy) + + # one loss for the wrapper, not one per lossy gate (0.9 ** 5) + assert np.isclose(state.rep_data.probability, 0.9) diff --git a/tests/test_circuits/test_circuit_json.py b/tests/test_circuits/test_circuit_json.py new file mode 100644 index 00000000..52b3524c --- /dev/null +++ b/tests/test_circuits/test_circuit_json.py @@ -0,0 +1,83 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Round-trip coverage for CircuitDAG.to_json / from_json: the generic gate arms, and the +OneQubitGateWrapper arms that are only reached when a circuit actually carries a wrapped gate. +""" + +import json + +import pytest + +from graphiq.benchmarks.circuits import ( + ghz3_state_circuit, + linear_cluster_3qubit_circuit, +) +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.circuit.ops import Hadamard, OneQubitGateWrapper, SigmaX +from graphiq.utils.circuit_comparison import compare_circuits + + +@pytest.mark.parametrize( + "make_circuit", + [ghz3_state_circuit, linear_cluster_3qubit_circuit], + ids=["ghz3", "linear_cluster_3qubit"], +) +def test_to_json_from_json_round_trips_a_measurement_circuit(make_circuit): + # Both fixtures carry a MeasurementCNOTandReset, exercising the generic gate arms on + # both sides of the round trip. + circuit, _ = make_circuit() + + assert compare_circuits(circuit, CircuitDAG.from_json(circuit.to_json())) + + +@pytest.mark.parametrize( + "make_circuit", + [ghz3_state_circuit, linear_cluster_3qubit_circuit], + ids=["ghz3", "linear_cluster_3qubit"], +) +def test_to_json_from_json_round_trips_through_a_real_json_dump(make_circuit): + # to_json returns a dict, so the in-memory round trip above never serializes it. Pushing the + # dict through json.dumps/json.loads turns every tuple (q_registers, q_registers_type, + # c_registers) into a list, which must still come back as a tuple on the way in. + circuit, _ = make_circuit() + + serialized = json.loads(json.dumps(circuit.to_json())) + + assert compare_circuits(circuit, CircuitDAG.from_json(serialized)) + + +def test_from_json_gives_tuple_typed_registers_after_a_real_json_dump(): + # Confirms the register fields are coerced back to tuples, not merely that comparison happens + # to still succeed on lists. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(Hadamard(register=0, reg_type="e")) + + serialized = json.loads(json.dumps(circuit.to_json())) + rebuilt = CircuitDAG.from_json(serialized) + + gate = next(op for op in rebuilt.sequence() if type(op).__name__ == "Hadamard") + assert isinstance(gate.q_registers, tuple) + assert isinstance(gate.q_registers_type, tuple) + assert isinstance(gate.c_registers, tuple) + + +def test_to_json_from_json_round_trips_a_one_qubit_gate_wrapper(): + # OneQubitGateWrapper has its own branch in to_json/from_json; + # a plain gate circuit never reaches it. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(OneQubitGateWrapper([Hadamard, SigmaX], register=0, reg_type="e")) + + assert compare_circuits(circuit, CircuitDAG.from_json(circuit.to_json())) diff --git a/tests/test_circuits/test_dag_circuit.py b/tests/test_circuits/test_dag_circuit.py index 0069f6eb..27c231c8 100644 --- a/tests/test_circuits/test_dag_circuit.py +++ b/tests/test_circuits/test_dag_circuit.py @@ -14,6 +14,8 @@ # limitations under the License. import random +import matplotlib.pyplot as plt +import numpy as np import pytest import graphiq.circuit.ops as ops @@ -652,3 +654,250 @@ def test_circuit_comparison_8(): match = circuit1.compare(circuit2) assert ged assert match + + +def test_initialize_parameters_same_seed_gives_identical_parameter_values(): + # Keys are id(op)-derived, so a fresh circuit's map compares unequal to another fresh + # circuit's map even with the same seed; comparing the parameter *values* is what actually + # checks reproducibility. + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit_a, _ = variational_entangling_layer_2qubit() + circuit_b, _ = variational_entangling_layer_2qubit() + + parameters_a = circuit_a.initialize_parameters(seed=42) + parameters_b = circuit_b.initialize_parameters(seed=42) + + assert list(parameters_a.values()) == list(parameters_b.values()) + + +def test_initialize_parameters_different_seed_gives_different_parameter_values(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit_a, _ = variational_entangling_layer_2qubit() + circuit_b, _ = variational_entangling_layer_2qubit() + + parameters_a = circuit_a.initialize_parameters(seed=42) + parameters_b = circuit_b.initialize_parameters(seed=43) + + assert list(parameters_a.values()) != list(parameters_b.values()) + + +@pytest.mark.parametrize("gate", [ops.RX, ops.RY, ops.RZ]) +def test_initialize_parameters_samples_default_rotation_gate_bounds(gate): + circuit = CircuitDAG(n_emitter=1) + op = gate(register=0, reg_type="e") + circuit.add(op) + + parameters = circuit.initialize_parameters(seed=0) + + random.seed(0) + expected = [random.uniform(-np.pi, np.pi)] + assert list(parameters.values()) == [expected] + assert list(op.params) == expected + + +@pytest.mark.parametrize("gate", [ops.RX, ops.RY, ops.RZ]) +def test_default_rotation_gate_param_info_is_accepted_back(gate): + assert ops.OperationBase._validate_param_info(gate().param_info, 1) + gate(param_info=gate().param_info) + + +def test_initialize_parameters_draws_in_sequence_order_across_gate_kinds(): + circuit = CircuitDAG(n_emitter=1) + gates = [ + ops.ParameterizedOneQubitRotation(register=0, reg_type="e"), + ops.RX(register=0, reg_type="e"), + ops.ParameterizedOneQubitRotation(register=0, reg_type="e"), + ] + for op in gates: + circuit.add(op) + + circuit.initialize_parameters(seed=7) + + random.seed(7) + draws = [random.uniform(-np.pi, np.pi) for _ in range(7)] + assert [list(op.params) for op in gates] == [draws[0:3], draws[3:4], draws[4:7]] + + +def test_initialize_parameters_of_the_variational_layer_is_unchanged(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit, _ = variational_entangling_layer_2qubit() + + parameters = circuit.initialize_parameters(seed=42) + + assert list(parameters.values()) == [ + [0.8760444114976647, -2.984445443853267, -1.4135324813693264], + [-1.7391182232484002, 1.48579245840624, 1.1102356231618007], + ] + + +def _rotation(register=0): + return ops.ParameterizedOneQubitRotation( + register=register, reg_type="e", params=(0.0, 1.0, 1.0) + ) + + +def _shared_weight_fmap(circuit): + return lambda: { + id(op): op.__class__.__name__ for op in circuit.sequence(unwrapped=True) + } + + +def test_parameters_setter_accepts_ops_added_after_construction(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation = _rotation() + circuit.add(rotation) + + circuit.parameters = {id(rotation): [0.1, 0.2, 0.3]} + + assert rotation.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_accepts_ops_added_after_initialize_parameters(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation1 = _rotation() + circuit.add(rotation1) + circuit.initialize_parameters(seed=0) + rotation2 = _rotation() + circuit.add(rotation2) + + circuit.parameters = { + id(rotation1): [0.1, 0.2, 0.3], + id(rotation2): [0.4, 0.5, 0.6], + } + + assert rotation1.params == [0.1, 0.2, 0.3] + assert rotation2.params == [0.4, 0.5, 0.6] + + +def test_parameters_setter_with_a_one_qubit_gate_wrapper(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + rotation = _rotation() + circuit.add(rotation) + circuit.add( + ops.OneQubitGateWrapper([ops.Hadamard, ops.Phase], register=0, reg_type="e") + ) + circuit.initialize_parameters(seed=0) + + circuit.parameters = {id(rotation): [0.1, 0.2, 0.3]} + + assert rotation.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_with_a_shared_weight_fmap(): + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + rotation0 = _rotation(0) + rotation1 = _rotation(1) + circuit.add(rotation0) + circuit.add(rotation1) + circuit.fmap = _shared_weight_fmap(circuit) + + circuit.parameters = {"ParameterizedOneQubitRotation": [0.1, 0.2, 0.3]} + + assert rotation0.params == [0.1, 0.2, 0.3] + assert rotation1.params == [0.1, 0.2, 0.3] + + +def test_parameters_setter_keeps_the_map_built_by_initialize_parameters(): + # when every op is already in the map, the setter uses it as it is, even + # if fmap was changed after the parameters were initialised + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + rotation0 = _rotation(0) + rotation1 = _rotation(1) + circuit.add(rotation0) + circuit.add(rotation1) + circuit.initialize_parameters(seed=0) + circuit.fmap = _shared_weight_fmap(circuit) + + circuit.parameters = { + id(rotation0): [0.1, 0.2, 0.3], + id(rotation1): [0.4, 0.5, 0.6], + } + + assert rotation0.params == [0.1, 0.2, 0.3] + assert rotation1.params == [0.4, 0.5, 0.6] + + +def test_parameters_setter_after_initialize_parameters_assigns_given_values(): + from graphiq.benchmarks.circuits import variational_entangling_layer_2qubit + + circuit, _ = variational_entangling_layer_2qubit() + initial = circuit.initialize_parameters(seed=42) + shifted = {key: [value + 1 for value in values] for key, values in initial.items()} + + circuit.parameters = shifted + + parameterized = [op for op in circuit.sequence(unwrapped=True) if op.params] + assert parameterized + for op in parameterized: + assert list(op.params) == shifted[circuit.map[id(op)]] + + +def _one_hadamard_dag(): + dag = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + dag.add(ops.Hadamard(register=0, reg_type="e")) + return dag + + +def test_draw_dag_draws_on_the_ax_it_is_given_without_a_fig(): + dag = _one_hadamard_dag() + fig0, ax0 = plt.subplots() + try: + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, ax=ax0) + assert ax is ax0 + assert fig is fig0 + assert len(ax0.collections) > 0 + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") + + +def test_draw_dag_returns_the_root_figure_for_an_ax_in_a_subfigure(): + dag = _one_hadamard_dag() + root = plt.figure() + try: + axs = root.subfigures(1, 2)[0].subplots() + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, ax=axs) + assert ax is axs + assert fig is root + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") + + +def test_draw_dag_draws_on_the_fig_it_is_given_without_an_ax(): + dag = _one_hadamard_dag() + fig0 = plt.figure() + other = plt.figure() # the pyplot-current figure, which draw_dag should leave alone + try: + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, fig=fig0) + assert fig is fig0 + assert fig0.axes == [ax] + assert len(ax.collections) > 0 + assert other.axes == [] + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") + + +def test_draw_dag_draws_on_the_current_axes_of_a_fig_given_without_an_ax(): + dag = _one_hadamard_dag() + fig0, (ax_left, ax_middle, ax_right) = plt.subplots(1, 3) + try: + fig0.sca(ax_middle) + n_figures = len(plt.get_fignums()) + fig, ax = dag.draw_dag(show=False, fig=fig0) + assert fig is fig0 + assert ax is ax_middle + assert fig0.axes == [ax_left, ax_middle, ax_right] + assert len(ax_middle.collections) > 0 + assert len(ax_left.collections) == 0 + assert len(ax_right.collections) == 0 + assert len(plt.get_fignums()) == n_figures + finally: + plt.close("all") diff --git a/tests/test_circuits/test_openqasm.py b/tests/test_circuits/test_openqasm.py index 3f092614..4d2c4e1f 100644 --- a/tests/test_circuits/test_openqasm.py +++ b/tests/test_circuits/test_openqasm.py @@ -16,6 +16,7 @@ import numpy as np import pytest from qiskit import QuantumCircuit +from qiskit.quantum_info import Operator import graphiq.circuit.ops as ops import graphiq.utils.openqasm_lib as oq_lib @@ -166,6 +167,47 @@ def test_gates_wrapper_1(gate_list): raise e +@pytest.mark.parametrize( + "gate_list", + [ + [ops.Hadamard, ops.Phase], + [ops.Phase, ops.Hadamard], + [ops.Hadamard, ops.SigmaX], + [ops.Hadamard, ops.SigmaY, ops.Phase], + [ops.Hadamard, ops.PhaseDagger, ops.SigmaY], + [ops.Hadamard, ops.Phase, ops.Hadamard, ops.Phase], + [ops.Phase, ops.Identity, ops.Hadamard], + [ops.Identity, ops.SigmaX], + [ops.Hadamard], + ], +) +def test_wrapper_openqasm_applies_the_last_gate_first(gate_list): + # the compilers apply a wrapper through unwrap(), so its openQASM must match + wrapper = ops.OneQubitGateWrapper(gate_list, register=0, reg_type="e") + wrapped = CircuitDAG(n_emitter=1) + wrapped.add(wrapper) + unwrapped = CircuitDAG(n_emitter=1) + for op in wrapper.unwrap(): + unwrapped.add(op) + u_wrapped = Operator(QuantumCircuit.from_qasm_str(wrapped.to_openqasm())) + u_unwrapped = Operator(QuantumCircuit.from_qasm_str(unwrapped.to_openqasm())) + assert u_wrapped.equiv(u_unwrapped) + + +def test_wrapper_openqasm_name_and_symbol_unchanged(): + gate_list = [ops.Hadamard, ops.Phase, ops.SigmaY] + circuit = CircuitDAG(n_emitter=1) + circuit.add(ops.OneQubitGateWrapper(gate_list, register=0, reg_type="e")) + assert "hsy e0[0];" in circuit.to_openqasm() + assert circuit.openqasm_symbols["hsy"] == "U=HPY" + (wrapper,) = [ + op + for op in CircuitDAG.from_openqasm(circuit.to_openqasm()).sequence() + if isinstance(op, ops.OneQubitGateWrapper) + ] + assert wrapper.operations == gate_list + + @visualization def test_load_circuit_1(initialization_circuit): """ @@ -230,6 +272,60 @@ def test_load_circuit_3(initialization_circuit): circuit = CircuitDAG.from_openqasm(str) +@pytest.mark.parametrize("name", ["hs", "hsdgy", "hk"]) +def test_name_to_class_map_returns_none_outside_its_mapping(name): + # composite wrapper names are decoded by from_openqasm, not by name_to_class_map + assert ops.name_to_class_map(name) is None + + +def test_load_circuit_wrapped_gate_written_with_p_for_phase(): + circuit = CircuitDAG.from_openqasm( + CircuitDAG(n_emitter=1, n_photon=0, n_classical=0).to_openqasm() + "hp e0[0];" + ) + (wrapper,) = [ + op for op in circuit.sequence() if isinstance(op, ops.OneQubitGateWrapper) + ] + assert wrapper.operations == [ops.Hadamard, ops.Phase] + + +def test_load_circuit_multichar_wrapped_gate(): + """ + Regression test for round-tripping one-qubit gates whose openqasm name is + multi-character (e.g. PhaseDagger -> "sdg"), both wrapped inside a + OneQubitGateWrapper and standalone. Guards against the parser assuming every + single-qubit gate name is a single character. + """ + circuit1 = CircuitDAG(n_emitter=1, n_photon=1, n_classical=0) + circuit1.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.PhaseDagger, ops.SigmaY], register=0, reg_type="e" + ) + ) + circuit1.add(ops.PhaseDagger(register=0, reg_type="p")) + + circuit2 = CircuitDAG.from_openqasm(circuit1.to_openqasm()) + + def signatures(circuit): + sigs = [] + for op in circuit.sequence(): + if isinstance(op, (ops.Input, ops.Output)): + continue + if isinstance(op, ops.OneQubitGateWrapper): + sigs.append( + ( + "wrapper", + op.reg_type, + op.register, + tuple(o.__name__ for o in op.operations), + ) + ) + else: + sigs.append((type(op).__name__, op.reg_type, op.register)) + return sorted(sigs, key=str) + + assert signatures(circuit1) == signatures(circuit2) + + def test_load_circuit_4(initialization_circuit): """ Here, we check that we get the expected failure when a gate that we do not have defined it used diff --git a/tests/test_circuits/test_ops.py b/tests/test_circuits/test_ops.py index 83493c77..c4297adf 100644 --- a/tests/test_circuits/test_ops.py +++ b/tests/test_circuits/test_ops.py @@ -12,6 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import itertools + import numpy as np import pytest @@ -157,10 +159,23 @@ def test_get_local_clifford_matrix_by_name(): ) == [ops.Identity, ops.SigmaZ] +def test_find_local_clifford_by_matrix_returns_each_candidate_itself(): + gate_list1, gate_list2 = ops.local_clifford_composition() + for op1, op2 in itertools.product(gate_list1, gate_list2): + matrix1 = ops.local_clifford_to_matrix_map(op1) + matrix2 = ops.local_clifford_to_matrix_map(op2) + assert ops.find_local_clifford_by_matrix(matrix1 @ matrix2) == op1 + op2 + + def test_op(): u1 = dmf.phase() @ dmf.hadamard() u2 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() @ dmf.sigmax() - print(f"is u1 equivalent to u2 = {dmf.check_equivalent_unitaries(u1, u2)}") + assert dmf.check_equivalent_unitaries(u1, u2) u3 = dmf.hadamard() @ dmf.phase() @ dmf.hadamard() @ dmf.phase() u4 = dmf.phase() @ dmf.hadamard() @ dmf.sigmax() - print(f"is u3 equivalent to u4 = {dmf.check_equivalent_unitaries(u3, u4)}") + assert dmf.check_equivalent_unitaries(u3, u4) + + +def test_one_qubit_gate_wrapper_rejects_nested_wrapper(): + with pytest.raises(AssertionError): + ops.OneQubitGateWrapper([ops.OneQubitGateWrapper]) diff --git a/tests/test_data_collection/__init__.py b/tests/test_data_collection/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_data_collection/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_data_collection/test_correlation_module.py b/tests/test_data_collection/test_correlation_module.py new file mode 100644 index 00000000..b20e449d --- /dev/null +++ b/tests/test_data_collection/test_correlation_module.py @@ -0,0 +1,183 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +import graphiq.circuit.ops as ops +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.data_collection.correlation_module import ( + GraphCorr, + NodeCorr, + graph_to_circ, + graph_to_cnot, + graph_to_emit_depth, + rnd_graph, +) +from graphiq.metrics import CircuitMaxEmitDepth, Infidelity +from graphiq.state import QuantumState + + +def test_graph_corr_initial_graph_is_none_without_an_initial_graph(): + assert GraphCorr(graph_list=[nx.path_graph(3)]).initial_graph is None + assert GraphCorr().initial_graph is None + + +def test_initial_graph_setter_rejects_none_and_leaves_the_object_unchanged(): + graph = nx.path_graph(3) + corr = GraphCorr(initial_graph=graph, num_isomorph=2) + before = corr.graph_list + with pytest.raises( + AssertionError, match="initial graph must be a networkx graph object" + ): + corr.initial_graph = None + assert corr.initial_graph is graph + assert corr.graph_list is before + + +def test_num_isomorph_setter_without_an_initial_graph_sets_the_relabel_count( + monkeypatch, +): + graph = nx.cycle_graph(6) + corr = GraphCorr(circ_metric="num_emit", graph_list=[graph], relabel_trials=1) + corr.num_isomorph = 3 + assert corr.num_isomorph == 3 + assert corr.graph_list == [graph] + + counts = [] + + def record(g, count): + counts.append(count) + return [g] + + monkeypatch.setattr(GraphCorr, "_graph_list_maker", staticmethod(record)) + corr._min_circ_met_over_relabel(graph) + assert counts == [3] + + +def test_relabel_trials_work_after_graph_circ_dict(monkeypatch): + graph = nx.cycle_graph(6) + perm = [0, 1, 2, 3, 5, 4] + adj = nx.to_numpy_array(graph) + relabeled = nx.from_numpy_array(adj[np.ix_(perm, perm)]) + assert graph_to_cnot(graph) == 3 + assert graph_to_cnot(relabeled) == 2 + monkeypatch.setattr( + GraphCorr, "_graph_list_maker", staticmethod(lambda g, count: [g, relabeled]) + ) + + corr = GraphCorr( + circ_metric="cnot", graph_list=[graph], relabel_trials=2, num_isomorph=1 + ) + corr.graph_circ_dict() + assert relabeled not in corr._graph_circ_dictionary + + assert corr._min_circ_met_over_relabel(graph) == 2 + + +def test_node_corr_graph_setter_updates_the_adjacency_matrix(): + corr = NodeCorr(nx.path_graph(4), relabel_trials=3, show_plot=False) + new_graph = nx.cycle_graph(5) + corr.graph = new_graph + + assert np.array_equal(corr.adj, nx.to_numpy_array(new_graph)) + assert corr.adj_list + for adj in corr.adj_list: + assert nx.is_isomorphic(nx.from_numpy_array(adj), new_graph) + + +_p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) + + +@pytest.mark.parametrize( + "graph", + [ + _p3_plus_k1, + nx.disjoint_union(nx.empty_graph(1), nx.path_graph(3)), + nx.to_numpy_array(_p3_plus_k1), + ], + ids=["isolate_last", "isolate_first", "adjacency_matrix"], +) +def test_graph_to_circ_rejects_a_graph_with_an_isolated_vertex(graph): + with pytest.raises(ValueError, match="isolated"): + graph_to_circ(graph) + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(outcome): + graph = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + circuit = graph_to_circ(graph) + + target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") + # force every measurement to 0, then to 1, so each outcome is checked every run + compiler = StabilizerCompiler() + compiler.measurement_determinism = outcome + state = compiler.compile(circuit) + n_photon = graph.number_of_nodes() + state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) + assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) + + +def test_rnd_graph_with_a_seed_redraws_until_connected(monkeypatch): + n, p, seed = 6, 0.3, 0 + assert not nx.is_connected(nx.gnp_random_graph(n, p, seed=seed)) + + draw = nx.gnp_random_graph + calls = [] + + def bounded(*args, **kwargs): + calls.append(1) + if len(calls) > 100: + raise RuntimeError("rnd_graph drew 100 graphs without a connected one") + return draw(*args, **kwargs) + + monkeypatch.setattr(nx, "gnp_random_graph", bounded) + graph = rnd_graph(n, p, seed=seed) + assert nx.is_connected(graph) + assert graph.number_of_nodes() == n + assert sorted(rnd_graph(n, p, seed=seed).edges()) == sorted(graph.edges()) + + +def test_rnd_graph_with_a_seed_keeps_a_connected_first_draw(): + n, p = 6, 0.3 + seeds = [s for s in range(50) if nx.is_connected(nx.gnp_random_graph(n, p, seed=s))] + assert seeds + for s in seeds: + expected = nx.gnp_random_graph(n, p, seed=s) + assert sorted(rnd_graph(n, p, seed=s).edges()) == sorted(expected.edges()) + + +def test_cnot_per_photon_divides_the_cnot_count_by_the_number_of_photons(): + graph = nx.cycle_graph(6) + n_cnot = graph_to_cnot(graph) + assert n_cnot > 0 + corr = GraphCorr(circ_metric="cnot_per_photon", graph_list=[graph]) + assert corr._circ_met_value(graph) == pytest.approx(n_cnot / 6) + + +def test_graph_to_emit_depth_counts_only_the_gates_on_the_emitter(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.SigmaX(register=0, reg_type="e")) + assert graph_to_emit_depth(circuit) == 2 + + graph_circuit = graph_to_circ(nx.cycle_graph(6)) + assert graph_to_emit_depth(graph_circuit) == CircuitMaxEmitDepth().evaluate( + None, graph_circuit + ) diff --git a/tests/test_data_collection/test_user_interface.py b/tests/test_data_collection/test_user_interface.py new file mode 100644 index 00000000..6c9edbeb --- /dev/null +++ b/tests/test_data_collection/test_user_interface.py @@ -0,0 +1,177 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json + +import networkx as nx +import numpy as np +import pandas as pd +import pytest + +import graphiq.circuit.ops as ops +from graphiq.benchmarks.circuits import ghz3_state_circuit +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.data_collection.correlation_module import graph_to_circ, graph_to_cnot +from graphiq.data_collection.user_interface import ( + LC_scaling_known, + find_best, + graph_analyzer, + plot_map_based, + result_maker, + rgs_analysis, +) +from graphiq.metrics import CircuitUnitaryCount +from graphiq.solvers.solver_result import SolverResult + + +def test_result_maker_counts_each_one_qubit_gate_once(): + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + for gate in [ops.SigmaX, ops.SigmaY, ops.SigmaZ, ops.PhaseDagger, ops.Hadamard]: + circuit.add(gate(register=0, reg_type="e")) + + result = result_maker( + SolverResult([circuit]), graph_met_list=[], circ_met_list=["n_unitary"] + ) + + assert result["n_unitary"] == [5] + assert result["n_unitary"][0] == CircuitUnitaryCount().evaluate(None, circuit) + + +def test_result_maker_gives_0_emitter_depths_for_a_circuit_without_emitters(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + + result = result_maker(SolverResult([circuit]), graph_met_list=[]) + + assert result["n_emitters"] == [0] + assert result["max_emit_depth"] == [0] + assert result["max_emit_reset_depth"] == [0] + assert result["max_emit_eff_depth"] == [0] + assert result["depth"] == [0] + + +def test_plot_map_based_plots_each_relabel_map_group(monkeypatch): + calls = [] + + def record(result, indices=None, dir_name="new", **kwargs): + calls.append((result, indices, dir_name)) + + monkeypatch.setattr("graphiq.data_collection.user_interface.plot_figs", record) + # the shape get_relabel_map returns for an unchanged labelling + identity = {-1: "self", 0: 0, 1: 1} + result = SolverResult([None, None, None], ["map"]) + result["map"] = [identity, {0: 1, 1: 0}, dict(identity)] + + plot_map_based(result) + + assert [(c[1], c[2]) for c in calls] == [ + ([0, 2], "map_0_len2"), + ([1], "map_1_len1"), + ] + assert all(c[0] is result for c in calls) + + +def test_find_best_without_dir_path_returns_results_and_writes_nothing( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + out = find_best(nx.to_numpy_array(nx.path_graph(3)), n_reordering=1, conf=0) + + assert len(out["cost"]) >= 1 + assert list(tmp_path.iterdir()) == [] + + +def test_rgs_analysis_saves_n_cnots_without_an_empty_n_cnot_column( + tmp_path, monkeypatch +): + ui = "graphiq.data_collection.user_interface" + monkeypatch.setattr(f"{ui}.correlation_checker", lambda *a, **k: None) + monkeypatch.setattr(f"{ui}.plot_figs", lambda *a, **k: None) + graph = nx.cycle_graph(5) + result = SolverResult([graph_to_circ(graph)], ["g"]) + result["g"] = [graph] + (tmp_path / "RGS" / "run").mkdir(parents=True) + + rgs_analysis(result, str(tmp_path), "run") + + saved = json.loads((tmp_path / "RGS" / "run" / "run.json").read_text()) + assert "n_cnot" not in saved + assert saved["n_cnots"] == [graph_to_cnot(graph)] + assert "n_cnots" in result._properties + assert "n_cnot" not in result._properties + + +def test_rgs_analysis_creates_its_save_directory(tmp_path, monkeypatch): + ui = "graphiq.data_collection.user_interface" + monkeypatch.setattr(f"{ui}.correlation_checker", lambda *a, **k: None) + monkeypatch.setattr(f"{ui}.plot_figs", lambda *a, **k: None) + graph = nx.cycle_graph(5) + result = SolverResult([graph_to_circ(graph)], ["g"]) + result["g"] = [graph] + + rgs_analysis(result, str(tmp_path), "run") + + saved = json.loads((tmp_path / "RGS" / "run" / "run.json").read_text()) + assert saved["n_cnots"] == [graph_to_cnot(graph)] + + +def test_lc_scaling_known_creates_its_save_directory(tmp_path): + circuit, _ = ghz3_state_circuit() + result = SolverResult([circuit] * 3, ["n_cnots", "max_emit_eff_depth"]) + result["n_cnots"] = [1, 2, 4] + result["max_emit_eff_depth"] = [3, 5, 6] + np.random.seed(0) + + c_means, c_stds, d_means, d_stds = LC_scaling_known( + result, str(tmp_path), "cls", n_lc_list=[1, 2] + ) + + # the files hold the returned statistics, whatever the random draws were + assert (tmp_path / "cls" / "bwCNOT.csv").read_text() == pd.DataFrame( + (c_means, c_stds) + ).to_csv(index=False, header=False) + assert (tmp_path / "cls" / "bwDepth.csv").read_text() == pd.DataFrame( + (d_means, d_stds) + ).to_csv(index=False, header=False) + + +@pytest.mark.parametrize( + "fidelities, best, worst", + [ + ([0.9, 0.5, 0.7], ("LC: 0 iso: 0", 0.9), ("LC: 0 iso: 1", 0.5)), + ([0.9, 0.5, 0.5], ("LC: 0 iso: 0", 0.9), ("LC: 0 iso: 1", 0.5)), + ([0.5, 0.9], ("LC: 0 iso: 1", 0.9), ("LC: 0 iso: 0", 0.5)), + ], +) +def test_graph_analyzer_reports_the_lowest_fidelity_as_worst( + tmp_path, monkeypatch, fidelities, best, worst +): + ui = "graphiq.data_collection.user_interface" + path3 = nx.to_numpy_array(nx.path_graph(3)) + monkeypatch.setattr(f"{ui}.lcs", lambda *args, **kwargs: [path3]) + n_cases = len(fidelities) + cases = { + "cost": list(range(1, n_cases + 1)), + "edges": [[(0, 1), (1, 2)]] * n_cases, + "n_cnots": [0] * n_cases, + "max_emit_reset_depth": [1] * n_cases, + "fidelity": fidelities, + } + monkeypatch.setattr(f"{ui}.find_best", lambda *args, **kwargs: cases) + + out = graph_analyzer([0, 1, 1, 2], dir_path=str(tmp_path), graph_class="c") + + assert out[0][:2] == best + assert out[4][:2] == worst + bests = (tmp_path / "c" / "bests.txt").read_text() + assert f"worst graph w.r.t fidelity {worst[0]}" in bests diff --git a/tests/test_metrics/__init__.py b/tests/test_metrics/__init__.py new file mode 100644 index 00000000..fd1ce610 --- /dev/null +++ b/tests/test_metrics/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/test_metrics/test_metrics.py b/tests/test_metrics/test_metrics.py new file mode 100644 index 00000000..ce60469c --- /dev/null +++ b/tests/test_metrics/test_metrics.py @@ -0,0 +1,674 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for graphiq/metrics.py: the circuit-structure metrics, GraphMetric, the +Metrics wrapper, the Infidelity branch that converts a non-stabilizer state into +a (possibly mixed) stabilizer representation, and TraceDistance. +""" + +import networkx as nx +import numpy as np +import pytest + +import graphiq.backends.density_matrix.functions as dmf +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.backends.state_rep_conversion import graph_to_density +from graphiq.benchmarks.alternate_circuits import noise_model_pure_loss +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.alternate_target_solver import graph_to_circ +from graphiq.state import QuantumState +from graphiq.metrics import ( + CircuitCnotCount, + CircuitDepth, + CircuitEmitterCount, + CircuitMaxEmitDepth, + CircuitMaxEmitEffDepth, + CircuitMaxEmitResetDepth, + CircuitMeasureCount, + CircuitUnitaryCount, + GraphMetric, + Infidelity, + Metrics, + TraceDistance, + graph_met_value, +) + + +def _emitter_circuit(n_emitter, n_photon=0, n_classical=0): + return CircuitDAG(n_emitter=n_emitter, n_photon=n_photon, n_classical=n_classical) + + +def test_circuit_depth_default_penalty_returns_depth(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + assert CircuitDepth().evaluate(None, circuit) == circuit.depth + + +def test_circuit_depth_custom_penalty_applied(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metric = CircuitDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == circuit.depth + 100 + + +def test_circuit_emitter_count_default_penalty_returns_count(): + circuit = _emitter_circuit(3) + assert CircuitEmitterCount().evaluate(None, circuit) == 3 + + +def test_circuit_emitter_count_custom_penalty_applied(): + circuit = _emitter_circuit(3) + metric = CircuitEmitterCount(n_emitter_penalty=lambda n: 10 * n) + assert metric.evaluate(None, circuit) == 30 + + +# The following CircuitCnotCount tests are regression coverage for the +# CircuitCnotCount.__init__ bug, where the default-penalty branch set +# self.n_emitter_penalty instead of self.n_cnot_penalty, so evaluate() raised +# AttributeError whenever n_cnot_penalty was left at its default. + + +def test_circuit_cnot_count_no_cnots_default_penalty(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + assert CircuitCnotCount().evaluate(None, circuit) == 0 + + +def test_circuit_cnot_count_counts_cnots_with_default_penalty(): + circuit = _emitter_circuit(2) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.CNOT(control=1, control_type="e", target=0, target_type="e")) + assert CircuitCnotCount().evaluate(None, circuit) == 2 + + +def test_circuit_cnot_count_custom_penalty_applied(): + circuit = _emitter_circuit(2) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + metric = CircuitCnotCount(n_cnot_penalty=lambda n: 10 * n) + assert metric.evaluate(None, circuit) == 10 + + +# The following CircuitUnitaryCount tests are regression coverage for the +# CircuitUnitaryCount bug, where the gate label list checked "SigmaX" three +# times instead of "SigmaX", "SigmaY", "SigmaZ", so SigmaY/SigmaZ gates were +# never counted. + + +def test_circuit_unitary_count_counts_sigma_y_and_z(): + circuit = _emitter_circuit(1) + circuit.add(ops.SigmaY(register=0, reg_type="e")) + circuit.add(ops.SigmaZ(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Phase(register=0, reg_type="e")) + circuit.add(ops.PhaseDagger(register=0, reg_type="e")) + # SigmaY, SigmaZ, Hadamard, Phase, PhaseDagger: 5 unitaries, no SigmaX + # present at all. The pre-fix code only ever checked "SigmaX" (thrice) so + # it would report 3 (Hadamard + Phase + PhaseDagger) here. + assert CircuitUnitaryCount().evaluate(None, circuit) == 5 + + +def test_circuit_unitary_count_counts_all_gate_types_once_each(): + circuit = _emitter_circuit(2) + circuit.add(ops.SigmaX(register=0, reg_type="e")) + circuit.add(ops.SigmaY(register=0, reg_type="e")) + circuit.add(ops.SigmaZ(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Phase(register=0, reg_type="e")) + circuit.add(ops.PhaseDagger(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + assert CircuitUnitaryCount().evaluate(None, circuit) == 7 + + +def test_circuit_measure_count_counts_measurement_and_reset_ops(): + circuit = _emitter_circuit(1, n_photon=2, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + assert CircuitMeasureCount().evaluate(None, circuit) == 2 + + +def test_circuit_measure_count_custom_penalty_applied(): + circuit = _emitter_circuit(1, n_photon=1, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + metric = CircuitMeasureCount(m_penalty=lambda n: n + 5) + assert metric.evaluate(None, circuit) == 6 + + +# CircuitMaxEmitDepth, CircuitMaxEmitResetDepth, and CircuitMaxEmitEffDepth +# rely on undocumented CircuitDAG internals (reg_gate_history, _max_depth). +# The expected values below were derived by constructing each circuit and printing +# reg_gate_history/_max_depth/the metric's own output directly, rather than +# by hand-tracing the DAG, to avoid the test itself encoding a wrong +# understanding of these internals. + + +def test_circuit_max_emit_depth_default_penalty_is_max_across_emitters(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + # emitter 0 has 1 gate node, emitter 1 has 3 -> max is 3 + assert CircuitMaxEmitDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_depth_no_gates_is_zero(): + circuit = _emitter_circuit(1) + assert CircuitMaxEmitDepth().evaluate(None, circuit) == 0 + + +def test_circuit_max_emit_depth_custom_penalty_applied(): + circuit = _emitter_circuit(2) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + metric = CircuitMaxEmitDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def _reset_circuit(): + # A single emitter measured-and-reset twice, with photons/classical + # register interleaved: Input, H, H, M&R, H, M&R, Output. The longest + # gap between consecutive Input/M&R/Output markers is 3 nodes (Input to + # the first M&R). + circuit = _emitter_circuit(1, n_photon=2, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + return circuit + + +def test_circuit_max_emit_reset_depth_default_penalty(): + circuit = _reset_circuit() + assert CircuitMaxEmitResetDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_reset_depth_custom_penalty_applied(): + circuit = _reset_circuit() + metric = CircuitMaxEmitResetDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def test_circuit_max_emit_eff_depth_matches_reset_depth_without_branching(): + # With no two-qubit gates, DAG depth along a single emitter's own + # chronological chain increases by exactly 1 per node, so + # CircuitMaxEmitEffDepth reduces to the same value as + # CircuitMaxEmitResetDepth on this circuit. + circuit = _reset_circuit() + assert CircuitMaxEmitEffDepth().evaluate(None, circuit) == 3 + + +def test_circuit_max_emit_eff_depth_custom_penalty_applied(): + circuit = _reset_circuit() + metric = CircuitMaxEmitEffDepth(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, circuit) == 103 + + +def test_circuit_max_emit_eff_depth_diverges_from_reset_depth_with_cross_register_depth(): + # CircuitMaxEmitResetDepth counts nodes along each emitter's own chain, + # blind to what CNOTs bring in from other registers; CircuitMaxEmitEffDepth + # uses the DAG's true topological depth (_max_depth), which does pick up + # that cross-register depth. Chain e2 (5 Hadamards) -> CNOT into e1 (short + # chain) -> CNOT into e0 (short chain, then 2 measure-and-resets): e2's own + # depth injects into e1 and then e0, inflating e0/e1's effective depth well + # past their own node counts, while e2's own long chain still dominates the + # node-count-based CircuitMaxEmitResetDepth. + circuit = _emitter_circuit(3, n_photon=2, n_classical=1) + for _ in range(5): + circuit.add(ops.Hadamard(register=2, reg_type="e")) + circuit.add(ops.Hadamard(register=1, reg_type="e")) + circuit.add(ops.CNOT(control=2, control_type="e", target=1, target_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=1, control_type="e", target=0, target_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, control_type="e", target=1, target_type="p", c_register=0 + ) + ) + + assert CircuitMaxEmitResetDepth().evaluate(None, circuit) == 7 + assert CircuitMaxEmitEffDepth().evaluate(None, circuit) == 8 + + +def _circuit_without_emitters(): + circuit = _emitter_circuit(0, n_photon=2) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + circuit.add(ops.CNOT(control=0, control_type="p", target=1, target_type="p")) + return circuit + + +_EMITTER_DEPTH_METRICS = [ + CircuitMaxEmitDepth, + CircuitMaxEmitResetDepth, + CircuitMaxEmitEffDepth, +] + + +@pytest.mark.parametrize("metric_class", _EMITTER_DEPTH_METRICS) +def test_emitter_depth_metrics_on_a_circuit_without_emitters(metric_class): + assert metric_class().evaluate(None, _circuit_without_emitters()) == 0 + + +def test_emitter_depth_metrics_by_name_on_a_circuit_without_emitters(): + metrics = Metrics([m.__name__ for m in _EMITTER_DEPTH_METRICS]) + assert metrics.evaluate(None, _circuit_without_emitters()) == 0.0 + + +@pytest.mark.parametrize("metric_class", _EMITTER_DEPTH_METRICS) +def test_emitter_depth_penalty_applies_without_emitters(metric_class): + metric = metric_class(depth_penalty=lambda d: d + 100) + assert metric.evaluate(None, _circuit_without_emitters()) == 100 + assert metric.log == [100] + + +@pytest.fixture +def path_graph(): + # 0 - 1 - 2 - 3, a simple, hand-checkable graph + return nx.path_graph(4) + + +def test_graph_met_value_node_and_edge_counts(path_graph): + assert graph_met_value("node", path_graph) == 4 + assert graph_met_value("n_edges", path_graph) == 3 + + +def test_graph_met_value_max_degree(path_graph): + # both middle nodes (1 and 2) have degree 2 + assert graph_met_value("max_deg", path_graph) == 2 + + +def test_graph_met_value_radius_and_diameter(path_graph): + assert graph_met_value("radius", path_graph) == 2 + assert graph_met_value("diameter", path_graph) == 3 + + +def test_graph_met_value_pop_is_edge_density(path_graph): + # density of a path graph on 4 nodes: 3 edges / C(4, 2) = 3 / 6 = 0.5 + assert graph_met_value("pop", path_graph) == pytest.approx(0.5) + + +def test_graph_met_value_unknown_metric_raises(path_graph): + with pytest.raises(ValueError): + graph_met_value("not_a_real_metric", path_graph) + + +def test_graph_metric_evaluate_default_penalty(path_graph): + metric = GraphMetric(path_graph) + assert metric.evaluate("node") == 4 + + +def test_graph_metric_evaluate_custom_penalty(path_graph): + metric = GraphMetric(path_graph, penalty=lambda x: x * 2) + assert metric.evaluate("n_edges") == 6 + + +# The following Metrics-wrapper tests stick to CircuitDepth, with distinct +# penalty functions to get distinguishable per-metric values. + + +def test_metrics_default_equal_weighting(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + depth = circuit.depth + metrics = Metrics( + [ + CircuitDepth(depth_penalty=lambda d: d), + CircuitDepth(depth_penalty=lambda d: d + 1), + ] + ) + assert metrics.evaluate(None, circuit) == depth + (depth + 1) + + +def test_metrics_list_weighting(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + depth = circuit.depth + metrics = Metrics( + [ + CircuitDepth(depth_penalty=lambda d: d), + CircuitDepth(depth_penalty=lambda d: d + 1), + ], + metric_weight=[1, 10], + ) + assert metrics.evaluate(None, circuit) == 1 * depth + 10 * (depth + 1) + + +def test_metrics_callable_weighting(): + circuit = _emitter_circuit(1) + metrics = Metrics( + [CircuitDepth()], + metric_weight=lambda state, circuit: 99, + ) + assert metrics.evaluate(None, circuit) == 99 + + +@pytest.mark.parametrize("metric_weight", [(1, 10), 2.0]) +def test_metrics_rejects_an_unsupported_weight_type(metric_weight): + # a tuple or a scalar used to be accepted, and the first evaluate then + # raised AttributeError for the missing weighting function + with pytest.raises(TypeError, match="metric_weight"): + Metrics([CircuitDepth(), CircuitEmitterCount()], metric_weight=metric_weight) + + +def test_metrics_per_metric_log(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metrics = Metrics([CircuitDepth()]) + metrics.evaluate(None, circuit) + log = metrics.per_metric_log + assert log["CircuitDepth"] == [circuit.depth] + + +def test_metrics_string_metric_name(): + circuit = _emitter_circuit(1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + metrics = Metrics(["CircuitDepth"]) + assert metrics.evaluate(None, circuit) == circuit.depth + + +def test_metrics_unrecognized_metric_raises(): + with pytest.raises(UserWarning): + Metrics(["NotARealMetric"]) + + +@pytest.mark.parametrize( + "name", + [ + "CircuitEmitterCount", + "CircuitCnotCount", + "CircuitUnitaryCount", + "CircuitMaxEmitDepth", + "CircuitMaxEmitResetDepth", + "CircuitMaxEmitEffDepth", + "CircuitMeasureCount", + ], +) +def test_metrics_accepts_each_circuit_metric_by_name(name): + metrics = Metrics([name]) + assert [type(metric).__name__ for metric in metrics._metrics] == [name] + + +def test_metrics_rejects_graph_metric_by_name(): + # GraphMetric's evaluate takes a graph-metric name, not (state, circuit) + with pytest.raises(UserWarning): + Metrics(["GraphMetric"]) + + +def test_infidelity_graph_state_marked_mixed_matches_stabilizer_target(): + """Regression coverage for the Infidelity.evaluate bug where the mixed- + stabilizer branch checked `isinstance(state.rep_data, MixedStabilizer)` + instead of `isinstance(rep_data, MixedStabilizer)`. When the target is + rep_type "s" but the evaluated state is not, rep_data comes from a + converted copy, and state.rep_data (the original, un-converted dm/graph + data) never matches -- so `fid` was left unassigned and evaluate() raised + UnboundLocalError. + + Builds the |+> state two ways: once as a graph representation (a single + isolated node, no edges) marked mixed=True -- forcing the "g"->"s" + conversion this Infidelity branch performs to wrap its result in a + MixedStabilizer (a single (1.0, tableau) entry) -- and once as a genuine + Stabilizer state via a Hadamard-only circuit, used as the target. The + graph->stabilizer conversion is an exact, unambiguous transform, unlike + the density-matrix route, which goes through an eigendecomposition. + """ + graph = nx.Graph() + graph.add_node(0) + state = QuantumState(data=graph, rep_type="g", mixed=True) + + target_circuit = _emitter_circuit(1) + target_circuit.add(ops.Hadamard(register=0, reg_type="e")) + target = StabilizerCompiler().compile(target_circuit) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == pytest.approx(0.0, abs=1e-9) + + +def test_infidelity_and_trace_distance_of_a_graph_state_marked_mixed_against_a_dm_target(): + state = QuantumState(nx.path_graph(3), rep_type="g", mixed=True) + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + assert Infidelity(target).evaluate(state, None) == pytest.approx(0.0, abs=1e-9) + assert TraceDistance(target).evaluate(state, None) == pytest.approx(0.0, abs=1e-9) + + +def _empty_plus_triangle_mixture(): + # two orthogonal 3-qubit graph states with distinct weights + return 0.7 * graph_to_density(nx.empty_graph(3)) + 0.3 * graph_to_density( + nx.complete_graph(3) + ) + + +def test_infidelity_of_a_mixed_density_matrix_against_a_stabilizer_target(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + target = QuantumState( + get_clifford_tableau_from_graph(nx.empty_graph(3)), rep_type="s" + ) + + infidelity = Infidelity(target=target).evaluate(state, None) + + expected = 1 - dmf.fidelity(graph_to_density(nx.empty_graph(3)), rho) + assert infidelity == pytest.approx(expected) + + +def test_infidelity_rejects_a_mixed_density_matrix_not_marked_mixed(): + # Converting it would keep one branch of the mixture and score only that branch. + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + target = QuantumState( + get_clifford_tableau_from_graph(nx.empty_graph(3)), rep_type="s" + ) + + with pytest.raises(ValueError, match="mixed=True"): + Infidelity(target=target).evaluate(state, None) + + +def test_infidelity_rejects_a_lossy_density_matrix_not_marked_mixed(): + # Converting it to a stabilizer state would drop the lost 0.1 and score it lossless. + state = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + assert np.real(np.trace(state.rep_data.data)) == pytest.approx(0.9) + target = QuantumState( + get_clifford_tableau_from_graph(nx.path_graph(3)), rep_type="s" + ) + + with pytest.raises(ValueError, match="mixed=True"): + Infidelity(target=target).evaluate(state, None) + + +def test_trace_distance_stabilizer_state_against_its_own_density_matrix_is_zero(): + # TraceDistance.evaluate only compares density matrices; a stabilizer-represented state is + # converted to "dm" on the fly (a copy, so the original state is left untouched). + state = QuantumState(1, rep_type="s") + target = QuantumState(1, rep_type="s") + target.convert_representation("dm") + + trace_distance = TraceDistance(target=target).evaluate(state, None) + + assert trace_distance == pytest.approx(0.0, abs=1e-9) + + +def test_trace_distance_stabilizer_ket0_against_density_matrix_ket1_is_one(): + state = QuantumState(1, rep_type="s") # |0> + target = QuantumState(dmf.ket2dm(dmf.state_ketz1()), rep_type="dm") # |1> + + trace_distance = TraceDistance(target=target).evaluate(state, None) + + assert trace_distance == pytest.approx(1.0, abs=1e-9) + + +def test_trace_distance_raises_when_target_is_not_a_density_matrix(): + # The check is keyed on the target's rep_type, not the evaluated state's. + state = QuantumState(1, rep_type="s") + target = QuantumState(1, rep_type="s") + + with pytest.raises(ValueError, match="Cannot compute the trace distance"): + TraceDistance(target=target).evaluate(state, None) + + +def _path3_mixture(): + return 0.5 * graph_to_density(nx.path_graph(3)) + 0.5 * graph_to_density( + nx.star_graph(2) + ) + + +@pytest.mark.parametrize( + "rho", [graph_to_density(nx.path_graph(3)), _path3_mixture()], ids=["pure", "mixed"] +) +def test_infidelity_counts_the_missing_trace_of_a_lossy_density_matrix(rho): + target = QuantumState(rho, rep_type="dm") + state = QuantumState(rho, rep_type="dm") + # lose 0.1 in place; building the state from 0.9 * rho would renormalise it + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + + assert Infidelity(target=target).evaluate(state, None) == pytest.approx(0.1) + + +def test_infidelity_of_a_density_matrix_with_trace_one_up_to_rounding_is_unchanged(): + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + state = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + # four gate and dephasing steps leave the trace at 1 - 1.4e-15 + h = np.array([[1, 1], [1, -1]]) / np.sqrt(2) + unitaries = [np.kron(np.eye(2), np.kron(h, h)), np.kron(h, np.eye(4))] + dephasing = [np.sqrt(0.9) * np.eye(8), np.sqrt(0.1) * np.diag([1, -1] * 4)] + for step in range(4): + state.rep_data.apply_unitary(unitaries[step % 2]) + state.rep_data.apply_channel(dephasing) + sigma, rho = target.rep_data.data, state.rep_data.data + trace = np.real(np.trace(rho)) + assert np.trace(rho) != 1.0 and np.isclose(trace, 1) + # the lossy-state formula rounds differently here, so the check can tell + assert 1 - trace * dmf.fidelity(sigma, rho / trace) != 1 - dmf.fidelity(sigma, rho) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == 1 - dmf.fidelity(sigma, rho) + + +def _lossy_linear4(compiler, determinism, loss=0.1, lossy_gate_after=False): + circuit = graph_to_circ(nx.path_graph(4)).assign_noise(noise_model_pure_loss(loss)) + if lossy_gate_after: + # a loss after photon 3's last measurement, then a gate undoing the + # lossy Hadamard so the state still overlaps the target + circuit.add(ops.Hadamard(register=3, reg_type="p", noise=nm.PhotonLoss(0.1))) + circuit.add(ops.Hadamard(register=3, reg_type="p")) + compiler.noise_simulation = True + compiler.measurement_determinism = determinism + state = compiler.compile(circuit) + state.partial_trace(keep=list(range(4)), dims=(4 + circuit.n_emitters) * [2]) + return state + + +def _stabilizer_target_linear4(): + return QuantumState(get_clifford_tableau_from_graph(nx.path_graph(4)), rep_type="s") + + +def _density_matrix_target_linear4(): + return QuantumState(graph_to_density(nx.path_graph(4)), rep_type="dm") + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_infidelity_of_a_lossy_density_matrix_against_a_stabilizer_target(determinism): + state = _lossy_linear4(DensityMatrixCompiler(), determinism) + + infidelity = Infidelity(target=_stabilizer_target_linear4()).evaluate(state, None) + + assert infidelity == pytest.approx(0.1) + + +@pytest.mark.parametrize("determinism", [0, 1]) +@pytest.mark.parametrize("loss", [0.1, 1e-6]) +def test_density_matrix_infidelity_under_loss_matches_the_stabilizer_one( + loss, determinism +): + dm_state = _lossy_linear4(DensityMatrixCompiler(), determinism, loss) + stab_state = _lossy_linear4(StabilizerCompiler(), determinism, loss) + + dm = Infidelity(target=_density_matrix_target_linear4()).evaluate(dm_state, None) + stab = Infidelity(target=_stabilizer_target_linear4()).evaluate(stab_state, None) + + assert stab == pytest.approx(loss, rel=1e-6) + # relative only: an absolute tolerance near 1e-6 would accept 0.0 + assert dm == pytest.approx(stab, rel=1e-6) + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_density_matrix_infidelity_with_a_loss_after_the_last_measurement(determinism): + dm_state = _lossy_linear4( + DensityMatrixCompiler(), determinism, lossy_gate_after=True + ) + stab_state = _lossy_linear4( + StabilizerCompiler(), determinism, lossy_gate_after=True + ) + + dm = Infidelity(target=_density_matrix_target_linear4()).evaluate(dm_state, None) + stab = Infidelity(target=_stabilizer_target_linear4()).evaluate(stab_state, None) + + assert stab == pytest.approx(0.19) + assert dm == pytest.approx(stab) + + +_MIXED_RHO = np.array([[0.5, -0.2j], [0.2j, 0.5]]) +_MIXED_SIGMA = np.array([[0.6, 0.1 - 0.1j], [0.1 + 0.1j, 0.4]]) +# for qubits, F = tr(rho sigma) + 2 sqrt(det rho det sigma) +_F_MIXED = 0.54 + 2 * np.sqrt(0.21 * 0.22) + + +@pytest.mark.parametrize("loss", [0.0, 0.1]) +def test_infidelity_of_a_mixed_density_matrix_against_a_mixed_target(loss): + target = QuantumState(_MIXED_RHO, rep_type="dm") + state = QuantumState(_MIXED_SIGMA, rep_type="dm") + if loss: + # in place; building the state from (1 - loss) * sigma would renormalise it + state.rep_data.apply_channel([np.sqrt(1 - loss) * np.eye(2)]) + + infidelity = Infidelity(target=target).evaluate(state, None) + + assert infidelity == pytest.approx(1 - (1 - loss) * _F_MIXED) diff --git a/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py b/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py new file mode 100644 index 00000000..1559e783 --- /dev/null +++ b/tests/test_noise/test_additive_noise_on_classically_controlled_ops.py @@ -0,0 +1,170 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Additive noise on a classically controlled op's legs, on both backends. + +`ClassicalCNOT`/`ClassicalCZ`/`MeasurementCNOTandReset` (a +`ClassicalControlledPairOperationBase`) each carry one noise model per +register. `CompilerBase.compile` already splits those legs into a +pre-gate and a post-gate half by each leg's `"After gate"` flag whenever every +leg is an `AdditionNoiseBase`, and calls `_apply_additional_noise` on each +half -- this file exercises the backend arm that has to honour that split for +this op family, on both the stabilizer and the density-matrix backend. + +For `MeasurementCNOTandReset`, "the gate" runs as: measure the control, +conditionally apply the correction to the target, then reset the control. A +pre-gate leg on the control therefore acts on the qubit *before* it is +measured (so it can change the outcome), and a post-gate leg on the control +acts *after* the reset -- the only reading consistent with where +`compile_one_gate` measures and resets it. Every circuit below starts from +the all-zero state and every measurement outcome is deterministic once the +noise has acted, so `measurement_determinism = 0` never has to break a tie. + +Each behavioural test compiles the noisy circuit and an equivalent +circuit written out with an explicit, noiseless Pauli gate in the position +the noise should land, and asserts the two compile to the same state -- +mirroring `test_noise_placement.py`'s comparison style. A stabilizer compile +is converted to a density matrix via `QuantumState.convert_representation` +before the comparison, since both backends are being driven through the same +assertion. +""" + +import numpy as np +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _pauli(letter, after_gate): + """A `PauliError` leg with its `"After gate"` placement spelled out. + + `PauliError` takes no placement argument -- `AdditionNoiseBase.__init__` + always stamps `noise_parameters["After gate"] = True` on construction -- + so a pre-gate leg is only reachable by flipping the flag afterwards. + """ + noise = nm.PauliError(letter) + noise.noise_parameters["After gate"] = after_gate + return noise + + +def _cnot_and_reset_circuit(noise_legs): + """`MeasurementCNOTandReset(control=0/e, target=0/p)` on `|0>|0>`.""" + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=list(noise_legs), + ) + ) + return circuit + + +def _cnot_and_reset_explicit(pre_control=False, post_control=False): + """The same op, noiseless, with an explicit `SigmaX(control)` where a + noisy leg should land.""" + circuit = CircuitDAG(n_emitter=1, n_photon=1, n_classical=1) + if pre_control: + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="e", + target=0, + target_type="p", + c_register=0, + noise=[nm.NoNoise(), nm.NoNoise()], + ) + ) + if post_control: + circuit.add(ops.SigmaX(register=0, reg_type="e", noise=nm.NoNoise())) + return circuit + + + + + + +def _compile(compiler_class, circuit): + compiler = compiler_class() + compiler.noise_simulation = True + compiler.measurement_determinism = 0 + state = compiler.compile(circuit) + if compiler_class is StabilizerCompiler: + state.convert_representation("dm") + return state.rep_data.data + + +@pytest.mark.parametrize("compiler_class", [StabilizerCompiler, DensityMatrixCompiler]) +def test_pre_gate_noise_on_the_control_flips_the_branch(compiler_class): + noisy = _cnot_and_reset_circuit([_pauli("X", after_gate=False), nm.NoNoise()]) + expected = _cnot_and_reset_explicit(pre_control=True) + assert np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, expected) + ) + + # Without the noise, the same circuit measures 0 and touches nothing. + plain = _cnot_and_reset_explicit() + assert not np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, plain) + ) + + +@pytest.mark.parametrize("compiler_class", [StabilizerCompiler, DensityMatrixCompiler]) +def test_post_gate_noise_on_the_control_lands_after_the_reset(compiler_class): + noisy = _cnot_and_reset_circuit([_pauli("X", after_gate=True), nm.NoNoise()]) + expected = _cnot_and_reset_explicit(post_control=True) + assert np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, expected) + ) + + plain = _cnot_and_reset_explicit() + assert not np.allclose( + _compile(compiler_class, noisy), _compile(compiler_class, plain) + ) + + + + +def test_the_two_backends_agree(): + noisy_circuits = [ + _cnot_and_reset_circuit([_pauli("X", after_gate=False), nm.NoNoise()]), + _cnot_and_reset_circuit([_pauli("X", after_gate=True), nm.NoNoise()]), + ] + for circuit in noisy_circuits: + stabilizer_result = _compile(StabilizerCompiler, circuit) + dm_result = _compile(DensityMatrixCompiler, circuit) + assert np.allclose(stabilizer_result, dm_result) + + +def test_an_op_outside_every_arm_still_raises_on_the_stabilizer_backend(): + # The dispatcher never routes a MeasurementZ through + # `_apply_additional_noise` (it is not a controlled op), so this is only + # reachable by calling the method directly -- which is exactly what pins + # that the bare `pass` this arm used to be is gone. + op = ops.MeasurementZ( + register=0, reg_type="e", c_register=0, noise=nm.PauliError("X") + ) + with pytest.raises(ValueError): + StabilizerCompiler()._apply_additional_noise( + None, op, 1, lambda reg, reg_type: 0 + ) diff --git a/tests/test_noise/test_mixed_stabilizer_reduce.py b/tests/test_noise/test_mixed_stabilizer_reduce.py new file mode 100644 index 00000000..a0c4e19a --- /dev/null +++ b/tests/test_noise/test_mixed_stabilizer_reduce.py @@ -0,0 +1,243 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``MixedStabilizer.reduce`` folds branches that represent the same stabilizer +state, even when their tableaux differ: another route to the state, another +destabilizer completion or signs, or the generators in another order. Branches +of different states stay apart, the mixed state is unchanged, and the input +tableaux are not modified. The folding of tableaux that are ``==`` to each +other is covered in +tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py. +""" + +import numpy as np + +import graphiq.backends.stabilizer.functions.transformation as transform +import graphiq.backends.state_rep_conversion as rc +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.state import MixedStabilizer +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _density(mixture): + return sum(p * rc.stabilizer_to_density(t.to_stabilizer()) for p, t in mixture) + + +def _bell_routes(): + route_a = CliffordTableau(2) + route_a = transform.hadamard_gate(route_a, 0) + route_a = transform.cnot_gate(route_a, 0, 1) + + route_b = CliffordTableau(2) + route_b = transform.hadamard_gate(route_b, 1) + route_b = transform.cnot_gate(route_b, 1, 0) + return route_a, route_b + + +def _row_swapped_zero_state(): + # the generators Z0, Z1 in the other order, with the destabilizer rows + # X0, X1 swapped along with them so the tableau stays valid + swapped_table = CliffordTableau(2).table.copy() + swapped_table[[2, 3]] = swapped_table[[3, 2]] + swapped_table[[0, 1]] = swapped_table[[1, 0]] + return CliffordTableau(swapped_table) + + +def _noisy_hadamard_circuit(n_emitters, depolarizing_prob): + circ = CircuitDAG(n_emitter=n_emitters, n_photon=0, n_classical=0) + for i in range(n_emitters): + circ.add( + ops.Hadamard( + register=i, + reg_type="e", + noise=nm.DepolarizingNoise(depolarizing_prob=depolarizing_prob), + ) + ) + return circ + + +def _noisy_compiler(): + compiler = StabilizerCompiler() + compiler.noise_simulation = True + compiler.measurement_determinism = 1 + return compiler + + +def test_two_routes_to_the_same_state_reduce_to_one_branch(): + route_a, route_b = _bell_routes() + assert route_a != route_b # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, route_a), (0.5, route_b)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_stabilizer_phase_stay_separate(): + # |00> and |10>: the same generators up to sign are different states + zero_state = CliffordTableau(2) + one_state = CliffordTableau(2) + one_state.phase[one_state.n_qubits] = 1 + + mixed = MixedStabilizer([(0.5, zero_state), (0.5, one_state)]) + mixed.reduce() + + assert len(mixed.mixture) == 2 + + +def test_branches_differing_only_in_destabilizer_rows_merge(): + # stabilizers Z0, Z1 completed by the destabilizers X0Z1, X1Z0 instead of + # X0, X1: another valid tableau of |00> + same_state = CliffordTableau(2) + other_completion = CliffordTableau(2) + other_completion.destabilizer = np.array([[1, 0, 0, 1], [0, 1, 1, 0]]) + + assert same_state != other_completion # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, same_state), (0.5, other_completion)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_destabilizer_signs_merge(): + # what a Pauli error does to a branch whose state it leaves alone: Z on |0> + # flips the sign of the destabilizer X only + plain = CliffordTableau(1) + flipped = transform.z_gate(CliffordTableau(1), 0) + assert plain != flipped # the tableaux themselves differ + + mixed = MixedStabilizer([(0.75, plain), (0.25, flipped)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_branches_differing_only_in_generator_row_order_merge(): + reference = CliffordTableau(2) + reordered = _row_swapped_zero_state() + assert reference != reordered # the tableaux themselves differ + + mixed = MixedStabilizer([(0.5, reference), (0.5, reordered)]) + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, _ = mixed.mixture[0] + assert probability == 1.0 + + +def test_reduce_does_not_modify_any_input_tableau(): + # Bringing a branch to canonical form must not write back into it. The + # stabilizer half of route_b and of the row-swapped tableau is not already + # canonical, so a write-back would show on them; the signed copy of route_b + # also shows a write-back through its phase vector. + route_a, route_b = _bell_routes() + signed_b = transform.z_gate(route_b.copy(), 0) + inputs = [ + route_a, + route_b, + CliffordTableau(2), + _row_swapped_zero_state(), + signed_b, + ] + snapshots = [ + (np.copy(t.table), np.copy(t.phase), np.copy(t.iphase)) for t in inputs + ] + + mixed = MixedStabilizer([(0.2, t) for t in inputs]) + mixed.reduce() + + assert len(mixed.mixture) == 3 + for t, (table, phase, iphase) in zip(inputs, snapshots): + assert np.array_equal(t.table, table) + assert np.array_equal(t.phase, phase) + assert np.array_equal(t.iphase, iphase) + + +def test_reduce_preserves_total_probability_on_a_noisy_compile(monkeypatch): + # a real noise-simulation mixture: folding must not drop or double-count + # probability mass, and must leave the mixed state as it was + circ = _noisy_hadamard_circuit(3, 0.1) + + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) + unreduced = _noisy_compiler().compile(circ).rep_data + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) + reduced = _noisy_compiler().compile(circ).rep_data + assert isinstance(unreduced, MixedStabilizer) + assert isinstance(reduced, MixedStabilizer) + + total_unreduced = sum(p for p, _ in unreduced.mixture) + total_reduced = sum(p for p, _ in reduced.mixture) + assert np.isclose(total_unreduced, 1.0) + assert np.isclose(total_reduced, total_unreduced) + assert np.allclose(_density(reduced.mixture), _density(unreduced.mixture)) + + +def test_reduce_leaves_the_mixed_state_unchanged(monkeypatch): + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) + mixed = _noisy_compiler().compile(_noisy_hadamard_circuit(3, 0.1)).rep_data + n_before = len(mixed.mixture) + rho_before = _density(mixed.mixture) + + mixed.reduce() + + assert len(mixed.mixture) < n_before + assert np.isclose(sum(p for p, _ in mixed.mixture), 1.0) + assert np.allclose(_density(mixed.mixture), rho_before) + + +def test_noisy_compile_keeps_one_branch_per_distinct_state(): + # depolarizing noise after H on each of three emitters: 4^3 Kraus branches, + # but only the 2^3 states |+/->^3 + mixed = _noisy_compiler().compile(_noisy_hadamard_circuit(3, 0.1)).rep_data + + assert len(mixed.mixture) == 8 + assert np.isclose(sum(p for p, _ in mixed.mixture), 1.0) + + +def test_mixture_over_every_two_qubit_tableau_folds_to_the_sixty_states(): + # breadth-first over Clifford gates from |00>, keeping every tableau reached + # (many per state): folding must give exactly one branch per stabilizer state + gates = [(transform.hadamard_gate, (q,)) for q in range(2)] + gates += [(transform.phase_gate, (q,)) for q in range(2)] + gates += [(transform.x_gate, (q,)) for q in range(2)] + gates += [(transform.cnot_gate, (0, 1)), (transform.cnot_gate, (1, 0))] + seen, frontier, tableaux = set(), [CliffordTableau(2)], [] + while frontier: + next_frontier = [] + for t in frontier: + key = (t.table.tobytes(), t.phase.tobytes()) + if key in seen: + continue + seen.add(key) + tableaux.append(t) + next_frontier.extend(gate(t.copy(), *args) for gate, args in gates) + frontier = next_frontier + + mixed = MixedStabilizer([(1 / len(tableaux), t) for t in tableaux]) + rho = _density(mixed.mixture) + mixed.reduce() + + assert len(mixed.mixture) == 60 + assert np.allclose(_density(mixed.mixture), rho) diff --git a/tests/test_noise/test_mixed_stabilizers.py b/tests/test_noise/test_mixed_stabilizers.py index 40caea5e..884e95ad 100644 --- a/tests/test_noise/test_mixed_stabilizers.py +++ b/tests/test_noise/test_mixed_stabilizers.py @@ -12,9 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import copy + import numpy as np import pytest +import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler @@ -88,7 +91,7 @@ def test_mixed_state_compare_with_dm(n_emitters): @pytest.mark.parametrize("lam", [0.0, 0.1, 0.5]) -def test_mixed_state_tableaux_reduction(lam): +def test_mixed_state_tableaux_reduction(lam, monkeypatch): compiler = StabilizerCompiler() compiler.measurement_determinism = 1 n_emitters = 2 @@ -97,12 +100,14 @@ def test_mixed_state_tableaux_reduction(lam): compiler.noise_simulation = True - nm.REDUCE_STABILIZER_MIXTURE = False + # monkeypatch restores the module-global flag however the test ends; a + # leaked False would stop every later test's mixtures from being folded + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) state = compiler.compile(circ) assert isinstance(state.rep_data, MixedStabilizer) no_len_reduction = len(state.rep_data.mixture) - nm.REDUCE_STABILIZER_MIXTURE = True + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) state = compiler.compile(circ) assert isinstance(state.rep_data, MixedStabilizer) len_reduction = len(state.rep_data.mixture) @@ -112,14 +117,75 @@ def test_mixed_state_tableaux_reduction(lam): assert no_len_reduction > len_reduction -def test_mixed_state_measurement(): +def _x_p_values(state, qubit): + """ + The set of x_p values (see z_measurement_gate) a probe measurement of + `qubit` finds across every branch of `state`'s mixture. + + Probes on a deep copy of each branch's tableau, because the probabilistic + path of z_measurement_gate mutates its tableau argument (the deterministic + path does not, but a probe of a qubit that is still indefinite hits the + probabilistic path). + measurement_determinism=1 is ignored on the deterministic path, so it only + fixes the draw for a branch that is still probabilistic; it does not force + a deterministic branch's outcome. + """ + return { + sfc.z_measurement_gate(copy.deepcopy(t), qubit, measurement_determinism=1)[2] + for _, t in state.rep_data.mixture + } + + +def test_mixed_state_measurement(monkeypatch): compiler = StabilizerCompiler() compiler.measurement_determinism = "probabilistic" compiler.noise_simulation = True - n_emitters, n_photons, d_emitter, lam = 1, 1, 2, 0.2 + # d_emitter = 3 (odd): at an even depth the emitter is already a Z + # eigenstate before the measurement, so there is no indefinite-to-definite + # transition to observe and the `before` probe below cannot hold. Odd + # depth is what makes the before/after pair meaningful. + n_emitters, n_photons, d_emitter, lam = 1, 1, 3, 0.2 circ, circ_ideal = create_circuits(n_emitters, n_photons, d_emitter, lam) - circ.add(ops.MeasurementZ(register=0, reg_type="e")) - nm.REDUCE_STABILIZER_MIXTURE = False - state = compiler.compile(circ) + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", False) + + before = compiler.compile(circ) + + circ.add(ops.MeasurementZ(register=0, reg_type="e")) + after = compiler.compile(circ) + assert isinstance(after.rep_data, MixedStabilizer) + + # Photons are indexed before emitters (CompilerBase.reg_to_index_func), so + # with one photon and one emitter the emitter is qubit 1 and the photon + # is qubit 0. + emitter = n_photons + 0 + photon = 0 + + # Unfolded: the three depolarizing Hadamards on the emitter give + # 4**3 = 64 branches, both before and after the measurement. Anchored + # against a flag-on compile of the measured circuit, which folds down to + # 2, so this reads as "unfolded is 64 where folded is 2" rather than a + # bare pinned constant. The folded count is stable under "probabilistic" + # determinism: the 64 branches draw independently, so reaching a single + # branch would need all of them to agree. + assert len(before.rep_data.mixture) == 64 == len(after.rep_data.mixture) + monkeypatch.setattr(nm, "REDUCE_STABILIZER_MIXTURE", True) + folded = compiler.compile(circ) + assert len(folded.rep_data.mixture) == 2 + + # Probabilistic measurement of a mixture must neither renormalise nor + # drop branches. + assert np.isclose(after.rep_data.probability, 1.0) + + # The measurement did something: every branch's emitter goes from + # indefinite to definite. x_p is a stabilizer row index (see + # z_measurement_gate); 0 doubles as "deterministic", so a plain truth + # test is the documented contract, not the literal value. (Measured: + # {3} before, {0} after; photon stays {2} throughout.) + assert all(x != 0 for x in _x_p_values(before, emitter)) + assert all(x == 0 for x in _x_p_values(after, emitter)) + + # The photon is untouched -- guards against the measurement landing on + # the wrong qubit index. + assert all(x != 0 for x in _x_p_values(after, photon)) diff --git a/tests/test_noise/test_monte_carlo_noise.py b/tests/test_noise/test_monte_carlo_noise.py new file mode 100644 index 00000000..10d11609 --- /dev/null +++ b/tests/test_noise/test_monte_carlo_noise.py @@ -0,0 +1,126 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +How MonteCarloNoise draws the noise it attaches to each operation of a circuit: which sub-gate +of a wrapped operation each draw is paired with, a wrapped operation that carries a single model +for the whole wrapper, and the independent draws for the two legs of a controlled gate. +""" + +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.monte_carlo_noise import McNoiseMap, MonteCarloNoise + + +def test_noisy_gates_maps_a_wrapped_ops_sub_gates_in_constructor_order(): + # OneQubitGateWrapper pairs noise[i] with operations[i], in the order given to the + # constructor, so _noisy_gates/_find_wrapped_noise draw noise in that order too. + # Mapping the first two sub-gates at probability 1.0 avoids the residual NoNoise draw that + # would otherwise make the ordering assertion flaky; the third, unmapped sub-gate is what + # reaches the NoNoise arm for an op with no entry in the mapping. + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.SigmaX, ops.SigmaZ], register=0, reg_type="e" + ) + ) + + mc = MonteCarloNoise(circuit) + mc.n_noisy_gates = 0 + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("X"), 1.0)]) + mc_noise_model.add_gate_noise("e", "SigmaX", [(nm.PauliError("Z"), 1.0)]) + mc.mc_noise_model = mc_noise_model + + noisy_ops = mc._noisy_gates() + + assert len(noisy_ops) == 1 + noise = noisy_ops[0].noise + # constructor order is operations=[Hadamard, SigmaX, SigmaZ] + assert ( + isinstance(noise[0], nm.PauliError) + and noise[0].noise_parameters["Pauli error"] == "X" + ) + assert ( + isinstance(noise[1], nm.PauliError) + and noise[1].noise_parameters["Pauli error"] == "Z" + ) + assert isinstance(noise[2], nm.NoNoise) + + noise_by_gate = {type(gate).__name__: gate.noise for gate in noisy_ops[0].unwrap()} + assert noise_by_gate["Hadamard"].noise_parameters["Pauli error"] == "X" + assert noise_by_gate["SigmaX"].noise_parameters["Pauli error"] == "Z" + assert isinstance(noise_by_gate["SigmaZ"], nm.NoNoise) + assert mc.n_noisy_gates == 2 + + +def test_noisy_gates_leaves_a_whole_wrapper_model_in_place(): + # a wrapper carrying one model for the whole wrapper keeps it: no per-gate draw replaces + # it, and none is counted + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=0) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.SigmaX], + register=0, + reg_type="e", + noise=nm.PauliError("X"), + ) + ) + + mc = MonteCarloNoise(circuit) + mc.n_noisy_gates = 0 + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise("e", "Hadamard", [(nm.PauliError("Z"), 1.0)]) + mc.mc_noise_model = mc_noise_model + + noisy_ops = mc._noisy_gates() + + assert len(noisy_ops) == 1 + wrapper = noisy_ops[0] + assert isinstance(wrapper.noise, nm.PauliError) + assert wrapper.noise.noise_parameters["Pauli error"] == "X" + assert len(wrapper.unwrap()) == 3 + assert mc.n_noisy_gates == 0 + + +def test_noisy_gates_controlled_gate_legs_can_differ_across_seeds(): + # The controlled-gate noise-LIST arm is unreachable through the public API: the + # McNoiseMap.mapping setter, which add_gate_noise reaches through add_noise_tuple, + # requires the first element of every noise tuple to be a NoiseBase, so a list-valued + # entry is rejected before any draw happens. Only the per-leg + # redraw in the scalar-noise else arm is coverable -- control and target are drawn + # independently, so they can land on different noise objects. + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + mc_noise_model = McNoiseMap() + mc_noise_model.add_gate_noise( + "ee", "CNOT", [(nm.PauliError("X"), 0.5), (nm.PauliError("Z"), 0.5)] + ) + + def pauli_name(noise): + return noise.noise_parameters["Pauli error"] + + for seed in range(20): + mc = MonteCarloNoise(circuit, seed=seed) + mc.n_noisy_gates = 0 + mc.mc_noise_model = mc_noise_model + + control_noise, target_noise = mc._noisy_gates()[0].noise + if pauli_name(control_noise) != pauli_name(target_noise): + return + + pytest.fail("the two legs of a CNOT never differed across 20 seeds") diff --git a/tests/test_noise/test_noise_mapping.py b/tests/test_noise/test_noise_mapping.py new file mode 100644 index 00000000..8c212cf1 --- /dev/null +++ b/tests/test_noise/test_noise_mapping.py @@ -0,0 +1,113 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.noise.noise_mapping import noise_for_op, register_section + +# --- register_section --- + + +def test_register_section_of_a_pair_op_uses_control_and_target_types(): + op = ops.CNOT(control=0, control_type="e", target=1, target_type="e") + assert register_section(op) == "ee" + + op = ops.CNOT(control=0, control_type="e", target=1, target_type="p") + assert register_section(op) == "ep" + + +def test_register_section_of_a_classically_controlled_pair_op(): + op = ops.MeasurementCNOTandReset( + control=0, control_type="e", target=0, target_type="p", c_register=0 + ) + assert register_section(op) == "ep" + + +def test_register_section_of_a_one_qubit_op_uses_reg_type(): + op = ops.Hadamard(register=0, reg_type="p") + assert register_section(op) == "p" + + +# --- noise_for_op: pair ops (class and instance) --- + + +def test_noise_for_op_on_a_pair_class_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT_control": control_noise, "CNOT_target": target_noise} + + result = noise_for_op(ops.CNOT, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_instance_honours_per_leg_keys(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT_control": control_noise, "CNOT_target": target_noise} + op = ops.CNOT(control=0, target=1) + + result = noise_for_op(op, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_op_bare_key_reaches_both_legs(): + noise = nm.DepolarizingNoise(0.1) + section = {"CNOT": noise} + + result = noise_for_op(ops.CNOT, section) + + assert len(result) == 2 + assert result[0] is noise + assert result[1] is noise + + +def test_noise_for_op_on_a_pair_op_bare_key_with_a_two_element_list_passes_through(): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + section = {"CNOT": [control_noise, target_noise]} + + result = noise_for_op(ops.CNOT, section) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_noise_for_op_on_a_pair_op_bare_key_with_a_three_element_list_raises(): + section = {"CNOT": [nm.NoNoise(), nm.NoNoise(), nm.NoNoise()]} + + with pytest.raises(ValueError): + noise_for_op(ops.CNOT, section) + + +def test_noise_for_op_on_a_pair_op_explicit_no_noise_leg_key_takes_precedence_over_the_bare_key(): + bare_noise = nm.DepolarizingNoise(0.1) + section = {"CNOT": bare_noise, "CNOT_control": nm.NoNoise()} + + result = noise_for_op(ops.CNOT, section) + + assert isinstance(result[0], nm.NoNoise) + assert isinstance(result[1], nm.NoNoise) + + +def test_noise_for_op_on_a_one_qubit_op_reads_the_bare_key(): + noise = nm.DepolarizingNoise(0.1) + + assert noise_for_op(ops.Hadamard, {"Hadamard": noise}) is noise + assert isinstance(noise_for_op(ops.Hadamard, {}), nm.NoNoise) diff --git a/tests/test_noise/test_noise_models.py b/tests/test_noise/test_noise_models.py index cbdeb130..88610034 100644 --- a/tests/test_noise/test_noise_models.py +++ b/tests/test_noise/test_noise_models.py @@ -13,11 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.noise.noise_models as nm -from graphiq.backends.stabilizer.state import MixedStabilizer +from graphiq.backends.stabilizer.state import MixedStabilizer, Stabilizer from graphiq.state import QuantumState @@ -35,6 +36,17 @@ def _state_initialization_stabilizer(n_quantum): ) +def _two_branch_mixture(n_quantum): + return QuantumState( + [ + (0.25, sfc.create_n_ket0_state(n_quantum)), + (0.75, sfc.create_n_ket0_state(n_quantum)), + ], + rep_type="s", + mixed=True, + ) + + def test_depolarizing_noise(): n_quantum = 3 qubit_position = 1 @@ -145,3 +157,168 @@ def test_photon_loss_dm(): noise.apply(state, n_quantum, 1) assert state.rep_data.trace == 0.9 assert not state.rep_data.normalized + + +@pytest.mark.parametrize( + "state_factory", + [_state_initialization_stabilizer, _two_branch_mixture], + ids=["pure", "mixture"], +) +def test_two_qubit_replacement_refuses_the_stabilizer_backend(state_factory): + # MixedStabilizer is a sibling of Stabilizer, not a subclass, so a mixture + # must be named explicitly to get the same NotImplementedError as a pure + # tableau instead of the generic "Backend type is not supported." TypeError. + n_quantum = 2 + state = state_factory(n_quantum) + noise = nm.TwoQubitControlledGateReplacement(np.eye(2)) + + with pytest.raises(NotImplementedError): + noise.apply(state, n_quantum, 0, 1) + + +@pytest.mark.parametrize( + "state_factory", + [_state_initialization_stabilizer, _two_branch_mixture], + ids=["pure", "mixture"], +) +@pytest.mark.parametrize( + "noise", + [ + nm.OneQubitGateReplacement(np.eye(2)), + nm.LocalCliffordError(["sigmax"]), + nm.AmplitudeDampingNoise(0.1), + nm.MixedUnitaryError([np.eye(2)], [1.0]), + nm.CoherentUnitaryError(np.eye(2)), + nm.GeneralKrausError([np.eye(2)]), + nm.MeasurementError(np.eye(2)), + nm.ResetError(), + ], + ids=[ + "one_qubit_replacement", + "local_clifford", + "amplitude_damping", + "mixed_unitary", + "coherent_unitary", + "general_kraus", + "measurement", + "reset", + ], +) +def test_one_register_noise_refuses_the_stabilizer_backend(noise, state_factory): + # None of these is representable on the stabilizer backend; each must + # refuse a mixture the same way it refuses a pure tableau. + n_quantum = 2 + state = state_factory(n_quantum) + + with pytest.raises(NotImplementedError): + noise.apply(state, n_quantum, [0]) + + +@pytest.mark.parametrize("depolarizing_prob", [0.0, 0.2, 0.75]) +def test_depolarizing_noise_on_a_pure_stabilizer(depolarizing_prob): + # A pure Stabilizer (what Monte Carlo noise simulation compiles to) must be + # converted to a mixture before it can branch. The result should match the + # equivalent one-branch mixture, which reaches the same code with the + # conversion already done. + n_quantum = 2 + reg_list = [0] + pure = _state_initialization_stabilizer(n_quantum) + assert isinstance(pure.rep_data, Stabilizer) + one_branch = QuantumState( + [(1.0, sfc.create_n_ket0_state(n_quantum))], rep_type="s", mixed=True + ) + + noise = nm.DepolarizingNoise(depolarizing_prob) + noise.apply(pure, n_quantum, reg_list) + noise.apply(one_branch, n_quantum, reg_list) + + assert pure.mixed + assert isinstance(pure.rep_data, MixedStabilizer) + assert np.isclose(pure.rep_data.probability, 1.0) + got = pure.rep_data.mixture + expected = one_branch.rep_data.mixture + assert len(got) == len(expected) + for (p_got, t_got), (p_expected, t_expected) in zip(got, expected): + assert np.isclose(p_got, p_expected) + assert t_got == t_expected + if depolarizing_prob > 0: + assert len(got) > 1 + + +def test_general_kraus_error_single_qubit(): + # A bit flip with probability p on a one-qubit |0> state. + n_quantum = 1 + p = 0.3 + kraus_ops = [np.sqrt(1 - p) * np.eye(2), np.sqrt(p) * dmf.sigmax()] + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError(kraus_ops) + + noise.apply(state, n_quantum, [0]) + + assert np.allclose(state.rep_data.data, np.diag([1 - p, p])) + + +def test_general_kraus_error_applies_independently_to_every_register(): + # The same single-qubit channel on both qubits of |00> factorizes. + n_quantum = 2 + p = 0.3 + kraus_ops = [np.sqrt(1 - p) * np.eye(2), np.sqrt(p) * dmf.sigmax()] + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError(kraus_ops) + + noise.apply(state, n_quantum, [0, 1]) + + single_qubit_result = np.diag([1 - p, p]) + expected = np.kron(single_qubit_result, single_qubit_result) + assert np.allclose(state.rep_data.data, expected) + + +def test_general_kraus_error_refuses_operators_wider_than_one_qubit(): + n_quantum = 1 + state = _state_initialization_dm(n_quantum) + noise = nm.GeneralKrausError([np.eye(4)]) + + with pytest.raises(ValueError, match="single-qubit"): + noise.apply(state, n_quantum, [0]) + + +def test_reset_error_default_noise_parameters_are_not_shared_between_instances(): + a = nm.ResetError() + b = nm.ResetError() + + assert a.noise_parameters is not b.noise_parameters + + +@pytest.mark.parametrize("pauli", ["X", "Y", "Z", "I"]) +def test_pauli_error_on_a_mixed_stabilizer(pauli): + # A noisy stabilizer compile works on a MixedStabilizer; the Pauli must be + # applied to every branch, matching the pure-Stabilizer result per branch. + n_quantum = 2 + mixed = _two_branch_mixture(n_quantum) + pure = _state_initialization_stabilizer(n_quantum) + + nm.PauliError(pauli).apply(mixed, n_quantum, [0]) + nm.PauliError(pauli).apply(pure, n_quantum, [0]) + + assert [p_i for p_i, _ in mixed.rep_data.mixture] == [0.25, 0.75] + for _, tableau in mixed.rep_data.mixture: + assert tableau == pure.rep_data.tableau + + +def test_hadamard_perturbed_error_at_zero_perturbation_is_a_hadamard(): + noise = nm.HadamardPerturbedError(0, 0, 0) + + unitary = noise.noise_parameters["One-qubit unitary"] + + assert np.allclose(unitary, dmf.hadamard()) + assert noise.noise_parameters["Perturbation"] == (0, 0, 0) + assert noise.noise_parameters["Original parameters"] == (np.pi / 2, 0, np.pi) + + +def test_hadamard_perturbed_error_with_a_nonzero_perturbation_is_still_unitary(): + noise = nm.HadamardPerturbedError(0.1, 0.2, 0.3) + + unitary = noise.noise_parameters["One-qubit unitary"] + + assert np.allclose(unitary @ unitary.conj().T, np.eye(2)) + assert noise.noise_parameters["Perturbation"] == (0.1, 0.2, 0.3) diff --git a/tests/test_noise/test_noise_models_unsupported_backends.py b/tests/test_noise/test_noise_models_unsupported_backends.py new file mode 100644 index 00000000..ead068cf --- /dev/null +++ b/tests/test_noise/test_noise_models_unsupported_backends.py @@ -0,0 +1,146 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import networkx as nx +import numpy as np +import pytest + +import graphiq.noise.noise_models as nm +from graphiq.backends.graph.state import MixedGraph +from graphiq.state import QuantumState + + +def _state_initialization_graph(): + """ + A graph-backed QuantumState on a 3-node path graph. + + :return: the state + :rtype: QuantumState + """ + return QuantumState(nx.Graph([(1, 2), (2, 3)]), rep_type="g") + + +def _state_with_mixed_graph_rep(): + """ + A QuantumState whose backing representation is a MixedGraph. + + The constructor rejects a MixedGraph with TypeError, and the rep_data + setter re-initialises the representation, where Graph(data) raises + TypeError on a MixedGraph. A MixedGraph rep_data can be reached publicly + only by converting a mixed stabilizer state with + convert_representation("g"); assigning _rep_data directly gives a + MixedGraph rep_data without that detour. + + :return: the state + :rtype: QuantumState + """ + state = _state_initialization_graph() + state._rep_data = MixedGraph(state.rep_data) + return state + + +def _state_with_opaque_rep(): + """ + A QuantumState whose backing representation is a plain object(), which no + noise model's isinstance dispatch recognizes. QuantumState.validate_data + rejects object() the same way it rejects MixedGraph, so _rep_data is + assigned directly here too. + + :return: the state + :rtype: QuantumState + """ + state = _state_initialization_graph() + state._rep_data = object() + return state + + +# Every row shares the same simplest-valid-argument construction per model: +# AmplitudeDampingNoise and DepolarizingNoise take a probability, +# GeneralKrausError takes a list of Kraus operators, PauliError takes a Pauli +# label, ResetError takes no required argument, and OneQubitGateReplacement +# takes a 2x2 unitary. +# The OneQubitGateReplacement row carries a message match because its Graph +# arm raises the same TypeError as the fall-through else arm; without the +# match, deleting the Graph arm would leave the row green. +_GRAPH_ROWS = [ + (nm.AmplitudeDampingNoise(0.1), NotImplementedError, None), + (nm.ResetError(), NotImplementedError, None), + (nm.GeneralKrausError([np.eye(2)]), NotImplementedError, None), + (nm.PauliError("X"), NotImplementedError, None), + (nm.DepolarizingNoise(0.1), NotImplementedError, None), + ( + nm.OneQubitGateReplacement(np.eye(2)), + TypeError, + "does not support Graph backend", + ), +] + +# Every model falls through to the same generic "Backend type ... is not +# supported" TypeError once state.rep_data is neither a recognized backend +# class nor Graph, regardless of which of the six models is asked. +_FALLTHROUGH_MODELS = [ + nm.AmplitudeDampingNoise(0.1), + nm.ResetError(), + nm.GeneralKrausError([np.eye(2)]), + nm.PauliError("X"), + nm.DepolarizingNoise(0.1), + nm.OneQubitGateReplacement(np.eye(2)), +] + + +@pytest.mark.parametrize( + "noise_model, state, expected_exception, match", + [ + (noise_model, _state_initialization_graph(), expected_exception, match) + for noise_model, expected_exception, match in _GRAPH_ROWS + ] + + [ + (noise_model, _state_with_mixed_graph_rep(), TypeError, "not supported") + for noise_model in _FALLTHROUGH_MODELS + ] + + [ + (noise_model, _state_with_opaque_rep(), TypeError, "not supported") + for noise_model in _FALLTHROUGH_MODELS + ], +) +def test_apply_on_unsupported_backend_raises( + noise_model, state, expected_exception, match +): + """ + Every noise model's apply() dispatches on the type of state.rep_data + through an isinstance chain. A Graph representation is named explicitly in + each model but rejected as not yet implemented (NotImplementedError), + except OneQubitGateReplacement, whose Graph arm raises TypeError instead + (an arbitrary one-qubit unitary is not expressible in the Clifford + vocabulary the stabilizer backend understands, but the Graph branch + reports it as an unsupported backend rather than an unimplemented one). A + MixedGraph, or a plain object() that matches nothing, falls through every + model's isinstance chain to its final else branch, which always raises + TypeError. + + The NotImplementedError Graph rows assert no message: theirs read "not + implemented for graph representation", which does not contain "not + supported", and the exception type alone already separates them from the + fall-through. The OneQubitGateReplacement Graph row matches on its own + message because its type does not. The MixedGraph and object() rows assert on "not supported" + since every model's fall-through branch uses that phrase, one of them with + the backend's type interpolated into it. + + n_quantum is fixed at 3, matching the 3-node graph every row's state + starts from: no model reads n_quantum before raising, since the dispatch + is on the type of state.rep_data, and state.n_qubits is unavailable on + the object() rows below. + """ + with pytest.raises(expected_exception, match=match): + noise_model.apply(state, 3, [0]) diff --git a/tests/test_noise/test_noise_placement.py b/tests/test_noise/test_noise_placement.py new file mode 100644 index 00000000..6d497063 --- /dev/null +++ b/tests/test_noise/test_noise_placement.py @@ -0,0 +1,281 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Placement of `AdditionNoiseBase` noise around a controlled gate. + +`CompilerBase.compile` decides, per leg of a controlled pair, whether that +leg's noise is applied before or after the gate, from its +`noise_parameters["After gate"]` flag. The two legs may disagree, and each +leg must land on its own side regardless of what the other one does. + +These tests drive `compile()` end to end and compare against the same +circuit written out with explicit Pauli gates in the expected positions. +The probe noise is `PauliError`, which is a deterministic Clifford on every +backend (a coherent gate error, not a stochastic channel), so each expected +state is exact rather than statistical. +""" + +import numpy as np +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.circuit.circuit_dag import CircuitDAG + + +def _before_gate(noise): + """Flip an AdditionNoiseBase to pre-gate placement. + + No noise model exposes this through its constructor -- each builds its own + `noise_parameters` dict and `AdditionNoiseBase.__init__` then stamps + `"After gate": True` onto it -- so a mixed-position circuit can only be + built by mutating the flag afterwards. + """ + noise.noise_parameters["After gate"] = False + return noise + + +def _noisy_cnot_circuit(control_noise, target_noise): + """H(0), then a CNOT(0, 1) carrying one noise model per leg. + + Two things about the probe are load-bearing; a weaker probe passes even + when the noise is placed on the wrong side of the gate: + + The Hadamard leaves qubit 0 in an X eigenstate, so the Pauli errors do not + act as a global phase on a Z eigenstate -- which a density-matrix comparison + could not see. + + The callers pair an X error on the *control* with a Z error on the + *target*, because those are the two that anticommute across a CNOT. An X on + the target, or a Z on the control, commutes with the gate, so moving that + leg from one side to the other would leave the final state unchanged and + the test could not tell a correct placement from a wrong one. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + cnot = ops.CNOT( + control=0, + control_type="e", + target=1, + target_type="e", + noise=[control_noise, target_noise], + ) + # `ControlledPairOperationBase.__init__` turns a *scalar* noise argument + # into `2 * [noise]`, i.e. the same object in both slots -- mutating + # "After gate" on that would flip both legs at once and silently test a + # non-mixed case. Passing a list of two distinct instances avoids it. + assert cnot.noise[0] is not cnot.noise[1] + circuit.add(cnot) + return circuit, cnot + + +def _explicit_circuit(pre_gates, post_gates): + """The same H(0) and CNOT(0, 1), with explicit noiseless Pauli gates. + + `pre_gates`/`post_gates` are (operation class, register) pairs placed + before and after the CNOT. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + for gate, register in pre_gates: + circuit.add(gate(register=register, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=1, + target_type="e", + noise=[nm.NoNoise(), nm.NoNoise()], + ) + ) + for gate, register in post_gates: + circuit.add(gate(register=register, reg_type="e", noise=nm.NoNoise())) + return circuit + + +def _compile_dm(circuit): + compiler = DensityMatrixCompiler() + compiler.noise_simulation = True + return compiler.compile(circuit).rep_data.data + + + +# -------------------------------------------------------------------------- +# The two positions that agree. These pin the existing behaviour across any +# restructuring of the placement code. +# -------------------------------------------------------------------------- + + +def test_both_legs_after_gate(): + circuit, _ = _noisy_cnot_circuit(nm.PauliError("X"), nm.PauliError("Z")) + expected = _explicit_circuit([], [(ops.SigmaX, 0), (ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_neither_leg_after_gate(): + circuit, _ = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), _before_gate(nm.PauliError("Z")) + ) + expected = _explicit_circuit([(ops.SigmaX, 0), (ops.SigmaZ, 1)], []) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +# -------------------------------------------------------------------------- +# The two positions that disagree. +# -------------------------------------------------------------------------- + + +def test_control_after_target_before(): + circuit, _ = _noisy_cnot_circuit( + nm.PauliError("X"), _before_gate(nm.PauliError("Z")) + ) + expected = _explicit_circuit([(ops.SigmaZ, 1)], [(ops.SigmaX, 0)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_control_before_target_after(): + circuit, _ = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_mixed_position_differs_from_both_after(): + """A mixed placement must not coincide with the all-after placement. + + Guards the two tests above against passing for the wrong reason: if the + chosen Paulis commuted through the CNOT, moving one leg across the gate + would be unobservable and a dropped leg could look correct. + """ + mixed, _ = _noisy_cnot_circuit(nm.PauliError("X"), _before_gate(nm.PauliError("Z"))) + both_after, _ = _noisy_cnot_circuit(nm.PauliError("X"), nm.PauliError("Z")) + assert not np.allclose(_compile_dm(mixed), _compile_dm(both_after)) + + + +# -------------------------------------------------------------------------- +# `compile()` rebinds `op.noise` while placing the legs. It must put the +# caller's own list back, on every exit path -- the circuit belongs to the +# caller and is routinely compiled more than once. +# -------------------------------------------------------------------------- + + +def test_op_noise_restored_after_compile(): + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + _compile_dm(circuit) + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + +def test_op_noise_restored_when_gate_compilation_raises(monkeypatch): + """A compile that raises must leave the circuit usable. + + The raise comes from the first operation, before the CNOT's noise is split, so + this pins that the CNOT's noise list is untouched and that the circuit + compiles correctly afterwards. It does not reach the unwind around the + split; test_op_noise_restored_when_noise_application_raises does. + """ + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + + def _boom(self, state, op, n_quantum, q_index, classical_registers): + raise RuntimeError("gate compilation failed") + + monkeypatch.setattr(DensityMatrixCompiler, "compile_one_gate", _boom) + with pytest.raises(RuntimeError, match="gate compilation failed"): + _compile_dm(circuit) + + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + # The real symptom: the next compile of this circuit must still work. + monkeypatch.undo() + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +@pytest.mark.parametrize("failing_call", [1, 2]) +def test_op_noise_restored_when_noise_application_raises(monkeypatch, failing_call): + """A raise while a half of the split noise is bound must restore the gate's noise. + + The CNOT's noise is split into a pre-gate and a post-gate half, and `op.noise` is + bound to each in turn. Call 1 is the pre-gate half, call 2 the post-gate half; the + other ops carry no noise and never reach `_apply_additional_noise`. + """ + circuit, cnot = _noisy_cnot_circuit( + _before_gate(nm.PauliError("X")), nm.PauliError("Z") + ) + original = cnot.noise + original_legs = list(cnot.noise) + apply_noise = DensityMatrixCompiler._apply_additional_noise + bound = [] + + def _fail_on_call(self, state, op, n_quantum, q_index): + assert op is cnot + bound.append([not isinstance(leg, nm.NoNoise) for leg in op.noise]) + if len(bound) == failing_call: + raise RuntimeError("noise application failed") + return apply_noise(self, state, op, n_quantum, q_index) + + monkeypatch.setattr(DensityMatrixCompiler, "_apply_additional_noise", _fail_on_call) + with pytest.raises(RuntimeError, match="noise application failed"): + _compile_dm(circuit) + + # Which leg carries real noise in each bound half: the X leg before, the Z leg after. + assert bound == [[True, False], [False, True]][:failing_call] + assert cnot.noise is original + assert list(cnot.noise) == original_legs + + monkeypatch.undo() + expected = _explicit_circuit([(ops.SigmaX, 0)], [(ops.SigmaZ, 1)]) + assert np.allclose(_compile_dm(circuit), _compile_dm(expected)) + + +def test_non_addition_noise_on_a_leg_is_rejected(): + """Both legs must be `AdditionNoiseBase`; differing *positions* are fine.""" + replacement = nm.OneQubitGateReplacement(np.eye(2)) + circuit, _ = _noisy_cnot_circuit(replacement, nm.PauliError("X")) + with pytest.raises(ValueError, match="AdditionNoiseBase"): + _compile_dm(circuit) + + +@pytest.mark.parametrize("compiler_class", [DensityMatrixCompiler, StabilizerCompiler]) +def test_replacement_noise_on_measurement_z_is_refused(compiler_class): + """No measurement error model is implemented for replacement noise, so the + compiler must refuse it rather than drop the noise (or the measurement).""" + circuit = CircuitDAG(n_emitter=1, n_photon=0, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e", noise=nm.NoNoise())) + circuit.add( + ops.MeasurementZ( + register=0, + reg_type="e", + c_register=0, + noise=nm.OneQubitGateReplacement(np.eye(2)), + ) + ) + compiler = compiler_class() + compiler.noise_simulation = True + with pytest.raises(NotImplementedError, match="MeasurementZ"): + compiler.compile(circuit) diff --git a/tests/test_noise/test_solver_with_noise.py b/tests/test_noise/test_solver_with_noise.py index 13257bb5..82f86e00 100644 --- a/tests/test_noise/test_solver_with_noise.py +++ b/tests/test_noise/test_solver_with_noise.py @@ -21,6 +21,7 @@ linear_cluster_4qubit_circuit, ghz3_state_circuit, ) +import graphiq.circuit.ops as ops import graphiq.noise.noise_models as nm from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler import graphiq.backends.density_matrix.functions as dmf @@ -325,7 +326,7 @@ def linear3_noise_model_2(): noise_model_mapping["ee"] = {"CNOT": nm.DepolarizingNoise(dp)} noise_model_mapping["ep"] = { "CNOT": nm.DepolarizingNoise(dp), - "MeasurementCNOTandRest": nm.DepolarizingNoise(dp), + "MeasurementCNOTandReset": nm.DepolarizingNoise(dp), } noise_model_mapping["p"] = { "Identity": nm.DepolarizingNoise(dp), @@ -354,7 +355,7 @@ def linear4_noise_model(): noise_model_mapping["ee"] = {"CNOT": nm.DepolarizingNoise(dp)} noise_model_mapping["ep"] = { "CNOT": nm.DepolarizingNoise(dp), - "MeasurementCNOTandRest": nm.DepolarizingNoise(dp), + "MeasurementCNOTandReset": nm.DepolarizingNoise(dp), } noise_model_mapping["p"] = { "Identity": nm.DepolarizingNoise(dp), @@ -403,3 +404,41 @@ def ghz3_noise_model(): @visualization def test_solver_ghz3_visualized(ghz3_run_no_noise, ghz3_run_noise, ghz3_expected): check_run_noise_visual(ghz3_run_no_noise, ghz3_run_noise, ghz3_expected) + + +@pytest.mark.parametrize( + "noise_model_fixture_name", + [ + "linear3_noise_model", + "linear3_noise_model_2", + "linear4_noise_model", + "ghz3_noise_model", + ], +) +def test_noise_model_fixture_keys_name_real_operations( + noise_model_fixture_name, request +): + """Every key in a noise-model fixture (with an optional ``_control``/``_target`` + suffix stripped) must name a real class in ``graphiq.circuit.ops``, or be the + literal ``"OneQubitGateWrapper"`` that ``SolverBase._wrap_noise`` reads. A + misspelled key silently matches nothing and the operation it was meant to name + keeps its default of no noise. + """ + noise_model_mapping = request.getfixturevalue(noise_model_fixture_name) + op_names = { + name + for name, obj in vars(ops).items() + if isinstance(obj, type) and issubclass(obj, ops.OperationBase) + } + + for register_type, mapping in noise_model_mapping.items(): + for key in mapping: + base_key = key + for suffix in ("_control", "_target"): + if base_key.endswith(suffix): + base_key = base_key[: -len(suffix)] + break + assert base_key == "OneQubitGateWrapper" or base_key in op_names, ( + f"{noise_model_fixture_name}[{register_type!r}] has key {key!r}, " + f"which names no class in graphiq.circuit.ops" + ) diff --git a/tests/test_noise/test_time_depend_noise.py b/tests/test_noise/test_time_depend_noise.py index 43b52f4d..38c1a774 100644 --- a/tests/test_noise/test_time_depend_noise.py +++ b/tests/test_noise/test_time_depend_noise.py @@ -94,3 +94,69 @@ def test_parameters(error_rates, criteria): # the number of branches in each circuit tree should increase as cut off increases len_list = [len(x) for x in circ_tree_list] assert all(len_list[i] <= len_list[i + 1] for i in range(len(len_list) - 1)) + + +def test_noise_type_list_reg_specific_str_value(): + # a two-character string tells apart wrapping the whole string as one + # element ([noise_type]) from splitting it into characters (list(noise_type)) + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(0, "e")], + noise_parameters={"reg_specific_noise": {"e0": "xy"}}, + ) + assert noisy_ensemble.noise_type_list((0, "e")) == ["xy"] + + +def test_noise_type_list_reg_specific_list_value(): + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(0, "e")], + noise_parameters={"reg_specific_noise": {"e0": ["y", "z"]}}, + ) + assert noisy_ensemble.noise_type_list((0, "e")) == ["y", "z"] + + +def test_noise_type_list_falls_back_to_default_noise_type_str_value(): + # register "e1" has no entry in reg_specific_noise, so this reaches the + # fallback branch that reads noise_parameters["noise_type"] instead + noisy_ensemble = tdn.NoisyEnsemble( + None, + noisy_regs=[(1, "e")], + # two characters, so a wrapped string and a split string differ + noise_parameters={"noise_type": "xz"}, + ) + assert noisy_ensemble.noise_type_list((1, "e")) == ["xz"] + + +@pytest.mark.parametrize("criteria", ["reg_as_ctrl", "measurement", "all gates", ""]) +def test_section_finder_rejects_an_unknown_criteria_string(criteria): + circ, _ = linear_cluster_4qubit_circuit() + path = tdn.NoisyEnsemble.reg_history(circ, 0, "e") + with pytest.raises(ValueError, match="criteria should be"): + tdn.section_finder(circ, path, criteria) + + +def test_noisy_ensemble_with_an_unknown_criteria_string_raises(): + circ, _ = linear_cluster_4qubit_circuit() + noisy = tdn.NoisyEnsemble( + circ, [(0, "e")], noise_parameters={"criteria": "reg_as_ctrl"} + ) + with pytest.raises(ValueError, match="criteria should be"): + noisy.all_branches() + + +@pytest.mark.parametrize( + "criteria, section_lengths", + [ + ("measure", [10, 1]), + ("reg_as_control", [3, 2, 2, 1, 2, 1]), + ("all_gates", [1] * 11), + ], +) +def test_section_finder_keeps_the_sections_of_a_valid_criteria( + criteria, section_lengths +): + circ, _ = linear_cluster_4qubit_circuit() + path = tdn.NoisyEnsemble.reg_history(circ, 0, "e") + sections = tdn.section_finder(circ, path, criteria) + assert [len(s) for s in sections] == section_lengths diff --git a/tests/test_solvers/test_alternate_target_solver.py b/tests/test_solvers/test_alternate_target_solver.py index a4ecd45f..9079dddc 100644 --- a/tests/test_solvers/test_alternate_target_solver.py +++ b/tests/test_solvers/test_alternate_target_solver.py @@ -14,8 +14,15 @@ # limitations under the License. import pytest as pytest +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.solvers.alternate_target_solver import * +from graphiq.utils.relabel_module import ( + _label_finder, + _partial_orbit, + automorph_check, +) linear_cluster = nx.from_numpy_array(nx.to_numpy_array(linear_cluster_state(4).data)) @@ -101,3 +108,287 @@ def test_solver_density_noise(setting=my_setting(), target_graph=linear_cluster) solver.noise_simulation = True solver.seed = 1 solver.solve() + + +def _solve_with_label_map(label_map, n_iso_graphs): + setting = my_setting() + setting.n_iso_graphs = n_iso_graphs + setting.label_map = label_map + solver = AlternateTargetSolver( + target=linear_cluster, + solver_setting=setting, + noise_model_mapping="depolarizing", + ) + solver.metric = Infidelity + solver.compiler = StabilizerCompiler() + solver.noise_compiler = DensityMatrixCompiler() + solver.io = None + solver.noise_simulation = False + solver.seed = 1 + return solver.solve() + + +@pytest.mark.parametrize("n_iso_graphs", [3, 12]) +def test_label_map_setting_gives_the_same_result_as_without_it(n_iso_graphs): + # With label_map set, iso_finder returns (matrices, label maps). With seed 1, 3 + # graphs are found in its first batch of labellings and 12 need a longer search, + # so both of its return paths are covered. + setting = my_setting() + adj = nx.to_numpy_array(linear_cluster) + first_batch = automorph_check(adj, _label_finder(n_iso_graphs, len(adj), seed=1)) + assert (len(first_batch) >= n_iso_graphs) == (n_iso_graphs == 3) + found = iso_finder( + adj, + n_iso_graphs, + rel_inc_thresh=setting.rel_inc_thresh, + allow_exhaustive=setting.allow_exhaustive, + sort_emit=setting.sort_emitter, + label_map=True, + thresh=setting.iso_thresh, + seed=1, + ) + assert isinstance(found, tuple) + assert len(found[1]) == len(found[0]) + + with_map = _solve_with_label_map(True, n_iso_graphs) + without_map = _solve_with_label_map(False, n_iso_graphs) + + assert len(with_map) == len(without_map) == n_iso_graphs + for (circuit_a, props_a), (circuit_b, props_b) in zip(with_map, without_map): + assert nx.utils.graphs_equal(props_a["g"], props_b["g"]) + assert props_a["map"] == props_b["map"] + assert props_a["score"] == props_b["score"] + assert [type(op) for op in circuit_a.sequence()] == [ + type(op) for op in circuit_b.sequence() + ] + + +def test_setting_callback_func_is_per_instance(): + a = AlternateTargetSolverSetting() + b = AlternateTargetSolverSetting() + assert a.callback_func is not b.callback_func + assert a.callback_func == {} + + a.callback_func["x"] = 1 + assert b.callback_func == {} + + d = {} + setting = AlternateTargetSolverSetting(callback_func=d) + assert setting.callback_func is d + + +def test_default_setting_names_a_dispatchable_lc_method(): + # every other test sets lc_method by hand, so an undispatchable default + # went unnoticed + setting = AlternateTargetSolverSetting() + assert setting.lc_method is None + + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + solver.solve() + assert solver.result is not None + + +def test_allow_relabel_false_searches_only_the_given_labelling(): + setting = AlternateTargetSolverSetting(n_iso_graphs=3, n_lc_graphs=2) + setting.allow_relabel = False + setting.lc_method = None + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + results = solver.solve() + n = solver.target_graph.number_of_nodes() + for _, properties in results: + # get_relabel_map also carries a -1 "self" key, so identity is over range(n) + assert {i: properties["map"][i] for i in range(n)} == {i: i for i in range(n)} + + +def test_linear_lc_method_on_a_path_not_in_iteration_order(): + # nodes 0..5 inserted in order, path 2-0-4-1-5-3 + order = [2, 0, 4, 1, 5, 3] + target = nx.Graph() + target.add_nodes_from(range(6)) + target.add_edges_from(zip(order, order[1:])) + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=10) + setting.allow_relabel = False + setting.lc_method = "linear" + solver = AlternateTargetSolver(target=target, solver_setting=setting, seed=1) + results = solver.solve() + candidates = {frozenset(map(frozenset, r[1]["g"].edges())) for r in results} + assert len(candidates) == len(_partial_orbit(6)) + + +def test_allow_lc_false_keeps_the_isomorphic_graph_unchanged(): + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=4) + setting.allow_relabel = False + setting.allow_lc = False + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + results = solver.solve() + assert len(results) == 1 + # the candidate is rebuilt from an adjacency matrix, so it carries weight + # attributes the target does not; compare structure, not edge data + candidate = results[0][1]["g"] + assert sorted(candidate.nodes()) == sorted(solver.target_graph.nodes()) + assert sorted(map(sorted, candidate.edges())) == sorted( + map(sorted, solver.target_graph.edges()) + ) + + +def test_allow_lc_false_bypasses_lc_method_validation(): + setting = AlternateTargetSolverSetting(n_iso_graphs=1, n_lc_graphs=1) + setting.allow_relabel = False + setting.allow_lc = False + setting.lc_method = "no such search" + solver = AlternateTargetSolver( + target=nx.path_graph(4), solver_setting=setting, seed=1 + ) + solver.solve() + assert solver.result is not None + + +def test_mc_depol_builds_the_expected_noise_map(): + # solver_setting.monte_carlo = True, set before construction, is what makes __init__ set + # depolarizing_rate and build the Monte Carlo noise map via mc_depol() (rather than the + # density-matrix noise_model_mapping the other tests here use). + setting = my_setting() + setting.monte_carlo = True + solver = AlternateTargetSolver( + target=linear_cluster, + solver_setting=setting, + noise_model_mapping="depolarizing", + ) + + mapping = solver.mc_params["map"].mapping + assert set(mapping.keys()) == {"e", "p", "ee", "ep"} + assert mapping["p"] == {} + assert set(mapping["e"].keys()) == { + "Hadamard", + "Phase", + "PhaseDagger", + "SigmaX", + "SigmaY", + "SigmaZ", + } + assert set(mapping["ee"].keys()) == {"CNOT"} + assert set(mapping["ep"].keys()) == {"CNOT"} + + rate = solver.depolarizing_rate + assert rate == setting.depolarizing_rate + per_gate_rate = rate / 3 + gate_entries = [("e", name) for name in mapping["e"]] + [ + ("ee", "CNOT"), + ("ep", "CNOT"), + ] + for reg_type, gate_name in gate_entries: + entries = mapping[reg_type][gate_name] + assert len(entries) == 3 + assert {noise.noise_parameters["Pauli error"] for noise, _ in entries} == { + "X", + "Y", + "Z", + } + for _, prob in entries: + assert prob == pytest.approx(per_gate_rate) + + tuples = solver.mc_params["map"].get_gate_noise("e", "Hadamard") + assert len(tuples) == 4 + pauli_tuples, (no_noise, no_noise_prob) = tuples[:3], tuples[3] + assert {t[0].noise_parameters["Pauli error"] for t in pauli_tuples} == { + "X", + "Y", + "Z", + } + for _, prob in pauli_tuples: + assert prob == pytest.approx(per_gate_rate) + assert isinstance(no_noise, nm.NoNoise) + # The residual is built as 1 - 3 * (rate / 3), which differs from 1 - rate by one ulp + # for some rates, so the comparison is approximate rather than bit-exact. + assert no_noise_prob == pytest.approx(1 - rate) + + +# graph_to_circ input checks + +_p3_plus_k1 = nx.disjoint_union(nx.path_graph(3), nx.empty_graph(1)) + + +@pytest.mark.parametrize( + "graph", + [ + _p3_plus_k1, + nx.disjoint_union(nx.empty_graph(1), nx.path_graph(3)), + nx.to_numpy_array(_p3_plus_k1), + ], + ids=["isolate_last", "isolate_first", "adjacency_matrix"], +) +def test_graph_to_circ_rejects_a_graph_with_an_isolated_vertex(graph): + with pytest.raises(ValueError, match="isolated"): + graph_to_circ(graph) + + +@pytest.mark.parametrize("outcome", [0, 1]) +def test_graph_to_circ_solves_a_disconnected_graph_without_an_isolated_vertex(outcome): + graph = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + circuit = graph_to_circ(graph) + + target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") + # force every measurement to 0, then to 1, so each outcome is checked every run + compiler = StabilizerCompiler() + compiler.measurement_determinism = outcome + state = compiler.compile(circuit) + n_photon = graph.number_of_nodes() + state.partial_trace(list(range(n_photon)), [2] * circuit.n_quantum) + assert np.isclose(Infidelity(target).evaluate(state, circuit), 0.0) + + +def test_a_stabilizer_target_not_in_standard_form_is_accepted(): + # the star graph state (centre 0) as a circuit leaves it: generators XZZ, ZXI, IXX + tableau = run_circuit( + CliffordTableau(3), + [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)], + ) + + solver = AlternateTargetSolver(target=QuantumState(tableau, rep_type="s")) + + assert sorted(solver.target_graph.edges) == [(0, 1), (0, 2)] + + +def _noisy_scores(target): + """Scores of a seeded depolarizing solve (density-matrix noise compiler, two candidates).""" + setting = AlternateTargetSolverSetting(n_iso_graphs=2, n_lc_graphs=1) + solver = AlternateTargetSolver( + target=target, + solver_setting=setting, + noise_model_mapping="depolarizing", + seed=1, + ) + return [properties["score"] for _, properties in solver.solve()] + + +@pytest.mark.parametrize( + "target", + [ + linear_cluster_state(3).data, + QuantumState(linear_cluster_state(3).data, rep_type="g"), + nx.relabel_nodes(nx.path_graph(3), {0: "a", 1: "b", 2: "c"}), + ], + ids=["nodes 1..3", "graph QuantumState", "string nodes"], +) +def test_noisy_solve_accepts_a_target_not_labelled_0_to_n_minus_1(target): + assert _noisy_scores(target) == pytest.approx(_noisy_scores(nx.path_graph(3))) + + +def test_noisy_solve_scores_a_target_inserted_out_of_label_order_as_its_relabelling(): + # the path 2-0-1 with its nodes inserted as [2, 0, 1]; solve reads the target in + # insertion order, so it must score as the same graph relabelled 0..n-1 in that order + out_of_order = nx.Graph([(2, 0), (0, 1)]) + in_order = nx.convert_node_labels_to_integers(out_of_order) + assert _noisy_scores(out_of_order) == pytest.approx(_noisy_scores(in_order)) + + +def test_noisy_solve_keeps_the_score_of_a_target_labelled_in_order(): + assert _noisy_scores(nx.path_graph(3)) == pytest.approx( + [0.0766238732123441, 0.08587445241234315] + ) diff --git a/tests/test_solvers/test_deterministic_solver.py b/tests/test_solvers/test_deterministic_solver.py index 765bb6d6..0295e37d 100644 --- a/tests/test_solvers/test_deterministic_solver.py +++ b/tests/test_solvers/test_deterministic_solver.py @@ -20,6 +20,7 @@ from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.density_matrix.functions import fidelity from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.tableau import StabilizerTableau from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) @@ -28,6 +29,7 @@ from graphiq.solvers.time_reversed_solver import TimeReversedSolver from graphiq.state import QuantumState from graphiq.benchmarks.circuits import linear_cluster_4qubit_circuit +from graphiq.circuit import ops def test_linear4(): @@ -152,3 +154,61 @@ def test_random_graph_states(n_nodes): final_state.partial_trace([*range(n_nodes)], n_total * [2]) fid = fidelity(target_state, np.round(final_state.rep_data.data, 8)) assert fid > 0.99 + + +def _one_qubit_tableau(pauli): + x = np.array([[1 if pauli in "XY" else 0]]) + z = np.array([[1 if pauli in "YZ" else 0]]) + return StabilizerTableau([x, z], np.array([0])) + + +def _cell(tableau): + return {(1, 0): "X", (1, 1): "Y", (0, 1): "Z", (0, 0): "I"}[ + (tableau.x_matrix[0, 0], tableau.z_matrix[0, 0]) + ] + + +@pytest.mark.parametrize( + "current, result, expected_gates, expected_final", + [ + ("X", "x", [], "X"), + ("X", "y", [ops.SigmaZ, ops.Phase], "Y"), + ("X", "z", [ops.Hadamard], "Z"), + ("Y", "x", [ops.Phase], "X"), + ("Y", "y", [], "Y"), + ("Y", "z", [ops.Phase, ops.Hadamard], "Z"), + ("Z", "x", [ops.Hadamard], "X"), + ("Z", "y", [ops.Hadamard, ops.SigmaZ, ops.Phase], "Y"), + ("Z", "z", [], "Z"), + ("X", "Z", [ops.Hadamard], "Z"), + ("Z", "X", [ops.Hadamard], "X"), + ], +) +def test_change_pauli_type_rotates_between_the_three_bases( + current, result, expected_gates, expected_final +): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau(current) + + gates = solver._change_pauli_type(tableau, 0, 0, result) + + assert gates == expected_gates + assert _cell(tableau) == expected_final + + +def test_change_pauli_type_leaves_an_identity_entry_alone(): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau("I") + + assert solver._change_pauli_type(tableau, 0, 0, "z") == [] + assert _cell(tableau) == "I" + + +@pytest.mark.parametrize("pauli", ["X", "I"]) +@pytest.mark.parametrize("result", ["q", "", "zz", "i"]) +def test_change_pauli_type_rejects_an_unrecognised_basis(result, pauli): + solver = TimeReversedSolver.__new__(TimeReversedSolver) + tableau = _one_qubit_tableau(pauli) + + with pytest.raises(ValueError, match=repr(result)): + solver._change_pauli_type(tableau, 0, 0, result) diff --git a/tests/test_solvers/test_evolutionary_random_solvers.py b/tests/test_solvers/test_evolutionary_random_solvers.py index 9ccb646e..5b8711b5 100644 --- a/tests/test_solvers/test_evolutionary_random_solvers.py +++ b/tests/test_solvers/test_evolutionary_random_solvers.py @@ -19,9 +19,20 @@ import graphiq.backends.stabilizer.functions.stabilizer as sfs from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.state import MixedStabilizer, Stabilizer +from graphiq.circuit.ops import ( + Hadamard, + Identity, + MeasurementCNOTandReset, + MeasurementZ, + OneQubitGateWrapper, +) from graphiq.io import IO from graphiq.metrics import Infidelity -from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) from graphiq.visualizers.density_matrix import density_matrix_bars from tests.test_flags import visualization from graphiq.benchmarks.circuits import ( @@ -89,6 +100,70 @@ def check_run(run_info, expected_info): assert state.rep_data == target_state.rep_data +def assert_robust_to_measurement_outcome( + circuit, expected_info, compiler_class, n_photon, n_emitter +): + """ + Assert that a winning circuit scores 0 infidelity against the target state when every + measurement is forced to outcome 0 and when every measurement is forced to outcome 1, not + only under the outcome the solver searched with. For a circuit with a single measurement, + as every winner certified in this module has, that covers every measurement branch; with + more than one measurement the mixed outcome patterns are not checked. + + The solver fixtures pin the compiler at measurement_determinism = 1, so every mid-circuit + measurement in the search, and in check_run, takes that one outcome. A circuit that is 0.0 + there but far from the target in the other branch is effectively post-selecting on a + measurement outcome. Compiling the circuit under both settings catches it, so a change of + solver seed cannot silently accept a branch-dependent winner. + + Deliberately not checked under "probabilistic": that draws an outcome at random, so a + post-selected circuit passes or fails by luck. Forcing both branches is stronger and + deterministic. + + :param circuit: the circuit to check, e.g. the winner of an evolutionary search + :type circuit: CircuitDAG + :param expected_info: (target_state, ideal_circuit, metric) tuple, as produced by the + ``*_expected`` fixtures in this module + :type expected_info: tuple + :param compiler_class: the compiler class to instantiate fresh for each determinism setting + (e.g. StabilizerCompiler or DensityMatrixCompiler) + :type compiler_class: type + :param n_photon: number of photonic qubits in the circuit + :type n_photon: int + :param n_emitter: number of emitter qubits in the circuit, traced out before scoring + :type n_emitter: int + :return: nothing + :rtype: None + """ + target_state, _, metric = expected_info + + # Without a measurement the two compiles are identical and this function guards nothing + # while still passing. + assert any( + isinstance(op, (MeasurementCNOTandReset, MeasurementZ)) + for op in circuit.sequence() + ), "circuit contains no measurement, so both determinism settings are the same run" + + for determinism in (0, 1): + compiler = compiler_class() + compiler.measurement_determinism = determinism + state = compiler.compile(circuit) + emitters = list(range(n_photon, n_photon + n_emitter)) + if isinstance(state.rep_data, (Stabilizer, MixedStabilizer)): + # a stabilizer trace measures the emitters; resolve it in the same branch + state.rep_data.trace_out_qubits( + emitters, measurement_determinism=determinism + ) + else: + # an exact density-matrix trace has no outcome to resolve + state.partial_trace(list(range(n_photon)), (n_photon + n_emitter) * [2]) + infidelity = metric.evaluate(state, circuit) + assert np.isclose(infidelity, 0.0), ( + f"circuit is not robust to measurement outcome: infidelity={infidelity} " + f"under measurement_determinism={determinism}" + ) + + def check_run_visual(run_info, expected_info): hof, state = run_info target_state, _, metric = expected_info @@ -158,12 +233,12 @@ def linear4_expected(): @pytest.fixture(scope="module") def ghz3_run(density_matrix_compiler, ghz3_expected): - return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 25) + return generate_run(3, 1, ghz3_expected, density_matrix_compiler, 0) @pytest.fixture(scope="module") def ghz3_run_stabilizer(stabilizer_compiler, ghz3_expected): - return generate_run(3, 1, ghz3_expected, stabilizer_compiler, 10) + return generate_run(3, 1, ghz3_expected, stabilizer_compiler, 0) @pytest.fixture(scope="module") @@ -177,10 +252,18 @@ def ghz3_expected(): def test_solver_linear3(linear3_run, linear3_expected): check_run(linear3_run, linear3_expected) + hof, _ = linear3_run + assert_robust_to_measurement_outcome( + hof[0][1], linear3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 + ) def test_solver_linear3_stabilizer(linear3_run_stabilizer, linear3_expected): check_run(linear3_run_stabilizer, linear3_expected) + hof, _ = linear3_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], linear3_expected, StabilizerCompiler, n_photon=3, n_emitter=1 + ) @visualization @@ -190,10 +273,18 @@ def test_solver_linear3_visualized(linear3_run, linear3_expected): def test_solver_linear4(linear4_run, linear4_expected): check_run(linear4_run, linear4_expected) + hof, _ = linear4_run + assert_robust_to_measurement_outcome( + hof[0][1], linear4_expected, DensityMatrixCompiler, n_photon=4, n_emitter=1 + ) def test_solver_linear4_stabilizer(linear4_run_stabilizer, linear4_expected): check_run(linear4_run_stabilizer, linear4_expected) + hof, _ = linear4_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], linear4_expected, StabilizerCompiler, n_photon=4, n_emitter=1 + ) @visualization @@ -203,12 +294,20 @@ def test_solver_linear4_visualized(linear4_run, linear4_expected): def test_solver_ghz3(ghz3_run, ghz3_expected, density_matrix_compiler): check_run(ghz3_run, ghz3_expected) + hof, _ = ghz3_run + assert_robust_to_measurement_outcome( + hof[0][1], ghz3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 + ) def test_solver_ghz3_stabilizer( ghz3_run_stabilizer, ghz3_expected, stabilizer_compiler ): check_run(ghz3_run_stabilizer, ghz3_expected) + hof, _ = ghz3_run_stabilizer + assert_robust_to_measurement_outcome( + hof[0][1], ghz3_expected, StabilizerCompiler, n_photon=3, n_emitter=1 + ) @visualization @@ -216,6 +315,36 @@ def test_solver_ghz3_visualized(ghz3_run, ghz3_expected): check_run_visual(ghz3_run, ghz3_expected) +def test_assert_robust_to_measurement_outcome_rejects_a_post_selected_winner( + density_matrix_compiler, ghz3_expected +): + """ + Seed 25 of the ghz3 density-matrix search ends on a winner that scores 0.0 in the branch the + compiler is pinned to (measurement_determinism = 1) but is wrong in the other one, so + check_run accepts it and assert_robust_to_measurement_outcome must not. + + The seed only selects such a winner under the current search path. If a solver change moves + that path, the precondition below fails; pick a new seed by searching for one whose winner + scores 0.0 with measurement_determinism = 1 and not with measurement_determinism = 0. + """ + hof, state = generate_run(3, 1, ghz3_expected, density_matrix_compiler, 25) + circuit = hof[0][1] + _, _, metric = ghz3_expected + branch0_compiler = DensityMatrixCompiler() + branch0_compiler.measurement_determinism = 0 + branch0_state = branch0_compiler.compile(circuit) + branch0_state.partial_trace(list(range(3)), 4 * [2]) + assert np.isclose(hof[0][0], 0.0) and not np.isclose( + metric.evaluate(branch0_state, circuit), 0.0 + ), "seed 25 no longer gives a one-branch winner; pick a new seed (see docstring)" + + check_run((hof, state), ghz3_expected) + with pytest.raises(AssertionError, match="not robust to measurement outcome"): + assert_robust_to_measurement_outcome( + hof[0][1], ghz3_expected, DensityMatrixCompiler, n_photon=3, n_emitter=1 + ) + + @pytest.mark.parametrize("seed", [0, 3, 325, 2949]) def test_add_remove_measurements(seed): n_emitter = 1 @@ -230,9 +359,9 @@ def test_add_remove_measurements(seed): compiler=compiler, n_emitter=n_emitter, n_photon=n_photon, + solver_setting=EvolutionarySolverSetting(n_stop=10), ) solver.seed(seed) - solver.n_stop = 10 original_trans_prob = solver.trans_probs solver.trans_probs = { @@ -267,12 +396,15 @@ def test_solver_logging(seed): n_emitter=n_emitter, n_photon=n_photon, io=io, + solver_setting=EvolutionarySolverSetting(n_stop=5), ) solver.seed(seed) - solver.n_stop = 5 solver.solve() solver.logs_to_df() + # one row per iteration in each log + assert list(solver.logs["population"]["iteration"]) == [*range(5)] + assert list(solver.logs["hof"]["iteration"]) == [*range(5)] def test_stabilizer_linear3(): @@ -316,3 +448,321 @@ def test_stabilizer_linear3(): print(f"the target stabilizer in the canonical form is {target_s_tableau}") assert output_s_tableau == target_s_tableau assert state.rep_data == target_state.rep_data + + assert_robust_to_measurement_outcome( + circuit, + (target_state, circuit_ideal, metric), + StabilizerCompiler, + n_photon=n_photon, + n_emitter=n_emitter, + ) + + +# Tests for EvolutionarySolver.adapt_probabilities and get_emission_assignment, which have no +# oracle beyond the source and are exercised here directly rather than through a full search. + + +def test_adapt_probabilities_moves_weight_from_additive_to_removal_transforms(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert ( + solver.trans_probs[solver.add_emitter_one_qubit_op] + < before[solver.add_emitter_one_qubit_op] + ) + assert ( + solver.trans_probs[solver.replace_photon_one_qubit_op] + < before[solver.replace_photon_one_qubit_op] + ) + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert solver.add_emitter_cnot not in solver.trans_probs + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_also_shrinks_the_cnot_key_with_multiple_emitters(): + solver = EvolutionarySolver(None, None, None, n_emitter=2) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert solver.trans_probs[solver.add_emitter_cnot] < before[solver.add_emitter_cnot] + + +def test_adapt_probabilities_adapts_only_the_allowed_transformations(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + {solver.add_emitter_one_qubit_op: 0.5, solver.remove_op: 0.5} + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + assert ( + solver.trans_probs[solver.add_emitter_one_qubit_op] + < before[solver.add_emitter_one_qubit_op] + ) + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_without_the_cnot_transformation(): + solver = EvolutionarySolver(None, None, None, n_emitter=2) + solver.set_allowed_transformations( + { + solver.add_emitter_one_qubit_op: 1 / 3, + solver.replace_photon_one_qubit_op: 1 / 3, + solver.remove_op: 1 / 3, + } + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + for key in (solver.add_emitter_one_qubit_op, solver.replace_photon_one_qubit_op): + assert solver.trans_probs[key] < before[key] + assert solver.trans_probs[solver.remove_op] > before[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_without_the_emitter_one_qubit_or_remove_transformations(): + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + {solver.replace_photon_one_qubit_op: 0.5, solver.add_emitter_cnot: 0.5} + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + assert set(solver.trans_probs) == set(before) + assert ( + solver.trans_probs[solver.replace_photon_one_qubit_op] + < before[solver.replace_photon_one_qubit_op] + ) + assert np.isclose(sum(solver.trans_probs.values()), 1) + + +def test_adapt_probabilities_leaves_the_cnot_key_on_a_single_emitter_solver(): + # only the normalisation moves the cnot entry on a one-emitter solver, so its ratio + # to remove_op is the one remove_op's own update gives + solver = EvolutionarySolver(None, None, None, n_emitter=1) + solver.set_allowed_transformations( + { + solver.add_emitter_one_qubit_op: 0.25, + solver.replace_photon_one_qubit_op: 0.25, + solver.remove_op: 0.25, + solver.add_emitter_cnot: 0.25, + } + ) + before = dict(solver.trans_probs) + + solver.adapt_probabilities() + + after = solver.trans_probs + new_remove = min(before[solver.remove_op] + 1 / solver.setting.n_stop, 0.99) + assert np.isclose( + after[solver.add_emitter_cnot] / after[solver.remove_op], + before[solver.add_emitter_cnot] / new_remove, + ) + + +def test_adapt_probabilities_stay_non_strict_monotone_over_many_calls(): + # The clamp to [0.01, 0.99] happens before normalisation, so it is not observable in the raw + # values -- normalised, every decreasing key plateaus at a fixed point (0.0099 for one + # emitter, with remove_op at 0.9802) rather than continuing to shrink, so strict + # monotonicity would eventually fail. Non-strict (<=, >=) holds throughout. + solver = EvolutionarySolver(None, None, None, n_emitter=1) + n_stop = solver.setting.n_stop + decreasing_keys = [ + solver.add_emitter_one_qubit_op, + solver.replace_photon_one_qubit_op, + ] + + previous = dict(solver.trans_probs) + for _ in range(3 * n_stop + 1): + solver.adapt_probabilities() + for key in decreasing_keys: + assert solver.trans_probs[key] <= previous[key] + assert solver.trans_probs[solver.remove_op] >= previous[solver.remove_op] + assert np.isclose(sum(solver.trans_probs.values()), 1) + previous = dict(solver.trans_probs) + + assert max(solver.trans_probs, key=solver.trans_probs.get) == solver.remove_op + + +@pytest.mark.parametrize("n_photon, n_emitter", [(4, 2), (6, 3), (3, 3)]) +def test_get_emission_assignment_invariants_hold_over_many_seeded_draws( + n_photon, n_emitter +): + # Keep (6, 3): it is the only case where the reuse assertion at the end is load-bearing. + # With the random-branch growth removed, (4, 2) still reuses emitter 1 by chance, while + # (6, 3) can only ever reach emitter 2 through the forced tail, exactly once. + # Do not add a case with n_photon < n_emitter here: every invariant below fails for it + # (measured for (2, 3) and (3, 5)), which is a separate question about input validation + # that this function does not perform. + np.random.seed(2026) + top_emitter_reused = False + for _ in range(2000): + assignment = EvolutionarySolver.get_emission_assignment(n_photon, n_emitter) + top_emitter_reused |= assignment.count(n_emitter - 1) > 1 + + assert len(assignment) == n_photon + assert assignment[0] == 0 + assert set(assignment) == set(range(n_emitter)) + + # Contiguous: a new emitter id, once it first appears, is always exactly one more than + # the largest id seen so far. This is also why an index into available_emitter can + # double as the emitter id it names: available_emitter is maintained as exactly + # list(range(len(available_emitter))) throughout the assignment loop. + largest_seen = 0 + for emitter_id in assignment: + assert emitter_id <= largest_seen + 1 + largest_seen = max(largest_seen, emitter_id) + + # The invariants above are all enforced by the forced tail branch alone. The random branch + # is what lets an emitter be reused after it first appears; without it the top emitter + # could only ever appear once, in the forced tail. (3, 3) has no slack, so skip it there. + if n_photon > n_emitter: + assert top_emitter_reused + + +# Tests for replace_photon_one_qubit_op on the photon gate that initialization places after each +# emission and labels "Fixed" so that remove_op leaves it in place. With one photon and one +# emitter it is the only photonic one-qubit gate, so the replace always picks it. + + +def _one_photon_circuit(): + solver = EvolutionarySolver(None, None, None, n_emitter=1, n_photon=1) + circuit = solver.initialization([0], [0]) + (node,) = circuit.get_node_by_labels(["OneQubitGateWrapper", "Photonic"]) + return solver, circuit, node + + +def test_replacing_the_fixed_photon_gate_keeps_it_fixed(): + solver, circuit, node = _one_photon_circuit() + + solver.replace_photon_one_qubit_op(circuit) + solver.remove_op(circuit, node) + + assert node in circuit.dag.nodes + assert "Fixed" in circuit.dag.nodes[node]["op"].labels + + +def test_replacing_an_unfixed_photon_gate_leaves_it_removable(): + solver, circuit, node = _one_photon_circuit() + # stands in for a gate a mutation added, which carries no "Fixed" label + circuit.replace_op( + node, OneQubitGateWrapper([Identity, Hadamard], register=0, reg_type="p") + ) + + solver.replace_photon_one_qubit_op(circuit) + + assert "Fixed" not in circuit.dag.nodes[node]["op"].labels + solver.remove_op(circuit, node) + assert node not in circuit.dag.nodes + + +def _saved_files(folder): + if not folder.exists(): + return set() + return {path.name for path in folder.iterdir()} + + +@pytest.mark.parametrize("save_openqasm", ["hof", "both"]) +def test_saving_the_hall_of_fame_skips_unfilled_slots(tmp_path, save_openqasm): + # two candidates per iteration fill 2, 4, then all 5 slots of the hall of fame; + # a slot no candidate has filled yet has no circuit to save + _, target = linear_cluster_3qubit_circuit() + setting = EvolutionarySolverSetting( + n_stop=3, n_pop=2, n_hof=5, save_openqasm=save_openqasm + ) + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=setting, + io=IO(path=tmp_path, verbose=False), + ) + EvolutionarySolver.seed(1) + + solver.solve() + + filled = {0: 2, 1: 4, 2: 5} + assert _saved_files(tmp_path / "hof") == { + f"iteration{k}_hof{i}.txt" for k, n in filled.items() for i in range(n) + } + expected_pop = ( + {f"iteration{k}_pop{j}.txt" for k in range(3) for j in range(2)} + if save_openqasm == "both" + else set() + ) + assert _saved_files(tmp_path / "pop") == expected_pop + + +def _solver_with_two_filled_hall_of_fame_slots(): + # n_pop = 2 < n_hof = 5: after one iteration, three slots are still (np.inf, None) + _, target = linear_cluster_3qubit_circuit() + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=EvolutionarySolverSetting(n_pop=2, n_hof=5), + ) + shallow, _ = linear_cluster_3qubit_circuit() # depth 7 + deep, _ = linear_cluster_4qubit_circuit() # depth 9 + population = [(0.25, shallow), (0.5, deep)] + solver.update_hof(population) + assert [c is None for _, c in solver.hof] == [False, False, True, True, True] + return solver, population + + +def test_update_logs_leaves_unfilled_hall_of_fame_slots_out_of_depth_statistics(): + solver, population = _solver_with_two_filled_hall_of_fame_slots() + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert (log["depth_mean"], log["depth_variance"]) == (8.0, 1.0) + assert (log["depth_min"], log["depth_max"]) == (7, 9) + + +def test_hall_of_fame_log_cost_statistics_leave_out_unfilled_slots(): + solver, population = _solver_with_two_filled_hall_of_fame_slots() + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert log["cost_mean"] == pytest.approx(0.375) + assert log["cost_variance"] == pytest.approx(0.015625) + assert (log["cost_min"], log["cost_max"]) == (0.25, 0.5) + + +def test_hall_of_fame_log_cost_statistics_keep_an_inf_scored_candidate(): + # a real candidate scored inf fills a slot, unlike an unfilled (np.inf, None) slot + _, target = linear_cluster_3qubit_circuit() + solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + solver_setting=EvolutionarySolverSetting(n_pop=2, n_hof=5), + ) + finite, _ = linear_cluster_3qubit_circuit() + infinite, _ = linear_cluster_3qubit_circuit() + population = [(0.25, finite), (np.inf, infinite)] + solver.update_hof(population) + assert [c is None for _, c in solver.hof] == [False, False, True, True, True] + + solver.update_logs(population=population, iteration=0) + + log = solver.logs["hof"][0] + assert log["cost_mean"] == np.inf + assert log["cost_max"] == np.inf diff --git a/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py b/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py new file mode 100644 index 00000000..f21337f9 --- /dev/null +++ b/tests/test_solvers/test_evolutionary_solver_seed_reproducibility.py @@ -0,0 +1,264 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +`SolverBase.seed` reseeds only `numpy.random` and the `random` module. +`_select_possible_cnot_position` and `_select_possible_measurement_position` used to build their +candidate list by iterating a Python `set` of edge tuples, whose iteration order depends on +`PYTHONHASHSEED`. Since CPython randomises that per process, the same numpy seed could select a +different edge in a different process, so a seeded search was not reproducible across processes. + +Two properties are pinned here. The candidate order must not depend on `PYTHONHASHSEED`, and the +helpers must not order-compare node ids at all: ids are a mix of `str` (register endpoints) and +`int` (interior ids minted by `CircuitDAG._unique_node_id`), so `sorted()` raises +`TypeError: '<' not supported between instances of 'str' and 'int'`. Any fixture used here must +therefore contain inserted gates -- an *empty* `CircuitDAG` has only string ids and is exactly the +case in which a `sorted()`-based fix appears to work. + +`PYTHONHASHSEED` is read once at interpreter startup, so this needs separate processes; it cannot +be exercised by forking or by reseeding in-process. Fingerprints use op class name, `register`, +`reg_type` and `q_registers` -- never `repr`/`str` of a circuit or op, which embeds a +`<... object at 0x...>` address that differs between processes regardless of any bug. +""" + +import os +import pathlib +import subprocess +import sys + +from graphiq.circuit import ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.evolutionary_solver import EvolutionarySolver + +# Several hash seeds, not two, because how many seeds it takes to expose the bug is +# fixture-dependent and not obvious from the fixture. Measured against the pre-fix helpers on +# the circuit `_CANDIDATE_ORDER_SCRIPT` builds, these five seeds produce five *distinct* +# candidate orders -- so any pair of them would do here. That is not something to rely on: on +# the smaller `_emitter_only_circuit` the same range collapses to two orders, and a two-seed +# comparison is then one coincidence away from passing against the live defect. +_HASH_SEEDS = (0, 1, 2, 3, 5) + +# Searches are the slow path (~10s per process), so they span three seeds rather than five. +_SEARCH_HASH_SEEDS = (0, 1, 2) + + +def _emitter_only_circuit(): + """ + Mixed `str`/`int` node ids on the emitter edges, which is what + `_select_possible_cnot_position` scans. Yields 4 candidate pairs. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + return circuit + + +def _emitter_and_photon_circuit(): + """ + Mixed ids on the photon edges too, so `_select_possible_measurement_position` returns a + non-empty list. On `_emitter_only_circuit` that helper returns [] -- its inner loop never + runs, so a `sorted()` call there would not raise and a test built on it would pass vacuously. + """ + circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) + circuit.add(ops.Hadamard(register=0, reg_type="e")) + circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) + circuit.add(ops.CNOT(control=1, control_type="e", target=1, target_type="p")) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + return circuit + + +def _run_with_hashseed(script, hashseed, timeout=180): + """ + Run `script` in a fresh subprocess under `PYTHONHASHSEED=hashseed`. + + A fork cannot express this: CPython reads `PYTHONHASHSEED` once at interpreter startup, so a + forked child keeps the parent's hash seed whatever the environment says afterwards. + """ + env = {**os.environ, "PYTHONHASHSEED": str(hashseed)} + # Run from the repo root so the child imports this checkout's graphiq even when it is not + # installed. Using os.getcwd() would make these tests pass only when pytest happens to be + # run from the root. + repo_root = pathlib.Path(__file__).resolve().parents[2] + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + cwd=repo_root, + capture_output=True, + text=True, + timeout=timeout, + ) + assert ( + result.returncode == 0 + ), f"subprocess with PYTHONHASHSEED={hashseed} failed:\n{result.stderr}" + return result.stdout.strip() + + +def test_node_ids_are_mixed_str_and_int(): + """ + The premise the other tests rest on. If node ids ever became uniformly typed this would fail, + and the `sorted()` formulation these tests exclude would become viable -- so the exclusion + should be revisited rather than silently kept. + """ + circuit = _emitter_and_photon_circuit() + + id_types = { + type(node).__name__ + for edge in circuit.edge_dict["e"] + circuit.edge_dict["p"] + for node in edge[:2] + } + + assert id_types == {"str", "int"} + + +def test_cnot_position_candidates_do_not_order_compare_node_ids(): + """ + `sorted()` on the set difference raises `TypeError` on this fixture. Asserting the exact + length matters: an empty result would let this pass without the ordering code ever running. + + Note what this does NOT guard: it passes against the pre-fix helper too, which returns the + same four pairs in a hash-dependent order. It pins that no *future* fix reintroduces an + order comparison between mixed-type node ids -- the `sorted()` formulation this file's + module docstring rules out -- not the set-iteration bug itself. The hash-seed tests below + are the regression guards for that. + """ + edge_pair = EvolutionarySolver._select_possible_cnot_position( + _emitter_only_circuit() + ) + + assert len(edge_pair) == 4 + + +def test_measurement_position_candidates_do_not_order_compare_node_ids(): + edge_pair = EvolutionarySolver._select_possible_measurement_position( + _emitter_and_photon_circuit() + ) + + assert len(edge_pair) == 7 + + +_CANDIDATE_ORDER_SCRIPT = """ +from graphiq.circuit import ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.solvers.evolutionary_solver import EvolutionarySolver + +circuit = CircuitDAG(n_emitter=2, n_photon=3, n_classical=1) +circuit.add(ops.Hadamard(register=0, reg_type="e")) +circuit.add(ops.CNOT(control=0, control_type="e", target=0, target_type="p")) +circuit.add(ops.CNOT(control=1, control_type="e", target=1, target_type="p")) +circuit.add(ops.Hadamard(register=0, reg_type="p")) +circuit.add(ops.CNOT(control=0, control_type="e", target=1, target_type="e")) + +print(repr(EvolutionarySolver._select_possible_cnot_position(circuit))) +print(repr(EvolutionarySolver._select_possible_measurement_position(circuit))) +""" + + +def test_candidate_order_is_hash_seed_independent(): + """ + The most direct pin of the defect: the helpers' own output on a fixed circuit, rather than a + downstream search symptom. Covers both helpers in one process per seed. + """ + orders = { + seed: _run_with_hashseed(_CANDIDATE_ORDER_SCRIPT, seed) for seed in _HASH_SEEDS + } + + assert len(set(orders.values())) == 1, ( + f"candidate order varies with PYTHONHASHSEED: {len(set(orders.values()))} " + f"distinct orders across seeds {list(orders)}" + ) + + +_SEARCH_SCRIPT = """ +import networkx as nx + +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.state import QuantumState + + +def fingerprint(op): + return ( + type(op).__name__, + getattr(op, "register", None), + getattr(op, "reg_type", None), + op.q_registers, + ) + + +graph = nx.Graph({graph_edges}) +target = QuantumState(get_clifford_tableau_from_graph(graph), rep_type="s") +compiler = StabilizerCompiler() +compiler.measurement_determinism = 1 + +solver = EvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=compiler, + n_emitter={n_emitter}, + n_photon={n_photon}, +) +solver.seed({seed}) +solver.solve() + +score, circuit = solver.hof[0] +print(repr((score, [fingerprint(op) for op in circuit.sequence(unwrapped=True)]))) +""" + + +def test_two_emitter_search_reproduces_across_hash_seeds(): + """ + At `n_emitter > 1` `add_emitter_cnot` is in `trans_probs`, so the search reaches + `_select_possible_cnot_position`. Both the score and the winning circuit must match across + hash seeds. + """ + script = _SEARCH_SCRIPT.format( + graph_edges="[(1, 2), (2, 3), (2, 4), (4, 3), (1, 3)]", + n_emitter=2, + n_photon=4, + seed=3, + ) + + winners = {seed: _run_with_hashseed(script, seed) for seed in _SEARCH_HASH_SEEDS} + + assert len(set(winners.values())) == 1, ( + f"n_emitter=2 search gave {len(set(winners.values()))} distinct (score, circuit) " + f"results across hash seeds {list(winners)} at one numpy seed" + ) + + +def test_one_emitter_search_is_hash_seed_independent(): + """ + At `n_emitter = 1` neither helper is reached -- `add_emitter_cnot` is gated on + `n_emitter > 1` and `add_measurement_cnot_and_reset` is not in the default `trans_probs` -- + so this was already reproducible and stays so. + + It compares hash seeds, so it would have passed before the fix as well; it is a regression + guard on the unaffected path. + """ + script = _SEARCH_SCRIPT.format( + graph_edges="[(1, 2), (2, 3)]", n_emitter=1, n_photon=3, seed=5 + ) + + winners = {seed: _run_with_hashseed(script, seed) for seed in _SEARCH_HASH_SEEDS} + + assert len(set(winners.values())) == 1, ( + f"n_emitter=1 search gave {len(set(winners.values()))} distinct results across hash " + f"seeds {list(winners)}; this path reaches neither changed helper, so it should not move" + ) diff --git a/tests/test_solvers/test_hybrid_solvers.py b/tests/test_solvers/test_hybrid_solvers.py index b1b385a0..44bc4f70 100644 --- a/tests/test_solvers/test_hybrid_solvers.py +++ b/tests/test_solvers/test_hybrid_solvers.py @@ -14,6 +14,7 @@ # limitations under the License. import numpy as np import networkx as nx +import pytest from graphiq.benchmarks.circuits import ( linear_cluster_4qubit_circuit, linear_cluster_3qubit_circuit, @@ -22,7 +23,9 @@ ) from graphiq.state import QuantumState from graphiq.metrics import Infidelity +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.state_rep_conversion import graph_to_density from graphiq.backends.stabilizer.functions.rep_conversion import ( get_clifford_tableau_from_graph, ) @@ -140,7 +143,14 @@ def test_alternate_circuits_w_noise2(): loss_rate = 0.01 graph = repeater_graph_states(3) noise_model = noise_model_loss_and_depolarizing(error_rate, loss_rate) - exemplary_test(graph, noise_model, None, 1) + # The depolarizing noise on every emitter CNOT leg folds each noisy compile into a + # large mixed stabilizer, so the default 50x50 search is very slow. This is a smoke + # test that the noisy hybrid search completes, not a search-quality test, so it runs + # 50 candidates instead of 2,500. + solver_setting = EvolutionarySolverSetting() + solver_setting.n_pop = 10 + solver_setting.n_stop = 5 + exemplary_test(graph, noise_model, solver_setting, 1) def test_alternate_circuits_w_noise3(): @@ -149,3 +159,119 @@ def test_alternate_circuits_w_noise3(): graph = nx.star_graph(3) noise_model = noise_model_loss_and_depolarizing(error_rate, loss_rate) exemplary_test(graph, noise_model, None, 1000) + + +def _solve_path3(target, compiler): + metric = Infidelity(target) + solver = HybridEvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + solver_setting=EvolutionarySolverSetting(n_stop=3, n_pop=5), + ) + HybridEvolutionarySolver.seed(3) + solver.solve() + return solver, metric + + +def test_a_density_matrix_target_with_the_density_matrix_compiler_is_solved(): + # the deterministic solver seeding the population converts its target to "s" in + # place; the metric sharing this target must keep scoring density matrices + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + solver, metric = _solve_path3(target, DensityMatrixCompiler()) + + assert target.rep_type == "dm" + assert metric.target is target + assert all(0 <= score <= 1 for score, _ in solver.hof) + # the first evaluation is the deterministic circuit, which prepares the target + assert metric.log[0] == pytest.approx(0, abs=1e-9) + + +def test_a_density_matrix_target_with_the_stabilizer_compiler_is_unchanged(): + target = QuantumState(graph_to_density(nx.path_graph(3)), rep_type="dm") + + solver, _ = _solve_path3(target, StabilizerCompiler()) + + assert [score for score, _ in solver.hof] == pytest.approx( + [0.5, 0.5, 0.75, 0.75, 0.75] + ) + # the stabilizer compiler's states are still scored against the converted target; + # this assertion, not the scores, is what tells the two metric arms apart + assert target.rep_type == "s" + + +def test_a_graph_target_with_the_stabilizer_compiler_still_solves(): + target = QuantumState(nx.path_graph(3), rep_type="g") + + solver, _ = _solve_path3(target, StabilizerCompiler()) + + assert [score for score, _ in solver.hof] == pytest.approx( + [0.5, 0.5, 0.75, 0.75, 0.75] + ) + + +def _path3_hybrid(noise_model_mapping=None): + target = QuantumState( + get_clifford_tableau_from_graph(nx.path_graph(3)), rep_type="s" + ) + compiler = StabilizerCompiler() + solver = HybridEvolutionarySolver( + target=target, + metric=Infidelity(target), + compiler=compiler, + noise_model_mapping=noise_model_mapping, + solver_setting=EvolutionarySolverSetting(n_stop=2, n_pop=2), + ) + return solver, compiler + + +def _record_search_compiles(solver, compiler, monkeypatch): + # record the `mixed` flag of every state compiled after the population is + # initialised; the deterministic solve that seeds it compiles first + recorded = [] + initialize = solver.population_initialization + compile_ = compiler.compile + + def recording_compile(circuit, initial_state=None): + state = compile_(circuit, initial_state) + recorded.append(state.mixed) + return state + + def initialize_then_record(): + population = initialize() + monkeypatch.setattr(compiler, "compile", recording_compile) + return population + + monkeypatch.setattr(solver, "population_initialization", initialize_then_record) + return recorded + + +def test_hybrid_without_noise_leaves_the_compiler_noise_free(): + solver, compiler = _path3_hybrid() + HybridEvolutionarySolver.seed(0) + solver.solve() + assert solver.noise_simulation is False + assert compiler.noise_simulation is False + assert compiler.compile(solver.result[1]).mixed is False + + +def test_hybrid_without_noise_searches_with_noise_free_compiles(monkeypatch): + solver, compiler = _path3_hybrid() + recorded = _record_search_compiles(solver, compiler, monkeypatch) + HybridEvolutionarySolver.seed(0) + solver.solve() + assert recorded + assert not any(recorded) + + +def test_hybrid_with_a_noise_mapping_still_simulates_noise(monkeypatch): + solver, compiler = _path3_hybrid( + noise_model_mapping={"e": {}, "p": {}, "ee": {}, "ep": {}} + ) + recorded = _record_search_compiles(solver, compiler, monkeypatch) + HybridEvolutionarySolver.seed(0) + solver.solve() + assert solver.noise_simulation is True + assert recorded + assert all(recorded) diff --git a/tests/test_solvers/test_identify_noise.py b/tests/test_solvers/test_identify_noise.py new file mode 100644 index 00000000..74a431e0 --- /dev/null +++ b/tests/test_solvers/test_identify_noise.py @@ -0,0 +1,98 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +import graphiq.noise.noise_models as nm +from graphiq.benchmarks.alternate_circuits import noise_model_loss_and_depolarizing +from graphiq.solvers.solver_base import SolverBase + + +class _MinimalSolver(SolverBase): + """A concrete SolverBase subclass with no search behaviour, so that + ``_identify_noise`` -- which does not read any solver state -- can be called on + an instance without constructing a full solver.""" + + def solve(self, *args): + raise NotImplementedError + + +@pytest.fixture +def solver(): + return _MinimalSolver(target=None, metric=None, compiler=None) + + +def test_per_leg_keys_are_honoured(solver): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + mapping = {"CNOT_control": control_noise, "CNOT_target": target_noise} + + result = solver._identify_noise(ops.CNOT, mapping) + + assert result[0] is control_noise + assert result[1] is target_noise + + +def test_one_per_leg_key_leaves_the_other_leg_noiseless(solver): + control_noise = nm.DepolarizingNoise(0.1) + mapping = {"MeasurementCNOTandReset_control": control_noise} + + result = solver._identify_noise(ops.MeasurementCNOTandReset, mapping) + + assert result[0] is control_noise + assert isinstance(result[1], nm.NoNoise) + + +def test_a_bare_name_key_reaches_both_legs(solver): + noise = nm.DepolarizingNoise(0.1) + mapping = {"CNOT": noise} + + result = solver._identify_noise(ops.CNOT, mapping) + + assert len(result) == 2 + assert result[0] is noise + assert result[1] is noise + + +def test_an_instance_and_its_class_agree(solver): + control_noise = nm.DepolarizingNoise(0.1) + target_noise = nm.DepolarizingNoise(0.2) + mapping = {"CNOT_control": control_noise, "CNOT_target": target_noise} + op_instance = ops.CNOT(control=0, target=1) + + result_from_instance = solver._identify_noise(op_instance, mapping) + result_from_class = solver._identify_noise(ops.CNOT, mapping) + + assert result_from_instance[0] is result_from_class[0] is control_noise + assert result_from_instance[1] is result_from_class[1] is target_noise + + +def test_one_qubit_ops_are_unchanged(solver): + noise = nm.DepolarizingNoise(0.1) + + assert solver._identify_noise(ops.Hadamard, {"Hadamard": noise}) is noise + assert isinstance(solver._identify_noise(ops.Hadamard, {}), nm.NoNoise) + + +def test_the_loss_and_depolarizing_benchmark_mapping_reaches_the_cnot_legs(solver): + noise_model_mapping = noise_model_loss_and_depolarizing(0.1, 0.1) + + both_legs = solver._identify_noise(ops.CNOT, noise_model_mapping["ee"]) + assert isinstance(both_legs[0], nm.DepolarizingNoise) + assert isinstance(both_legs[1], nm.DepolarizingNoise) + + one_leg = solver._identify_noise(ops.CNOT, noise_model_mapping["ep"]) + assert isinstance(one_leg[0], nm.DepolarizingNoise) + assert isinstance(one_leg[1], nm.NoNoise) diff --git a/tests/test_solvers/test_solver_base.py b/tests/test_solvers/test_solver_base.py index ed5f84c7..bfbcda87 100644 --- a/tests/test_solvers/test_solver_base.py +++ b/tests/test_solvers/test_solver_base.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np + +from graphiq.benchmarks.circuits import linear_cluster_3qubit_circuit from graphiq.solvers.solver_base import * @@ -34,3 +37,66 @@ def test_random_search_solver_setting_getter_setter(): assert solver_setting.n_hof == 10 assert solver_setting.n_pop == 10 assert solver_setting.n_stop == 10 + + +# Test for RandomSearchSolver.tournament_selection. The payload is (score, [score]) rather than a +# plain (float, str) pair or a CircuitDAG: a (float, str) pair would round-trip through +# copy.deepcopy as the very same object (CPython treats it as atomic), defeating the identity +# check below, and CircuitDAG has no __eq__, so the k=1 equality check would fail regardless of +# whether the copy is otherwise correct. A one-element list is mutable enough to force a real +# deep copy while still comparing equal by value. + + +def test_tournament_selection_k_zero_is_a_no_op(): + solver = RandomSearchSolver(None, None, None) + population = [(0.5, [0.5]), (0.1, [0.1])] + + assert solver.tournament_selection(population, k=0) is population + + +def test_tournament_selection_samples_with_replacement_and_returns_deep_copies(): + # random.choices(population, k=k) samples with replacement, so k == len(population) does + # not guarantee the minimum-scoring entry is ever drawn; only the length, copy-ness, and + # score bound are guaranteed. + random.seed(0) + solver = RandomSearchSolver(None, None, None) + population = [(0.5, [0.5]), (0.1, [0.1]), (0.9, [0.9])] + input_scores = [score for score, _ in population] + + new_population = solver.tournament_selection(population, k=2) + + assert len(new_population) == solver.setting.n_pop + for score, payload in new_population: + assert score in input_scores + assert not any(entry is p for entry in new_population for p in population) + + +def test_tournament_selection_k_one_with_a_single_element_population_copies_that_element(): + random.seed(0) + solver = RandomSearchSolver(None, None, None) + element = (0.42, [0.42]) + population = [element] + + new_population = solver.tournament_selection(population, k=1) + + assert len(new_population) == solver.setting.n_pop + for entry in new_population: + assert entry == element + assert entry is not element + + +def test_an_inf_scored_candidate_fills_an_unfilled_hall_of_fame_slot(): + # the hall of fame starts as (np.inf, None) placeholders; a candidate scored inf ties + # with the first one and must take its place rather than compare against its None circuit + solver = RandomSearchSolver( + None, None, None, solver_setting=RandomSearchSolverSetting(n_hof=3) + ) + circuit, _ = linear_cluster_3qubit_circuit() + + solver.update_hof([(np.inf, circuit)]) + + score, kept = solver.hof[0] + assert score == np.inf + assert kept is not None and kept is not circuit + assert len(kept.dag.nodes) == len(circuit.dag.nodes) + assert solver.hof[1:] == [(np.inf, None), (np.inf, None)] diff --git a/tests/test_solvers/test_solver_seed_recording.py b/tests/test_solvers/test_solver_seed_recording.py new file mode 100644 index 00000000..5ee888d1 --- /dev/null +++ b/tests/test_solvers/test_solver_seed_recording.py @@ -0,0 +1,66 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.benchmarks.circuits import ghz3_state_circuit +from graphiq.benchmarks.graph_states import linear_cluster_state +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import EvolutionarySolver +from graphiq.solvers.solver_base import SolverBase +from graphiq.solvers.time_reversed_solver import TimeReversedSolver +from graphiq.state import QuantumState + + +@pytest.fixture(autouse=True) +def _restore_last_seed(): + saved = getattr(SolverBase, "last_seed", None) + yield + SolverBase.last_seed = saved + + +def _evolutionary_solver(): + _, ideal_state = ghz3_state_circuit() + return EvolutionarySolver( + target=ideal_state, + metric=Infidelity(target=ideal_state), + compiler=DensityMatrixCompiler(), + n_emitter=1, + n_photon=3, + ) + + +def _time_reversed_solver(): + tableau = get_clifford_tableau_from_graph(linear_cluster_state(2).data) + target = QuantumState(tableau, rep_type="stab") + return TimeReversedSolver( + target=target, metric=Infidelity(target=target), compiler=StabilizerCompiler() + ) + + +@pytest.mark.parametrize("build", [_evolutionary_solver, _time_reversed_solver]) +def test_solver_reports_its_seed(build): + solver = build() + solver.seed(4) + assert solver.solver_info["seed"] == 4 + + +def test_class_level_seed_is_visible_to_an_instance(): + EvolutionarySolver.seed(7) + assert _evolutionary_solver().solver_info["seed"] == 7 diff --git a/tests/test_solvers/test_solver_setting_defaults.py b/tests/test_solvers/test_solver_setting_defaults.py new file mode 100644 index 00000000..0cc719db --- /dev/null +++ b/tests/test_solvers/test_solver_setting_defaults.py @@ -0,0 +1,117 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests that RandomSearchSolver, EvolutionarySolver and HybridEvolutionarySolver each +build their own default settings object instead of sharing one module-level instance. +""" + +import networkx as nx +import pytest + +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.stabilizer.compiler import StabilizerCompiler +from graphiq.backends.stabilizer.functions.rep_conversion import ( + get_clifford_tableau_from_graph, +) +from graphiq.benchmarks.circuits import ghz3_state_circuit +from graphiq.metrics import Infidelity +from graphiq.solvers.evolutionary_solver import ( + EvolutionarySolver, + EvolutionarySolverSetting, +) +from graphiq.solvers.hybrid_solvers import HybridEvolutionarySolver +from graphiq.solvers.solver_base import RandomSearchSolver, RandomSearchSolverSetting +from graphiq.state import QuantumState + +_NO_SETTING = object() + + +def _build_random_search_solver(solver_setting=_NO_SETTING): + _, target = ghz3_state_circuit() + metric = Infidelity(target) + compiler = DensityMatrixCompiler() + if solver_setting is _NO_SETTING: + return RandomSearchSolver(target, metric, compiler) + return RandomSearchSolver(target, metric, compiler, solver_setting=solver_setting) + + +def _build_evolutionary_solver(solver_setting=_NO_SETTING): + _, target = ghz3_state_circuit() + metric = Infidelity(target) + compiler = DensityMatrixCompiler() + if solver_setting is _NO_SETTING: + return EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=1, + n_photon=3, + ) + return EvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + n_emitter=1, + n_photon=3, + solver_setting=solver_setting, + ) + + +def _build_hybrid_evolutionary_solver(solver_setting=_NO_SETTING): + tableau = get_clifford_tableau_from_graph(nx.path_graph(3)) + target = QuantumState(tableau, rep_type="s") + metric = Infidelity(target) + compiler = StabilizerCompiler() + if solver_setting is _NO_SETTING: + return HybridEvolutionarySolver(target=target, metric=metric, compiler=compiler) + return HybridEvolutionarySolver( + target=target, + metric=metric, + compiler=compiler, + solver_setting=solver_setting, + ) + + +_CASES = [ + pytest.param( + _build_random_search_solver, RandomSearchSolverSetting, id="RandomSearchSolver" + ), + pytest.param( + _build_evolutionary_solver, EvolutionarySolverSetting, id="EvolutionarySolver" + ), + pytest.param( + _build_hybrid_evolutionary_solver, + EvolutionarySolverSetting, + id="HybridEvolutionarySolver", + ), +] + + +@pytest.mark.parametrize("build_solver, setting_cls", _CASES) +def test_default_setting_is_per_instance(build_solver, setting_cls): + a = build_solver() + b = build_solver() + + assert a.setting is not b.setting + + a.setting.n_stop = 7 + assert b.setting.n_stop == 50 + + c = build_solver() + assert c.setting.n_stop == 50 + + s = setting_cls() + solver = build_solver(solver_setting=s) + assert solver.setting is s diff --git a/tests/test_source_hygiene.py b/tests/test_source_hygiene.py new file mode 100644 index 00000000..acd9d1ab --- /dev/null +++ b/tests/test_source_hygiene.py @@ -0,0 +1,52 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +r"""Guard against invalid escape sequences in the source tree. + +Every ``*.py`` file under the directories in ``SWEPT_DIRECTORIES`` is compiled +with all warnings enabled, and any compiler warning whose message contains +"invalid escape sequence" is collected. Python 3.12 reports an invalid escape +(e.g. ``"\s"``) as a ``SyntaxWarning``, older versions as a +``DeprecationWarning``; matching on the message covers both. +""" + +import warnings +from pathlib import Path + +SWEPT_DIRECTORIES = ("graphiq", "tests", "examples", "visualization") + + +def test_no_invalid_escape_sequences(): + root = Path(__file__).resolve().parents[1] + for directory in SWEPT_DIRECTORIES: + assert (root / directory).is_dir(), f"swept directory missing: {directory}" + + files = sorted( + path + for directory in SWEPT_DIRECTORIES + for path in (root / directory).rglob("*.py") + ) + assert files, "no .py files found under the swept directories" + + findings = [] + for path in files: + source = path.read_text(encoding="utf-8") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + compile(source, str(path), "exec") + for w in caught: + if "invalid escape sequence" in str(w.message): + findings.append((str(path.relative_to(root)), w.lineno, str(w.message))) + + assert not findings, "\n".join(str(entry) for entry in findings) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py index 9b02b72a..c332c5b9 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_functions.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_functions.py @@ -14,6 +14,7 @@ # limitations under the License. import pytest +import graphiq.backends.density_matrix.functions as dmf from graphiq.backends.density_matrix.functions import * @@ -105,3 +106,225 @@ def test_fidelity_multiqubit(n_qubits): ) # works only for even number of qubits assert np.allclose(fidelity(rho, sigma), 0.5**n_qubits) + + +def test_swap_two_qubits_exchanges_a_computational_basis_state(): + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + swapped = swap_two_qubits(rho, 0, 1) + + assert np.allclose(swapped, ket2dm(tensor([state_ketz1(), state_ketz0()]))) + + +def test_swap_two_qubits_refuses_equal_control_and_target(): + # Delegates to get_two_qubit_controlled_gate, which refuses to build a controlled gate + # whose control and target are the same qubit. + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + with pytest.raises(ValueError): + swap_two_qubits(rho, 0, 0) + + +def test_trace_out_qubit_of_a_bell_pair_leaves_the_maximally_mixed_state(): + bell = ( + tensor([state_ketz0(), state_ketz0()]) + tensor([state_ketz1(), state_ketz1()]) + ) / np.sqrt(2) + rho = ket2dm(bell) + + traced = trace_out_qubit(rho, 1) + + assert np.allclose(traced, np.eye(2) / 2) + + +def test_trace_out_qubit_of_a_product_state_leaves_the_untraced_factor(): + rho = ket2dm(tensor([state_ketz0(), state_ketz1()])) + + traced = trace_out_qubit(rho, 1) + + assert np.allclose(traced, ket2dm(state_ketz0())) + + +def test_partial_trace_of_a_bell_pair_is_maximally_mixed(): + # Tracing one half of |00> + |11> must remove the coherence between |0> and + # |1>. Summing the traced qubit's row and column indices separately instead of + # along its diagonal leaves |+><+| here, off-diagonals of 0.5 instead of 0. + bell = np.array([1, 0, 0, 1]) / np.sqrt(2) + rho = np.outer(bell, bell.conj()) + + for keep in ([0], [1]): + assert np.allclose(partial_trace(rho, keep, [2, 2]), np.eye(2) / 2) + + +@pytest.mark.parametrize("keep", [[0], [1], [2], [0, 1], [0, 2], [1, 2]]) +def test_partial_trace_matches_an_explicit_sum_over_traced_basis_states(keep): + # Reference: sum over the traced qubits' basis states of , built + # with explicit projectors, on a random entangled three-qubit state. + rng = np.random.default_rng(7) + psi = rng.normal(size=8) + 1j * rng.normal(size=8) + psi /= np.linalg.norm(psi) + rho = np.outer(psi, psi.conj()) + + traced = [q for q in range(3) if q not in keep] + expected = np.zeros((2 ** len(keep), 2 ** len(keep)), dtype=complex) + for bits in np.ndindex(*(2,) * len(traced)): + # a (8 x 2**len(keep)) isometry picking out the traced qubits' values + iso = np.zeros((8, 2 ** len(keep))) + for kept_bits in np.ndindex(*(2,) * len(keep)): + full = [0, 0, 0] + for q, b in zip(traced, bits): + full[q] = b + for q, b in zip(keep, kept_bits): + full[q] = b + row = full[0] * 4 + full[1] * 2 + full[2] + col = int("".join(map(str, kept_bits)), 2) if keep else 0 + iso[row, col] = 1 + expected += iso.T @ rho @ iso + + assert np.allclose(partial_trace(rho, keep, [2, 2, 2]), expected) + + +# two non-commuting mixed qubit states, both complex +_RHO = np.array([[0.5, -0.2j], [0.2j, 0.5]]) +_SIGMA = np.array([[0.6, 0.1 - 0.1j], [0.1 + 0.1j, 0.4]]) +# for qubits, F = tr(rho sigma) + 2 sqrt(det rho det sigma) +_F_RHO_SIGMA = 0.54 + 2 * np.sqrt(0.21 * 0.22) + + +def test_fidelity_of_two_different_real_mixed_states(): + p, q = np.array([0.7, 0.3]), np.array([0.5, 0.5]) + # commuting states: F = (sum_i sqrt(p_i q_i))^2 + expected = np.sum(np.sqrt(p * q)) ** 2 + + assert fidelity(np.diag(p), np.diag(q)) == pytest.approx(expected) + + +def test_fidelity_of_a_complex_mixed_state_with_itself_is_one(): + assert fidelity(_RHO, _RHO.copy()) == pytest.approx(1.0) + + +def test_fidelity_of_two_complex_mixed_states(): + assert fidelity(_RHO, _SIGMA) == pytest.approx(_F_RHO_SIGMA) + + +def test_sqrtm_psd_of_a_complex_matrix_squares_back_to_it(): + root = sqrtm_psd(_RHO) + + assert np.allclose(root @ root, _RHO) + + +def test_sqrtm_psd_of_a_real_valued_matrix_is_unchanged(): + # complex dtype with zero imaginary parts, as the compiler's states carry + matrix = np.array([[0.6, 0.2], [0.2, 0.4]], dtype=complex) + eig_vals, eig_vecs = eigh(matrix) + + expected = (eig_vecs * np.sqrt(np.maximum(eig_vals, 0))) @ eig_vecs.T + + assert np.array_equal(sqrtm_psd(matrix), expected) + + +def test_fidelity_still_raises_above_one(monkeypatch): + # fidelity looks sqrtm_psd up in its own module, so patch it there + monkeypatch.setattr(dmf, "sqrtm_psd", lambda m: 1.1 * np.eye(2)) + + with pytest.raises(Warning): + fidelity(np.diag([0.7, 0.3]), np.diag([0.5, 0.5])) + + +def _elementwise_partial_transpose(rho, dim1, dim2, subsys): + # the definition: with row index (i, j) and column index (k, l), transposing + # subsystem 0 exchanges i and k, and transposing subsystem 1 exchanges j and l + out = np.zeros_like(rho) + for i in range(dim1): + for j in range(dim2): + for k in range(dim1): + for l in range(dim2): + if subsys == 0: + out[k * dim2 + j, i * dim2 + l] = rho[ + i * dim2 + j, k * dim2 + l + ] + else: + out[i * dim2 + l, k * dim2 + j] = rho[ + i * dim2 + j, k * dim2 + l + ] + return out + + +@pytest.mark.parametrize("subsys", [0, 1]) +@pytest.mark.parametrize("dim1, dim2", [(2, 2), (3, 3), (2, 3), (3, 2), (2, 4), (4, 2)]) +def test_bipartite_partial_transpose_matches_the_elementwise_definition( + dim1, dim2, subsys +): + rng = np.random.default_rng(0) + a = rng.normal(size=(dim1 * dim2, dim1 * dim2)) + 1j * rng.normal( + size=(dim1 * dim2, dim1 * dim2) + ) + rho = a @ a.conj().T + rho = rho / np.trace(rho) + + assert np.allclose( + bipartite_partial_transpose(rho, dim1, dim2, subsys), + _elementwise_partial_transpose(rho, dim1, dim2, subsys), + ) + + +def test_bipartite_partial_transpose_of_a_product_transposes_one_factor(): + rng = np.random.default_rng(1) + a = rng.normal(size=(2, 2)) + b = rng.normal(size=(3, 3)) + + assert np.allclose( + bipartite_partial_transpose(np.kron(a, b), 2, 3, 0), np.kron(a.T, b) + ) + assert np.allclose( + bipartite_partial_transpose(np.kron(a, b), 2, 3, 1), np.kron(a, b.T) + ) + + +def test_negativity_with_unequal_dimensions(): + # a Bell pair on qubits 0 and 1, with qubit 2 in |0>, split as qubit 0 | qubits 1, 2 + bell = np.array([1, 0, 0, 1]) / np.sqrt(2) + state = np.kron(bell, np.array([1, 0])) + assert np.isclose(negativity(np.outer(state, state.conj()), 2, 4), 0.5) + + # a product state across the same 2 | 4 split has no negativity + plus = np.array([1, 1]) / np.sqrt(2) + phi = np.array([1, 0, 1j, 0]) / np.sqrt(2) + product = np.kron(np.outer(plus, plus.conj()), np.outer(phi, phi.conj())) + assert np.isclose(negativity(product, 2, 4), 0, atol=1e-12) + + +def test_check_equivalent_unitaries_ignores_round_off_at_exact_zeros(): + x = sigmax().astype(complex) + + noisy_x = x.copy() + noisy_x[0, 0] = 1e-17 + + noisy_ix = (1j * x).copy() + noisy_ix[0, 0] = 1e-17 + + exact_ix = 1j * x + + assert is_unitary(noisy_x) + assert is_unitary(noisy_ix) + assert is_unitary(exact_ix) + + # both operands carry the same round-off at the exact-zero entry + assert check_equivalent_unitaries(noisy_ix, noisy_x) + # only the second operand carries it + assert check_equivalent_unitaries(exact_ix, noisy_x) + + # pairs that differ must still be rejected + noisy_identity = np.eye(2, dtype=complex) + noisy_identity[0, 1] = 1e-17 + assert not check_equivalent_unitaries( + np.diag([1, -1]).astype(complex), noisy_identity + ) + assert not check_equivalent_unitaries(x, np.eye(2, dtype=complex)) + + +def test_check_equivalent_unitaries_of_a_gate_built_x(): + # H S S H is X up to round-off, which sits at its exact-zero diagonal + x_from_gates = hadamard() @ phase() @ phase() @ hadamard() + + assert check_equivalent_unitaries(sigmax(), x_from_gates) + assert check_equivalent_unitaries(1j * sigmax(), x_from_gates) diff --git a/tests/test_state_representation/density_matrix/test_density_matrix_state.py b/tests/test_state_representation/density_matrix/test_density_matrix_state.py index f8bf858a..c5b30523 100644 --- a/tests/test_state_representation/density_matrix/test_density_matrix_state.py +++ b/tests/test_state_representation/density_matrix/test_density_matrix_state.py @@ -43,6 +43,38 @@ def test_density_matrix_creation_graph(): def test_density_matrix_get_single_qubit_gate(): - # TODO: replace these prints by assert np.allclose( <>, expected) - print(dmf.get_one_qubit_gate(3, 0, dmf.sigmaz())) - print(dmf.get_two_qubit_controlled_gate(3, 0, 1, dmf.sigmax())) + # qubit 0 is the leftmost tensor factor + assert np.allclose( + dmf.get_one_qubit_gate(3, 0, dmf.sigmaz()), + np.diag([1, 1, 1, 1, -1, -1, -1, -1]), + ) + assert np.allclose( + dmf.get_two_qubit_controlled_gate(3, 0, 1, dmf.sigmax()), + np.kron(np.kron(np.diag([1, 0]), np.eye(2)), np.eye(2)) + + np.kron(np.kron(np.diag([0, 1]), dmf.sigmax()), np.eye(2)), + ) + + +def test_density_matrix_eq_against_a_non_density_matrix(): + dm = DensityMatrix(np.array([[1.0, 0.0], [0.0, 0.0]])) + assert (dm == 3) is False + assert (dm == None) is False + + +def test_density_matrix_eq_different_sizes(): + # a (1, 1) matrix must not broadcast into equality with a (2, 2) one + assert (DensityMatrix(np.ones((1, 1))) == DensityMatrix(np.ones((2, 2)))) is False + assert (DensityMatrix(np.eye(2) / 2) == DensityMatrix(np.eye(4) / 4)) is False + + +def test_forced_outcome_0_with_zero_probability_does_not_divide_by_zero(): + # p(1) is 1 up to rounding and p(0) is exactly 0, so outcome 0 is impossible + state = DensityMatrix( + np.diag([0.0, 1.0 - 1e-16]).astype(complex), normalized=False + ) + projectors = [np.diag([1.0, 0.0]), np.diag([0.0, 1.0])] + + outcome = state.apply_measurement(projectors, measurement_determinism=0) + + assert outcome == 1 + assert not np.any(np.isnan(state.data)) diff --git a/tests/test_state_representation/density_matrix/test_measurements.py b/tests/test_state_representation/density_matrix/test_measurements.py index cfdcb58d..9913b573 100644 --- a/tests/test_state_representation/density_matrix/test_measurements.py +++ b/tests/test_state_representation/density_matrix/test_measurements.py @@ -15,10 +15,17 @@ import matplotlib.pyplot as plt import networkx as nx import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf import graphiq.backends.state_rep_conversion as rc -from graphiq.backends.density_matrix.state import DensityMatrix +import graphiq.circuit.ops as ops +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler +from graphiq.backends.density_matrix.state import ( + DensityMatrix, + _forced_measurement_tolerance, +) +from graphiq.circuit.circuit_dag import CircuitDAG def test_y_measurement(): @@ -55,3 +62,214 @@ def test_x_measurement(): graph_adj = rc.density_to_graph(rho.data, validate=False) # nx.draw(nx.from_numpy_array(graph_adj),with_labels=True) plt.show() + + +_PLUS = np.full((2, 2), 0.5) +_Z_PROJECTORS = [np.diag([1.0, 0.0]), np.diag([0.0, 1.0])] + + +@pytest.mark.parametrize("determinism", [0, 1, "probabilistic"]) +def test_measurement_keeps_the_trace_of_a_subnormalised_state(determinism): + # photon loss leaves a trace below 1, which the measurement must keep + np.random.seed(0) + rho = DensityMatrix(0.9 * _PLUS, normalized=False) + + outcome = rho.apply_measurement(_Z_PROJECTORS, measurement_determinism=determinism) + + assert np.allclose(rho.data, 0.9 * _Z_PROJECTORS[outcome]) + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_measurement_keeps_a_trace_just_below_one(determinism): + # a loss of 1e-6 is far above rounding drift, so it is kept too + rho = DensityMatrix(0.999999 * _PLUS, normalized=False) + + rho.apply_measurement(_Z_PROJECTORS, measurement_determinism=determinism) + + assert np.real(np.trace(rho.data)) == pytest.approx(0.999999, rel=1e-12) + + +def _path3_with_trace_one_up_to_rounding(): + # four gate and dephasing steps leave the trace at 1 - 1.4e-15 + h = np.array([[1, 1], [1, -1]]) / np.sqrt(2) + unitaries = [np.kron(np.eye(2), np.kron(h, h)), np.kron(h, np.eye(4))] + dephasing = [np.sqrt(0.9) * np.eye(8), np.sqrt(0.1) * np.diag([1, -1] * 4)] + rho = DensityMatrix(rc.graph_to_density(nx.path_graph(3))) + for step in range(4): + rho.apply_unitary(unitaries[step % 2]) + rho.apply_channel(dephasing) + return rho + + +@pytest.mark.parametrize("determinism", [0, 1]) +def test_measurement_of_a_state_with_trace_one_up_to_rounding_is_unchanged( + determinism, +): + rho = _path3_with_trace_one_up_to_rounding() + before = rho.data.copy() + trace = np.real(np.trace(before)) + assert np.trace(before) != 1.0 + projectors = [ + np.kron(np.diag([1.0, 0.0]), np.eye(4)), + np.kron(np.diag([0.0, 1.0]), np.eye(4)), + ] + + outcome = rho.apply_measurement(projectors, measurement_determinism=determinism) + + m = projectors[outcome] + expected = (m @ before @ np.transpose(np.conjugate(m))) / np.real( + np.trace(before @ m) + ) + # scaling by this trace would change the matrix, so the check can tell + assert not np.array_equal(trace * expected, expected) + assert np.array_equal(rho.data, expected) + + +# A forced outcome is taken only if its probability is above a resolution floor, +# tol = dim * eps * sum(|probs|); below it the probability is rounding error. These +# build states with normalized=False, since the constructor renormalises the very +# roundoff under test. + + +def _forced(rho, determinism, n_qubits=1): + state = DensityMatrix(np.asarray(rho, dtype=complex), normalized=False) + outcome = state.apply_measurement( + dmf.projectors_zbasis(n_qubits, 0), measurement_determinism=determinism + ) + return outcome, state.data + + +def _skewed_block(trace, coherence=3e-16): + # a 2x2 block with the given trace and a fixed off-diagonal roundoff: indefinite for + # a trace well below the coherence, so dividing by that trace would amplify a + # negative eigenvalue to order one; positive semidefinite well above it + a = b = trace / 2 + return np.array([[a, coherence], [coherence, b]]) + + +def _forced_boundary_state(forced_index, forced_trace, other_trace): + # 2 qubits, block diagonal: the forced outcome's block is _skewed_block(forced_trace) + # and the other outcome's block is diag([other_trace, 0]) + forced_block = _skewed_block(forced_trace) + other_block = np.diag([other_trace, 0.0]) + rho = np.zeros((4, 4), dtype=complex) + if forced_index == 0: + rho[:2, :2], rho[2:, 2:] = forced_block, other_block + else: + rho[:2, :2], rho[2:, 2:] = other_block, forced_block + return rho + + +def test_a_rounding_error_probability_is_not_forced(): + # a rotation by 2e-17 leaves p(1) = sin^2(1e-17), about 1e-34: one product with + # nothing to cancel, so it is nonzero on every BLAS kernel. It is a real branch, but + # far below the floor, which cannot tell a probability that small from rounding + # error; the precondition checks that p(1) is nonzero and far below the floor + circuit = CircuitDAG(n_emitter=1, n_classical=0) + circuit.add( + ops.ParameterizedOneQubitRotation( + register=0, reg_type="e", params=(2e-17, 0.0, 0.0) + ) + ) + state = DensityMatrixCompiler().compile(circuit).rep_data + projectors = dmf.projectors_zbasis(1, 0) + assert 0 < np.real(np.trace(state.data @ projectors[1])) < 1e-20 + + outcome = state.apply_measurement(projectors, measurement_determinism=1) + + assert outcome == 0 + assert np.allclose(state.data, np.diag([1.0, 0.0])) + + +@pytest.mark.parametrize("over_one", [False, True]) +@pytest.mark.parametrize("k", [0.5, 2, 10]) +@pytest.mark.parametrize("determinism", [0, 1]) +def test_forced_measurement_boundary_is_where_the_code_computes_it( + determinism, k, over_one +): + # The forced outcome's block trace is k * tol, with tol computed by the code's own + # function, so a small shift in roundoff does not flip the test; only a change to the + # tolerance does. Below tol the forced outcome is not taken (the block is indefinite + # there); above it, it is, and the result is still a valid density matrix. over_one + # puts the trace just above 1 (and, for k <= 2, the other outcome's probability), + # which must not matter. tol is taken for a total of 1, which the state's total + # matches to within 4e-15. + tol = _forced_measurement_tolerance(4, np.array([1.0])) + forced_trace = k * tol + other_trace = 1.0 - forced_trace + (3.55e-15 if over_one else 0.0) + rho = _forced_boundary_state(determinism, forced_trace, other_trace) + + outcome, data = _forced(rho, determinism, n_qubits=2) + + assert outcome == (determinism if k > 1 else 1 - determinism) + assert dmf.is_density_matrix(data) + + +def test_forcing_zero_on_a_state_with_trace_above_one(): + # an unnormalised state: p(1) = 1 does not make outcome 0 impossible + outcome, _ = _forced(np.diag([1.0, 1.0]), 0) + + assert outcome == 0 + + +@pytest.mark.parametrize("n_qubits", [1, 2, 3, 4]) +def test_a_state_is_psd_accepts_is_still_measurable(n_qubits): + # every eigenvalue is within is_psd's -1e-15, but the outcome-1 block sums to + # -0.9e-15 * 2^(n-1); a negative probability counts as 0 + dim = 2**n_qubits + diagonal = np.zeros(dim) + diagonal[dim // 2 :] = -0.9e-15 + diagonal[0] = 1 + 0.9e-15 * (dim // 2) + rho = np.diag(diagonal) + assert dmf.is_density_matrix(rho) + + for determinism in (0, 1, "probabilistic"): + outcome, _ = _forced(rho, determinism, n_qubits=n_qubits) + assert outcome == 0 + + +def test_a_branch_far_above_tolerance_is_honoured(): + # theta = pi - 1e-6 gives p(0) = cos^2(theta / 2) ~= 2.5e-13, far above tol (~4.4e-16 + # at one qubit): a real branch, which a coarse threshold such as 1e-9 would discard + circuit = CircuitDAG(n_emitter=1, n_classical=0) + circuit.add( + ops.ParameterizedOneQubitRotation( + register=0, reg_type="e", params=(np.pi - 1e-6, 0.0, 0.0) + ) + ) + state = DensityMatrixCompiler().compile(circuit).rep_data + + outcome = state.apply_measurement( + dmf.projectors_zbasis(1, 0), measurement_determinism=0 + ) + + assert outcome == 0 + assert dmf.is_density_matrix(state.data) + + +def test_an_int64_state_does_not_crash(): + # normalized=False keeps an integer matrix as it is + state = DensityMatrix(np.array([[1, 0], [0, 0]]), normalized=False) + + outcome = state.apply_measurement( + dmf.projectors_zbasis(1, 0), measurement_determinism=0 + ) + + assert outcome == 0 + + +def test_an_impossible_forced_outcome_returns_the_other_one(): + assert _forced(np.diag([1.0, 0.0]), 1)[0] == 0 + assert _forced(np.diag([0.0, 1.0]), 0)[0] == 1 + + +def test_an_ordinary_forced_measurement_is_unaffected(): + assert _forced(np.diag([0.5, 0.5]), 0)[0] == 0 + assert _forced(np.diag([0.5, 0.5]), 1)[0] == 1 + + +def test_a_redirected_forced_outcome_keeps_a_subnormalised_trace(): + outcome, data = _forced(np.diag([0.5, 0.0]), 1) + + assert outcome == 0 + assert np.real(np.trace(data)) == pytest.approx(0.5) diff --git a/tests/test_state_representation/graph/test_graph_rep.py b/tests/test_state_representation/graph/test_graph_rep.py index 47344eaa..9697c59e 100644 --- a/tests/test_state_representation/graph/test_graph_rep.py +++ b/tests/test_state_representation/graph/test_graph_rep.py @@ -12,8 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from graphiq.backends.graph.state import Graph, MixedGraph import networkx as nx +import pytest + +import graphiq.backends.density_matrix.functions as dmf +import graphiq.circuit.ops as ops +from graphiq.backends.graph.state import Graph, MixedGraph def test_graph_initialization(): @@ -31,7 +35,10 @@ def test_graph_lc(): lc_dict = {0: ["x"], 1: ["h"], 2: ["s"], 3: ["s", "h"]} graph = nx.Graph([(0, 1), (1, 2), (1, 3), (2, 3)]) state = Graph(graph, lc_dict) - print(f"Local Clifford gate at node 1 is {state.find_lc(1)}") + assert state.find_lc(1) == [ops.Hadamard, ops.Identity] + assert dmf.check_equivalent_unitaries( + ops.local_clifford_to_matrix_map(state.find_lc(3)), dmf.phase() @ dmf.hadamard() + ) def test_mixed_graph(): @@ -42,4 +49,115 @@ def test_mixed_graph(): state3 = MixedGraph([(0.5, state1), (0.5, state2)]) print(state3.mixture) assert state3.n_qubits == 4 - assert state3.probability == 1.0 \ No newline at end of file + assert state3.probability == 1.0 + + +def test_find_lc_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.find_lc(5) + + +def test_update_lc_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.update_lc(5, ["h"]) + + +def test_update_lc_sets_gate_and_resets_to_identity(): + state = Graph(nx.Graph([(0, 1)])) + assert state.find_lc(0) == [ops.Identity] + state.update_lc(0, ["h"]) + assert state.find_lc(0) != [ops.Identity] + state.update_lc(0, None) + assert state.find_lc(0) == [ops.Identity] + + +def test_add_node_new_node_and_existing_node_is_noop(): + state = Graph(nx.Graph([(0, 1)])) + state.add_node(2) + assert 2 in state.get_nodes() + assert state.find_lc(2) == [ops.Identity] + + # a node already present is left untouched, including its local Clifford + state.update_lc(2, ["h"]) + state.add_node(2) + assert state.find_lc(2) != [ops.Identity] + + +def test_add_node_with_explicit_lc_gate_stores_it(): + state = Graph(nx.Graph([(0, 1)])) + state.add_node(3, ["h"]) + assert 3 in state.get_nodes() + assert state.find_lc(3) == ops.simplify_local_clifford([ops.name_to_class_map("h")]) + + +def test_add_edge_creates_missing_second_endpoint(): + state = Graph(nx.Graph([(0, 1)])) + state.add_edge(1, 5) + assert 5 in state.get_nodes() + assert {1, 5} in [set(e) for e in state.get_edges()] + + +def test_add_edge_creates_missing_first_endpoint(): + state = Graph(nx.Graph([(0, 1)])) + state.add_edge(5, 1) + assert 5 in state.get_nodes() + assert state.find_lc(5) == [ops.Identity] + assert {5, 1} in [set(e) for e in state.get_edges()] + + +def test_get_edges_and_get_nodes(): + state = Graph(nx.Graph([(0, 1), (1, 2)])) + assert set(state.get_nodes()) == {0, 1, 2} + assert {frozenset(e) for e in state.get_edges()} == { + frozenset((0, 1)), + frozenset((1, 2)), + } + + +def test_get_neighbors_missing_node_raises(): + state = Graph(nx.Graph([(0, 1)])) + with pytest.raises(ValueError): + state.get_neighbors(99) + + +def test_mixed_graph_constructor_rejects_bare_nx_graph(): + with pytest.raises(TypeError): + MixedGraph(nx.Graph([(0, 1)])) + + +def test_mixed_graph_mixture_setter_rejects_bare_nx_graph(): + graph1 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + with pytest.raises(TypeError): + state.mixture = nx.Graph([(0, 1)]) + + +def test_mixed_graph_rejects_list_of_bare_nx_graph(): + with pytest.raises(AssertionError): + MixedGraph([(1.0, nx.Graph([(0, 1)]))]) + + +def test_mixed_graph_mixture_setter_list(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + state.mixture = [(0.5, graph1), (0.5, graph2)] + assert state.mixture == [(0.5, graph1), (0.5, graph2)] + + +def test_mixed_graph_mixture_setter_single_graph(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1), (1, 2)])) + state = MixedGraph(graph1) + state.mixture = graph2 + assert state.mixture == [(1.0, graph2)] + + +def test_mixed_graph_data_setter_delegates_to_mixture(): + graph1 = Graph(nx.Graph([(0, 1)])) + graph2 = Graph(nx.Graph([(0, 1)])) + state = MixedGraph(graph1) + state.data = [(0.5, graph1), (0.5, graph2)] + assert state.data == [(0.5, graph1), (0.5, graph2)] diff --git a/tests/test_state_representation/stabilizer/test_apply_x_measurement.py b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py new file mode 100644 index 00000000..c989791f --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_apply_x_measurement.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``Stabilizer.apply_x_measurement`` measures a qubit in the X basis and must +leave the tableau in a valid post-measurement state expressed in the qubit's +own (unrotated) basis -- not in the Z frame the measurement is taken in. + +These tests build small states through the ``Stabilizer`` class's own public +gate methods and check the outcome and the density matrix of the resulting +tableau. Every case forces its outcome with ``measurement_determinism`` so +nothing here depends on the unseeded global RNG. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.state import Stabilizer + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +_PLUS = np.array([[0.5, 0.5], [0.5, 0.5]]) +_MINUS = np.array([[0.5, -0.5], [-0.5, 0.5]]) + + +def test_measuring_ket_plus_in_the_x_basis_returns_zero_and_leaves_it_unchanged(): + stab = Stabilizer(1) + stab.apply_hadamard(0) + + outcome = stab.apply_x_measurement(0, measurement_determinism=0) + + assert outcome == 0 + assert np.allclose(tableau_to_density(stab.tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_zero_leaves_ket_plus(): + stab = Stabilizer(1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=0) + + assert outcome == 0 + assert np.allclose(tableau_to_density(stab.tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_one_leaves_ket_minus(): + stab = Stabilizer(1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=1) + + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), _MINUS) + + +def test_measuring_one_qubit_of_a_bell_pair_in_the_x_basis_leaves_the_other_correlated(): + # Measuring qubit 0 of a Bell pair in the X basis is probabilistic; the + # untouched qubit collapses to the same X eigenstate. Getting this right + # depends on the measured qubit's row landing back in its own frame, + # not the Z frame the measurement is taken in, before the surviving + # correlation is read off it. + stab = Stabilizer(2) + stab.apply_hadamard(0) + stab.apply_cnot(0, 1) + + outcome = stab.apply_x_measurement(0, measurement_determinism=1) + + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), np.kron(_MINUS, _MINUS)) + + +def test_measuring_qubit_one_of_an_asymmetric_state_measures_that_qubit(): + # The Bell case above cannot see which qubit was measured: that state is + # symmetric under exchanging the two, so measuring either gives the same + # answer. H then CNOT then H on qubit 1 breaks the symmetry, so this pins + # that the position argument reaches the measurement rather than the + # method always measuring qubit 0. + # + # H, CNOT, H leaves (|0>|+> + |1>|->)/sqrt(2). Forcing qubit 1 onto the -1 + # eigenstate of X selects the second branch, so the state collapses to + # |1>|->. Measuring qubit 0 instead would leave both qubits in an X + # eigenstate and never produce a |1> on qubit 0. + stab = Stabilizer(2) + stab.apply_hadamard(0) + stab.apply_cnot(0, 1) + stab.apply_hadamard(1) + + outcome = stab.apply_x_measurement(1, measurement_determinism=1) + + ket_one = np.array([[0.0, 0.0], [0.0, 1.0]]) + assert outcome == 1 + assert np.allclose(tableau_to_density(stab.tableau), np.kron(ket_one, _MINUS)) + + +def test_forcing_an_outcome_cannot_override_a_determined_one(): + # measurement_determinism selects among genuinely possible outcomes; it + # does not override a determined one. |-> is the -1 eigenstate of X, so + # measuring it in the X basis yields 1 whatever is asked for. + for determinism in (0, 1, "probabilistic"): + stab = Stabilizer(1) + stab.apply_hadamard(0) + stab.apply_sigmaz(0) + + outcome = stab.apply_x_measurement(0, measurement_determinism=determinism) + + assert outcome == 1, f"determinism={determinism}" + assert np.allclose(tableau_to_density(stab.tableau), _MINUS) diff --git a/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py new file mode 100644 index 00000000..8a1d0295 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_clifford_tableau_labels.py @@ -0,0 +1,82 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +The generator-string view of a Clifford tableau. + +``stabilizer_to_labels`` and ``destabilizer_to_labels`` render half the +tableau as Pauli strings; ``stabilizer_from_labels`` and +``destabilizer_from_labels`` set that half from them. The strings carry no +sign -- the phase and i-phase vectors are separate and the setters leave them +alone -- so a label round trip preserves the symplectic half only, and a +caller restoring a signed state has to carry the phase itself. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau + + +def test_a_fresh_tableau_renders_as_the_all_zero_state(): + # |00> is stabilized by ZI and IZ, with XI and IX as the destabilizers + # pairing with them by row index. + tableau = CliffordTableau(2) + + assert tableau.destabilizer_to_labels() == ["XI", "IX"] + assert tableau.stabilizer_to_labels() == ["ZI", "IZ"] + + +def test_labels_round_trip_through_the_setters(): + tableau = CliffordTableau(2) + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + tableau.destabilizer_from_labels(["ZI", "IX"]) + + assert tableau.stabilizer_to_labels() == ["XX", "ZZ"] + assert tableau.destabilizer_to_labels() == ["ZI", "IX"] + + +def test_the_setters_write_only_their_own_half(): + tableau = CliffordTableau(2) + destabilizers_before = tableau.destabilizer_to_labels() + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + + assert tableau.destabilizer_to_labels() == destabilizers_before + + +def test_the_setters_leave_the_phase_vectors_alone(): + # The strings carry no sign, so the setters cannot restore one. A caller + # rebuilding a signed state has to set the phase itself; this pins that + # the setters do not quietly zero or invent one. + tableau = CliffordTableau(2) + tableau.phase[2] = 1 + tableau.iphase[0] = 1 + + tableau.stabilizer_from_labels(["XX", "ZZ"]) + tableau.destabilizer_from_labels(["ZI", "IX"]) + + assert np.array_equal(tableau.phase, [0, 0, 1, 0]) + assert np.array_equal(tableau.iphase, [1, 0, 0, 0]) + + +def test_a_tableau_renders_both_halves(): + # __str__ is the only caller of the label getters in the library, so this + # is what keeps that path from breaking unnoticed. + rendered = str(CliffordTableau(1)) + + assert "Destabilizers" in rendered + assert "Stabilizer" in rendered + assert "['X']" in rendered + assert "['Z']" in rendered diff --git a/tests/test_state_representation/stabilizer/test_height_functions.py b/tests/test_state_representation/stabilizer/test_height_functions.py index c59191a6..ff2f8997 100644 --- a/tests/test_state_representation/stabilizer/test_height_functions.py +++ b/tests/test_state_representation/stabilizer/test_height_functions.py @@ -12,10 +12,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import networkx as nx import numpy as np import pytest -from graphiq.backends.stabilizer.functions.height import height_func_list +from graphiq.backends.stabilizer.functions.height import ( + height_dict, + height_func_list, + height_function, +) from graphiq.backends.stabilizer.functions.stabilizer import rref from graphiq.backends.stabilizer.tableau import StabilizerTableau @@ -171,3 +176,34 @@ def test_paper_example(): assert (height_list_2 == height_list_expected).all() assert (tableau.x_matrix == x_matrix_expected).all() assert (tableau.z_matrix == z_matrix_expected).all() + + +@pytest.mark.parametrize("qubit_position, expected", [(0, 1), (1, 1), (2, 0)]) +def test_height_function_linear_cluster_3qubit(qubit_position, expected): + # the 3-qubit linear cluster state: x is the identity, z is path_graph(3)'s adjacency matrix + x_matrix = np.eye(3) + z_matrix = nx.to_numpy_array(nx.path_graph(3)) + assert height_function(x_matrix, z_matrix, qubit_position) == expected + + +def test_height_dict_graph_arg_must_be_networkx_graph(): + # a truthy value that is not a networkx graph is rejected explicitly + with pytest.raises(ValueError, match="valid networkx graph"): + height_dict(graph="not a graph") + + +def test_height_dict_needs_graph_or_matrices(): + # an empty (falsy) graph argument falls through to the "neither" branch + with pytest.raises(ValueError, match="Either a graph or both"): + height_dict(graph=[]) + + +def test_height_dict_from_graph_orders_nodes_by_label(): + # Nodes are added in the order 0, 2, 1, 3; height_dict(graph=...) must key its result by + # node label, not by the order the edges were inserted in. + graph = nx.Graph() + graph.add_edges_from([(0, 2), (1, 3)]) + + h_dict = height_dict(graph=graph) + + assert [h_dict[0], h_dict[1], h_dict[2], h_dict[3]] == [1, 2, 1, 0] diff --git a/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py b/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py new file mode 100644 index 00000000..f1ef434e --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_insert_qubit_indexing.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``insert_qubit`` builds the enlarged phase and i-phase vectors with +``np.insert``, whose index list is read against the *original* array, so the +new stabilizer row goes at ``n_qubits + new_position``. That holds at either +end as well as in the middle: appending, which is what ``add_qubit`` does, +indexes the last slot of the original vector rather than one past it. + +These tests build small states that carry a non-zero phase and check the +inserted qubit's position, and the untouched qubits' signs, against the density matrix of the tableau's signed generators. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + add_qubit, + create_n_ket0_state, + insert_qubit, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, + x_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _ket_01(): + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 1) + return tableau + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_appending_an_isolated_qubit_to_a_state_with_a_nonzero_phase(): + # add_qubit is insert_qubit at new_position == n_qubits, the append case: + # the index is read against the original vector, so the new stabilizer + # row's slot is its last, not one past its end. add_qubit rests entirely + # on this, which is why it gets its own test. + tableau = add_qubit(_ket_01()) + assert np.allclose(tableau_to_density(tableau), _expect(0b010, 3)) + + +def test_inserting_a_qubit_in_the_middle_of_a_state_with_a_nonzero_phase(): + # Inserting at position 1 into |01> shifts the old qubit 1 to position 2; + # the inserted row's sign lands with its own qubit, not one to the right. + tableau = insert_qubit(_ket_01(), 1) + assert np.allclose(tableau_to_density(tableau), _expect(0b001, 3)) + + +def test_inserting_a_qubit_at_the_front_of_an_entangled_state(): + tableau = insert_qubit(_bell_state(), 0) + expected = np.kron(_expect(0, 1), tableau_to_density(_bell_state())) + assert np.allclose(tableau_to_density(tableau), expected) diff --git a/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py new file mode 100644 index 00000000..5492d9ef --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_inverse_circuit_reduction.py @@ -0,0 +1,227 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``inverse_circuit`` reduces a stabilizer state to +:math:`|0\\rangle^{\\otimes n}` and hands back the gates that do it. The +blocks after its opening Hadamard block clear the x matrix by column operations, +which needs a pivot on every diagonal entry of that matrix, and only Hadamards +can create one in a column whose generators are all Z. + +It first reduces with a Hadamard block that does not always leave such a pivot, +and keeps that result only if it is exactly the standard all-zero tableau. +Otherwise it starts again from the input with a block that places a pivot on +every column, and raises if that also misses. The states below include one where +the first block runs out of generators before the last column, and one where it +reaches the all-zero state written with other Z generators. + +These tests assert on the returned tableau directly, by exact equality with the +standard all-zero tableau, which needs no reference circuit. +""" + +import numpy as np + +from graphiq.backends.stabilizer.functions.metric import fidelity +from graphiq.backends.stabilizer.functions.rep_conversion import ( + clifford_from_stabilizer, +) +from graphiq.backends.stabilizer.functions.stabilizer import ( + canonical_form, + inverse_circuit, +) +from graphiq.backends.stabilizer.functions.transformation import ( + control_z_gate, + cnot_gate, + hadamard_gate, + phase_gate, + run_circuit, + x_gate, + z_gate, +) +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def _stabilizer_state(labels, phase): + tableau = StabilizerTableau(len(labels)) + tableau.from_labels(labels) + tableau.phase = np.array(phase) + return tableau + + +#: A five-qubit state written in canonical form, chosen so that qubit 1 is +#: stabilized on its own by ``IZIII`` while every other generator's support +#: sits in columns 0, 2, 3 and 4. A per-column walk hands column 1 the lone +#: ``IZIII``, which needs no Hadamard, and then has nothing left with support +#: in column 4 by the time it gets there. +_NO_PIVOT_IN_THE_LAST_COLUMN = ( + ["XIYZZ", "IIZYZ", "IIZZY", "ZIZII", "IZIII"], + [0, 1, 1, 0, 1], +) + + +def test_a_state_with_a_lone_z_generator_reduces_to_the_all_zero_state(): + state = _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + + reduced, _ = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(5) + + +def test_the_circuit_for_a_state_with_a_lone_z_generator_zeroes_it(): + state = _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + + _, circuit_list = inverse_circuit(state.copy()) + + # The circuit is compared in canonical form because it is only required to + # land the state on |0...0>, not to reproduce any particular generators + # for it: the reduction interleaves row operations that never enter the + # circuit, so the two end up in different bases of the same group. + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(5) + ) + + +def test_reducing_the_all_zero_state_asks_for_no_gates(): + reduced, circuit_list = inverse_circuit(StabilizerTableau(4)) + + assert circuit_list == [] + assert reduced == StabilizerTableau(4) + + +def test_the_reducing_circuit_skips_the_qubits_that_are_already_reduced(): + # A Bell pair on qubits 0 and 2, with qubits 1 and 3 in |0> and |1>. + # Qubits 1 and 3 are unentangled and already in a computational basis + # state, so no gate but the sign fix belongs on them. + state = _stabilizer_state( + ["XIXI", "ZIZI", "IZII", "IIIZ"], + [0, 0, 0, 1], + ) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(4) + assert [gate for gate in circuit_list if set(gate[1:]) & {1, 3}] == [("X", 3)] + + +#: The tableau TimeReversedSolver hands to ``inverse_circuit`` to disentangle +#: the emitters, for one random six-qubit case (four photons, two emitters). +#: The first Hadamard block reduces it, so its circuit is the one that block +#: gives. +_EMITTER_STAGE = ( + ["ZIIIII", "IZIIII", "IIZIII", "IIIZII", "IIIIYX", "IIIIZZ"], + [0, 0, 0, 0, 0, 0], +) + + +def test_a_state_the_first_hadamard_block_reduces_keeps_its_circuit(): + state = _stabilizer_state(*_EMITTER_STAGE) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert circuit_list == [ + ("CNOT", 4, 5), + ("CZ", 4, 5), + ("P", 4), + ("H", 4), + ("X", 4), + ] + assert reduced == StabilizerTableau(6) + + +#: A nine-qubit state that the first Hadamard block reduces to |0...0>, but +#: with a generator that is ``Z_7 Z_8`` rather than ``Z_7``. The state is +#: right, but the inner product in the stabilizer metric reads the returned +#: generators row by row and needs each to be the bare ``Z_j``. +_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS = ( + [ + "IXIXIZIIX", + "YXZXIIYXX", + "IIZIZIIII", + "IZIZIIIII", + "YXYXXIYZX", + "YYYXXYYXX", + "XXZXIIXXX", + "IIZIIZIXI", + "IZZIZIIIZ", + ], + [1, 1, 1, 0, 0, 1, 0, 0, 1], +) + + +def test_the_reduced_tableau_is_the_standard_all_zero_tableau(): + state = _stabilizer_state(*_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS) + + reduced, circuit_list = inverse_circuit(state.copy()) + + assert reduced == StabilizerTableau(9) + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(9) + ) + + +def test_fidelity_tells_a_state_from_one_a_single_x_makes_orthogonal(): + labels, phase = _ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS + state = clifford_from_stabilizer(_stabilizer_state(labels, phase)) + + # X on qubit 2 anticommutes with a generator, so it gives an orthogonal + # state. + flipped = x_gate(state.copy(), 2) + # Generator 0 stabilizes the state, so applying it gives the same state. + same = state.copy() + for qubit, pauli in enumerate(labels[0]): + if pauli in "XY": + same = x_gate(same, qubit) + if pauli in "ZY": + same = z_gate(same, qubit) + + assert fidelity(state, flipped) == 0 + assert fidelity(state, same) == 1 + + +def _random_stabilizer_state(rng, n_qubits, n_gates): + tableau = StabilizerTableau(n_qubits) + for _ in range(n_gates): + gate = rng.integers(5) + if gate < 3: + qubit = int(rng.integers(n_qubits)) + tableau = (hadamard_gate, phase_gate, x_gate)[gate](tableau, qubit) + else: + control, target = (int(q) for q in rng.choice(n_qubits, 2, replace=False)) + tableau = (cnot_gate, control_z_gate)[gate - 3](tableau, control, target) + return tableau + + +def _states_to_reduce(): + yield _stabilizer_state(*_NO_PIVOT_IN_THE_LAST_COLUMN) + yield _stabilizer_state(*_ZERO_STATE_WRITTEN_WITH_OTHER_GENERATORS) + yield _stabilizer_state(*_EMITTER_STAGE) + rng = np.random.default_rng(0) + for _ in range(200): + yield _random_stabilizer_state( + rng, int(rng.integers(2, 8)), int(rng.choice([5, 20, 60])) + ) + + +def test_every_state_reduces_in_place_to_the_standard_all_zero_tableau(): + for state in _states_to_reduce(): + n_qubits = state.n_qubits + argument = state.copy() + + reduced, circuit_list = inverse_circuit(argument) + + assert reduced is argument + assert reduced == StabilizerTableau(n_qubits) + assert canonical_form(run_circuit(state.copy(), list(circuit_list))) == ( + StabilizerTableau(n_qubits) + ) diff --git a/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py b/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py new file mode 100644 index 00000000..a4b5fcad --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_measure_x_y_restore_frame.py @@ -0,0 +1,96 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``measure_x`` and ``measure_y`` rotate a qubit into the Z frame, measure it +there, and must rotate it back before returning: the transformation gates +mutate the tableau in place, so a caller holding onto the tableau otherwise +keeps a state that is still expressed in the rotated frame -- which reads +back as a different state in the qubit's own basis. + +These tests build small states, some entangled, and check the caller's +tableau after the call against the density matrix of the tableau's signed generators. Both +outcomes are forced with ``measurement_determinism`` so nothing here depends +on the unseeded global RNG. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + measure_x, + measure_y, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +_PLUS = np.array([[0.5, 0.5], [0.5, 0.5]]) +_MINUS = np.array([[0.5, -0.5], [-0.5, 0.5]]) +_PLUS_Y = np.array([[0.5, -0.5j], [0.5j, 0.5]]) +_MINUS_Y = np.array([[0.5, 0.5j], [-0.5j, 0.5]]) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_zero_leaves_ket_plus(): + tableau = create_n_ket0_state(1) + outcome = measure_x(tableau, 0, measurement_determinism=0) + assert outcome == 0 + assert np.allclose(tableau_to_density(tableau), _PLUS) + + +def test_measuring_ket_0_in_the_x_basis_forced_to_one_leaves_ket_minus(): + tableau = create_n_ket0_state(1) + outcome = measure_x(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), _MINUS) + + +def test_measuring_ket_0_in_the_y_basis_forced_to_zero_leaves_ket_plus_y(): + tableau = create_n_ket0_state(1) + outcome = measure_y(tableau, 0, measurement_determinism=0) + assert outcome == 0 + assert np.allclose(tableau_to_density(tableau), _PLUS_Y) + + +def test_measuring_ket_0_in_the_y_basis_forced_to_one_leaves_ket_minus_y(): + tableau = create_n_ket0_state(1) + outcome = measure_y(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), _MINUS_Y) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_measuring_one_qubit_of_a_bell_pair_in_the_x_basis_leaves_the_other_correlated(): + # Measuring qubit 0 of a Bell pair in the X basis is probabilistic; the + # untouched qubit collapses to the same X eigenstate. Getting this right + # depends on the measured qubit's row landing back in its own frame + # before the surviving correlation is read off it. + tableau = _bell_state() + outcome = measure_x(tableau, 0, measurement_determinism=1) + assert outcome == 1 + assert np.allclose(tableau_to_density(tableau), np.kron(_MINUS, _MINUS)) diff --git a/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py new file mode 100644 index 00000000..2801bd71 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_mixed_stabilizer_reduce.py @@ -0,0 +1,83 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``MixedStabilizer.reduce`` folds identical tableaux in the mixture together, +summing their probabilities. Every match has to be found: comparing a +matched entry and moving on, without also re-checking the entry that shifted +into its place, would leave later duplicates unmerged. + +These tests build mixtures out of copies of the very same tableau, so the +only thing under test is whether every match gets folded in. Folding +different tableaux of the same state (other destabilizer rows or signs, or +another generator order) is covered by +tests/test_noise/test_mixed_stabilizer_reduce.py. +""" + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.state import MixedStabilizer + + +def test_four_identical_tableaux_reduce_to_one(): + tableau = CliffordTableau(2) + mixed = MixedStabilizer([(0.25, tableau.copy()) for _ in range(4)]) + + mixed.reduce() + + assert len(mixed.mixture) == 1 + probability, reduced = mixed.mixture[0] + assert probability == 1.0 + assert reduced == tableau + + +def test_reduce_still_keeps_distinct_tableaux_separate(): + zero_state = CliffordTableau(2) + one_state = CliffordTableau(2) + one_state.phase[one_state.n_qubits] = 1 # a different generator sign + + mixed = MixedStabilizer( + [ + (0.5, zero_state.copy()), + (0.5, one_state.copy()), + ] + ) + + mixed.reduce() + + assert len(mixed.mixture) == 2 + + +def test_reduce_folds_three_matching_entries_in_a_row(): + # A run of three matching entries is exactly where an off-by-one skip + # would drop the third instead of merging it: once the first match is + # removed, the next matching entry shifts into the position such a skip + # would pass over. + tableau = CliffordTableau(2) + other = CliffordTableau(2) + other.phase[other.n_qubits] = 1 + + mixed = MixedStabilizer( + [ + (0.25, tableau.copy()), + (0.25, tableau.copy()), + (0.25, tableau.copy()), + (0.25, other.copy()), + ] + ) + + mixed.reduce() + + assert len(mixed.mixture) == 2 + probabilities = sorted(p for p, _ in mixed.mixture) + assert probabilities == [0.25, 0.75] diff --git a/tests/test_state_representation/stabilizer/test_pauli_string_validation.py b/tests/test_state_representation/stabilizer/test_pauli_string_validation.py new file mode 100644 index 00000000..8025275f --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_pauli_string_validation.py @@ -0,0 +1,142 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``string_to_symplectic`` and ``get_stabilizer_element_by_string`` used to accept any +character, mapping everything that was not x/y/z (case-insensitively) to the identity +instead of rejecting it. A sign character, a stray space, a trailing newline, or a typo +would silently read as an extra identity qubit rather than raising. A ragged list of +labels was read at the width of the first label, so it was silently truncated when the +first label was the shortest and raised IndexError otherwise. A bare string was read as +one single-qubit generator per character, and an empty list raised IndexError. All of +these now raise ValueError. +""" + +import numpy as np +import pytest + +import graphiq.backends.stabilizer.functions.utils as sfu +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def test_string_to_symplectic_accepts_clean_input(): + x_matrix, z_matrix = sfu.string_to_symplectic(["ZI", "IZ"]) + + assert np.array_equal(x_matrix, np.zeros((2, 2))) + assert np.array_equal(z_matrix, np.array([[1, 0], [0, 1]])) + + +def test_string_to_symplectic_lowercase_matches_uppercase(): + lower_x, lower_z = sfu.string_to_symplectic(["zi", "iz"]) + upper_x, upper_z = sfu.string_to_symplectic(["ZI", "IZ"]) + + assert np.array_equal(lower_x, upper_x) + assert np.array_equal(lower_z, upper_z) + + +def test_string_to_symplectic_rejects_non_pauli_character(): + # The critical mutant: same width as a clean label, so only a character check (not a + # length check) can catch it. + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZQ", "IZ"]) + + +def test_string_to_symplectic_rejects_sign_character(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["-ZI", "-IZ"]) + + +def test_string_to_symplectic_rejects_trailing_newline(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZI\n", "IZ\n"]) + + +def test_string_to_symplectic_rejects_embedded_space(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["Z I", "I Z"]) + + +def test_string_to_symplectic_rejects_ragged_lengths(): + with pytest.raises(ValueError): + sfu.string_to_symplectic(["ZI", "IZZ"]) + + +def test_string_to_symplectic_ragged_and_bad_character_raises_value_error_with_message(): + # Both ragged and bad-charactered; must raise the new ValueError (naming the label + # and/or the offending character), not an IndexError from the old truncating parser. + with pytest.raises(ValueError) as exc_info: + sfu.string_to_symplectic(["-ZI", "IZ"]) + + message = str(exc_info.value) + assert "-ZI" in message + + +def test_string_to_symplectic_empty_list_is_rejected(): + # An empty list means "no generators over an unknown number of qubits", which is + # not something a tableau can be built from -- the width is exactly what is missing. + # Returning zero-by-zero matrices instead would assert "over zero qubits", which the + # function has no way to know, and would push the failure into a bare shape assert + # further downstream. + with pytest.raises(ValueError, match=r"at least one generator"): + sfu.string_to_symplectic([]) + + +def test_string_to_symplectic_rejects_bare_string(): + with pytest.raises(ValueError): + sfu.string_to_symplectic("ZI") + + +def test_string_to_symplectic_round_trips_with_symplectic_to_string(): + rng = np.random.default_rng(0) + for _ in range(20): + n_row, n_col = rng.integers(1, 5), rng.integers(1, 5) + x_matrix = rng.integers(0, 2, size=(n_row, n_col)) + z_matrix = rng.integers(0, 2, size=(n_row, n_col)) + labels = sfu.symplectic_to_string(x_matrix, z_matrix) + + round_trip_x, round_trip_z = sfu.string_to_symplectic(labels) + + assert np.array_equal(round_trip_x, x_matrix) + assert np.array_equal(round_trip_z, z_matrix) + + +def test_from_labels_rejects_non_pauli_character(): + tableau = StabilizerTableau(2) + + with pytest.raises(ValueError): + tableau.from_labels(["ZQ", "IZ"]) + + +def test_get_stabilizer_element_by_string_rejects_sign_character(): + with pytest.raises(ValueError): + sfu.get_stabilizer_element_by_string("-ZI") + + +def test_get_stabilizer_element_by_string_accepts_clean_input(): + element = sfu.get_stabilizer_element_by_string("ZI") + + assert element.shape == (4, 4) + + +def test_stabilizer_to_density_rejects_sign_character_labels(): + # A sign adds a column, which the square-table check would also reject; match the + # character check's own message so the test pins the new rejection. + with pytest.raises(ValueError, match="unexpected character"): + rc.stabilizer_to_density(["-ZI", "-IZ"]) + + +def test_stabilizer_to_density_rejects_stray_character_labels(): + with pytest.raises(ValueError): + rc.stabilizer_to_density(["ZQ", "IZ"]) diff --git a/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py new file mode 100644 index 00000000..ea5a7bb0 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_remove_qubit_sign_exactness.py @@ -0,0 +1,198 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``remove_qubit`` measures the traced-out qubit to disentangle it and then +deletes its columns. Applying X to the measured qubit on a 1 outcome puts +Z_q +in the group, at which point deleting the column transfers the outcome's sign +to the surviving generators. These tests hold that guarantee in both the +probabilistic and the deterministic branch, on small hand-checkable states: a +Bell pair, |110>, a case with more than one destabilizer row carrying the +removed qubit's X, and GHZ states. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + partial_trace, + remove_qubit, + z_measurement_gate, +) +from graphiq.backends.stabilizer.functions.linalg import row_sum +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, + x_gate, +) + + +def tableau_to_density(tableau): + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _combine_stabilizer_rows(tableau, row_to_add, target_row): + """ + Overwrite one stabilizer generator with its product with another, + in-place. Multiplying two generators together never changes the group + they generate, only which basis of it is written down -- so this is a + change of generator basis, not a change of state. + + :param tableau: the tableau to rewrite + :type tableau: CliffordTableau + :param row_to_add: stabilizer row index (0-based, within the stabilizer + half) to multiply in + :type row_to_add: int + :param target_row: stabilizer row index (0-based, within the stabilizer + half) to overwrite with the product + :type target_row: int + :return: the same tableau, rewritten + :rtype: CliffordTableau + """ + n_qubits = tableau.n_qubits + ( + tableau.table_x, + tableau.table_z, + tableau.phase, + tableau.iphase, + ) = row_sum( + tableau.table_x, + tableau.table_z, + tableau.phase, + tableau.iphase, + n_qubits + row_to_add, + n_qubits + target_row, + ) + return tableau + + +def test_removing_a_qubit_from_a_bell_pair_forced_to_outcome_zero_leaves_ket_0(): + tableau = remove_qubit(_bell_state(), 0, measurement_determinism=0) + + assert np.allclose(tableau_to_density(tableau), np.diag([1.0, 0.0])) + + +def test_removing_a_qubit_from_a_bell_pair_forced_to_outcome_one_leaves_ket_1(): + # This is the branch the un-fixed code gets wrong: outcome 1 never + # transfers its sign to the surviving qubit, so the result reads back as + # |0> instead of |1>. + tableau = remove_qubit(_bell_state(), 0, measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) + + +def test_removing_a_qubit_from_ket_110_leaves_ket_10(): + # |110> is a product state, so the measurement of qubit 0 is deterministic + # (x_p == 0), remove_qubit's *other* branch. Writing the state down in the + # diagonal basis ['-ZII', '-IZI', '+IIZ'] would pass even with the defect, + # because no surviving generator has support on qubit 0 to begin with, so + # a row product first rewrites it as ['-ZII', '+ZZI', '+IIZ']: ZZI is the + # surviving generator whose sign the un-fixed code drops. + tableau = create_n_ket0_state(3) + tableau = x_gate(tableau, 0) + tableau = x_gate(tableau, 1) + tableau = _combine_stabilizer_rows(tableau, row_to_add=0, target_row=1) + + tableau = remove_qubit(tableau, 0) + + expected = np.zeros((4, 4)) + expected[2, 2] = 1.0 # |10> + assert np.allclose(tableau_to_density(tableau), expected) + + +def _ket_11_via_cnot(): + # CNOT(0, 1) on |10> gives |11>, but -- unlike building |11> with two X + # gates -- it leaves *both* destabilizer rows carrying X on qubit 1: the + # AG rule for CNOT(control=0, target=1) sends destabilizer X_0 -> X_0 X_1 + # while destabilizer X_1 is untouched, so column 1 of the destabilizer + # block ends up nonzero on both rows. That is remove_qubit's + # ``len(non_zero) > 1`` sub-branch (confirmed below), reached here while + # the qubit being removed is deterministically |1>, so the sub-branch is + # actually exercised with a sign to get right. + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_the_more_than_one_destabilizer_sub_branch_is_really_exercised(): + # A check on the fixture itself, not on remove_qubit: if this ever stopped + # being true, the test below would still pass, but for the wrong reason + # -- it would have quietly stopped covering the len(non_zero) > 1 branch. + tableau = _ket_11_via_cnot() + non_zero = [i for i in range(2) if tableau.destabilizer_x[i, 1] != 0] + _, outcome, x_p = z_measurement_gate(tableau.copy(), 1) + + assert len(non_zero) > 1 + assert x_p == 0 # deterministic branch + assert outcome == 1 # the branch that needs the sign fix + + +def test_removing_a_qubit_with_more_than_one_destabilizer_row_on_it(): + tableau = remove_qubit(_ket_11_via_cnot(), 1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) + + +def _ghz3(): + tableau = create_n_ket0_state(3) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + return tableau + + +def test_removing_the_middle_qubit_of_a_ghz3_state_forced_to_zero_leaves_ket_00(): + # A single-qubit Z measurement of a GHZ state is probabilistic (x_p != 0) + # but fully correlated: whichever outcome it lands on forces the other two + # qubits to the same value, so the post-removal state is a product, not a + # Bell pair. + tableau = remove_qubit(_ghz3(), 1, measurement_determinism=0) + + assert np.allclose(tableau_to_density(tableau), np.diag([1.0, 0.0, 0.0, 0.0])) + + +def test_removing_the_middle_qubit_of_a_ghz3_state_forced_to_one_leaves_ket_11(): + # The un-fixed code gets this one wrong: it reads back as |00> too, + # because outcome 1's sign never reaches the surviving generators. + tableau = remove_qubit(_ghz3(), 1, measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 0.0, 0.0, 1.0])) + + +def _ghz4(): + tableau = create_n_ket0_state(4) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + tableau = cnot_gate(tableau, 0, 3) + return tableau + + +def test_partial_trace_of_multiple_qubits_forced_to_one_leaves_ket_1(): + # sfc.partial_trace removes three qubits from a GHZ4 state in one call + # (each a remove_qubit under the hood), which is the multi-qubit removal + # path the solvers' emitter trace uses. Forcing all three removed + # measurements to outcome 1 must leave the surviving qubit at |1>. + tableau = partial_trace(_ghz4(), keep=[0], dims=4 * [2], measurement_determinism=1) + + assert np.allclose(tableau_to_density(tableau), np.diag([0.0, 1.0])) diff --git a/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py b/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py new file mode 100644 index 00000000..ea931ec9 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_reset_sign_exactness.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Resetting a qubit is a Z-basis measurement followed by an X on the target +when the outcome disagrees with the requested state. Nothing else in the +state may move: the measurement has already projected the state, so any +extra write to the measured row's own sign lands on the generator every +other qubit's row still shares. + +These tests build a Bell pair -- the measurement of one of its qubits is +probabilistic, which is the branch that must not special-case a sign +rewrite -- and check both intended states against both forced outcomes with +the density matrix of the tableau's signed generators. ``reset_x`` and ``reset_y`` are built +from ``reset_z`` plus a basis change, so they inherit whatever ``reset_z`` +guarantees; the tests below hold that guarantee for each in turn. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + reset_x, + reset_y, + reset_z, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index): + expected = np.zeros((4, 4)) + expected[index, index] = 1.0 + return expected + + +def test_resetting_a_bell_pair_qubit_forced_to_zero_to_ket_0_leaves_ket_00(): + tableau = reset_z(_bell_state(), 0, intended_state=0, measurement_determinism=0) + assert np.allclose(tableau_to_density(tableau), _expect(0b00)) + + +def test_resetting_a_bell_pair_qubit_forced_to_zero_to_ket_1_leaves_ket_10(): + tableau = reset_z(_bell_state(), 0, intended_state=1, measurement_determinism=0) + assert np.allclose(tableau_to_density(tableau), _expect(0b10)) + + +def test_resetting_a_bell_pair_qubit_forced_to_one_to_ket_1_leaves_ket_11(): + tableau = reset_z(_bell_state(), 0, intended_state=1, measurement_determinism=1) + assert np.allclose(tableau_to_density(tableau), _expect(0b11)) + + +def test_resetting_a_bell_pair_qubit_forced_to_one_to_ket_0_leaves_ket_01(): + # The projection has already happened on the forced-1 branch; a reset to + # |0> here must apply X to the target qubit rather than rewriting the + # measured row's own sign, which would corrupt the surviving generator + # that the untouched qubit still shares. + tableau = reset_z(_bell_state(), 0, intended_state=0, measurement_determinism=1) + assert np.allclose(tableau_to_density(tableau), _expect(0b01)) + + +def test_resetting_a_bell_pair_qubit_in_x_basis_forced_to_one_to_plus_leaves_plus_1(): + # reset_x is reset_z followed by a Hadamard, so it inherits reset_z's + # sign-exactness on the probabilistic branch. + tableau = reset_x(_bell_state(), 0, intended_state=0, measurement_determinism=1) + plus = np.array([[0.5, 0.5], [0.5, 0.5]]) + one = _expect(1)[:2, :2] + assert np.allclose(tableau_to_density(tableau), np.kron(plus, one)) + + +def test_resetting_a_bell_pair_qubit_in_y_basis_forced_to_one_to_plus_i_leaves_plus_i_1(): + # reset_y is reset_z followed by H then S, so it inherits the same + # guarantee. Forcing the outcome to 1 while asking for 0 is what makes this + # discriminate: the two agree in the cell where the reset has nothing to + # do, and only disagree where the correcting X is required. + tableau = reset_y(_bell_state(), 0, intended_state=0, measurement_determinism=1) + plus_i = np.array([[0.5, -0.5j], [0.5j, 0.5]]) + one = _expect(1)[:2, :2] + assert np.allclose(tableau_to_density(tableau), np.kron(plus_i, one)) diff --git a/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py b/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py new file mode 100644 index 00000000..515655f4 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_run_circuit_reverse.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +run_circuit(..., reverse=True) runs the inverse circuit without changing the +caller's gate list. +""" + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.state import Stabilizer + +# the X gate puts a sign in the forward state, so a round trip catches a phase error +_GATES = [("H", 0), ("P", 0), ("CNOT", 0, 1), ("X", 1)] + + +def test_run_circuit_reverse_leaves_the_gate_list_unchanged(): + gates = list(_GATES) + run_circuit(CliffordTableau(2), gates, reverse=True) + assert gates == _GATES + + +def test_run_circuit_reverse_twice_with_one_list_gives_the_same_state(): + gates = list(_GATES) + first = run_circuit(CliffordTableau(2), gates, reverse=True) + second = run_circuit(CliffordTableau(2), gates, reverse=True) + assert first == second + # after two uses the same list still inverts the circuit + forward = run_circuit(CliffordTableau(2), list(_GATES)) + assert forward != CliffordTableau(2) + assert run_circuit(forward, gates, reverse=True) == CliffordTableau(2) + + +def test_stabilizer_apply_circuit_reverse_leaves_the_gate_list_unchanged(): + gates = list(_GATES) + Stabilizer(2).apply_circuit(gates, reverse=True) + assert gates == _GATES diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py index 1f0e3147..fb72a591 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_compiler.py @@ -18,8 +18,11 @@ import graphiq.backends.stabilizer.functions.stabilizer as sfs import graphiq.backends.state_rep_conversion as rc import graphiq.circuit.ops as ops +from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.tableau import * +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.state import QuantumState from graphiq.benchmarks.circuits import ( linear_cluster_4qubit_circuit, linear_cluster_3qubit_circuit, @@ -42,7 +45,7 @@ def test_linear_cluster_state(expected): dims=n_photons * [2], ) tableau = output_stabilizer.tableau.to_stabilizer() - output_state = rc.stabilizer_to_density(tableau.to_labels()) + output_state = rc.stabilizer_to_density(tableau) assert np.allclose(output_state, target_state.rep_data.data) output_tableau = sfs.canonical_form(tableau) @@ -64,7 +67,7 @@ def test_ghz3(): dims=n_photons * [2], ) output_s_tableau = output_state.rep_data.tableau.to_stabilizer() - output_dm = rc.stabilizer_to_density(output_s_tableau.to_labels()) + output_dm = rc.stabilizer_to_density(output_s_tableau) # use representation conversion module to convert to density matrix assert np.allclose(output_dm, target_state.rep_data.data) @@ -86,10 +89,19 @@ def test_compile_circuit(): ) dag.validate() dag.draw_circuit() - compiler = StabilizerCompiler() - compiler.measurement_determinism = 1 - output_stabilizer = compiler.compile(dag) - print(output_stabilizer.rep_data) + # the measurement is random: check each outcome against the DM backend + for determinism in (0, 1): + compiler = StabilizerCompiler() + compiler.measurement_determinism = determinism + output_stabilizer = compiler.compile(dag) + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + assert np.allclose( + rc.stabilizer_to_density( + output_stabilizer.rep_data.tableau.to_stabilizer() + ), + dm_compiler.compile(dag).rep_data.data, + ) def test_compile_circuit2(): @@ -108,7 +120,47 @@ def test_compile_circuit2(): ) dag.validate() dag.draw_circuit() + # the measurement is random: check each outcome against the DM backend + for determinism in (0, 1): + compiler = StabilizerCompiler() + compiler.measurement_determinism = determinism + output_stabilizer = compiler.compile(dag) + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + assert np.allclose( + rc.stabilizer_to_density( + output_stabilizer.rep_data.tableau.to_stabilizer() + ), + dm_compiler.compile(dag).rep_data.data, + ) + + +def test_compile_leaves_the_initial_state_unchanged(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + initial_state = QuantumState(CliffordTableau(2), rep_type="s") + initial_tableau = initial_state.rep_data.data + before = initial_tableau.copy() + + compiled_state = StabilizerCompiler().compile(circuit, initial_state=initial_state) + + assert initial_state.rep_data.data == before + assert compiled_state.rep_data.data is not initial_tableau + assert compiled_state.rep_data.data != before + + +def test_compile_leaves_a_mixed_initial_state_unchanged(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=0) + circuit.add(ops.Hadamard(register=0, reg_type="p")) + initial_state = QuantumState( + [(0.5, CliffordTableau(2)), (0.5, CliffordTableau(2))], rep_type="s", mixed=True + ) + before = [(p, tableau.copy()) for p, tableau in initial_state.rep_data.mixture] compiler = StabilizerCompiler() - compiler.measurement_determinism = 1 - output_stabilizer = compiler.compile(dag) - print(output_stabilizer.rep_data) + # a noisy compile evolves a mixed stabilizer state + compiler.noise_simulation = True + + compiled_state = compiler.compile(circuit, initial_state=initial_state) + + assert initial_state.rep_data.mixture == before + assert compiled_state.rep_data.mixture != before diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py index d03aafad..28eb068e 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_functions.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_functions.py @@ -19,6 +19,7 @@ import numpy as np import graphiq.backends.stabilizer.functions.clifford as sfc import graphiq.backends.stabilizer.functions.metric as sfm +import graphiq.backends.stabilizer.functions.stabilizer as sfs import graphiq.backends.stabilizer.functions.rep_conversion as conversion import graphiq.backends.stabilizer.functions.utils as sfu from graphiq.circuit import ops @@ -31,6 +32,7 @@ from graphiq.backends.stabilizer.compiler import StabilizerCompiler from graphiq.backends.stabilizer.functions.stabilizer import rref from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.state_rep_conversion import stabilizer_to_density from graphiq.metrics import Infidelity @@ -86,8 +88,8 @@ def test_symplectic_product(): def test_echelon_form(n_nodes): g1 = nx.complete_graph(n_nodes) g2 = nx.gnp_random_graph(n_nodes, 0.5) - r_vector1 = np.zeros([n_nodes, 1]) - r_vector2 = np.zeros([n_nodes, 1]) + r_vector1 = np.zeros(n_nodes) + r_vector2 = np.zeros(n_nodes) z1 = nx.to_numpy_array(g1).astype(int) z2 = nx.to_numpy_array(g2).astype(int) x1 = np.eye(n_nodes) @@ -145,6 +147,13 @@ def test_echelon_form(n_nodes): def test_qubit_insertion(): tableau = sfc.create_n_plus_state(4) tableau = sfc.insert_qubit(tableau, 1) + assert tableau == sfc.tensor( + [ + sfc.create_n_plus_state(1), + sfc.create_n_ket0_state(1), + sfc.create_n_plus_state(3), + ] + ) def test_qubit_insertion2(): @@ -156,12 +165,15 @@ def test_qubit_insertion2(): def test_qubit_removal(): tableau = sfc.create_n_plus_state(4) tableau = sfc.remove_qubit(tableau, 1) + # the measurement outcome is random, but every outcome leaves |+>^3 + assert tableau == sfc.create_n_plus_state(3) def test_qubit_insertion_removal(): tableau = sfc.create_n_plus_state(4) tableau = sfc.insert_qubit(tableau, 1) - sfc.remove_qubit(tableau, 1) + tableau = sfc.remove_qubit(tableau, 1) + assert tableau == sfc.create_n_plus_state(4) def test_stabilizer_inner_product(): @@ -198,16 +210,21 @@ def test_stabilizer_fidelity2(): dag.draw_circuit() _, target_state = linear_cluster_3qubit_circuit() metric = Infidelity(target_state) - dm_compiler = DensityMatrixCompiler() - dm_compiler.measurement_determinism = 1 - stab_compiler = StabilizerCompiler() - stab_compiler.measurement_determinism = 1 - state1 = dm_compiler.compile(dag) - state1.partial_trace([*range(3)], 4 * [2]) - print(metric.evaluate(state1, dag)) - state2 = stab_compiler.compile(dag) - state2.partial_trace([*range(3)], 4 * [2]) - print(metric.evaluate(state2, dag)) + # the emitter measurement is random; both backends must agree on each outcome + for determinism, expected in ((0, 0.75), (1, 1.0)): + dm_compiler = DensityMatrixCompiler() + dm_compiler.measurement_determinism = determinism + stab_compiler = StabilizerCompiler() + stab_compiler.measurement_determinism = determinism + state1 = dm_compiler.compile(dag) + state1.partial_trace([*range(3)], 4 * [2]) + infidelity1 = metric.evaluate(state1, dag) + state2 = stab_compiler.compile(dag) + state2.partial_trace([*range(3)], 4 * [2]) + infidelity2 = metric.evaluate(state2, dag) + assert np.isclose(infidelity1, infidelity2) + # outcome 0 leaves the photons at infidelity 0.75, outcome 1 at 1.0 + assert np.isclose(infidelity1, expected) def test_clifford_from_stabilizer(): @@ -216,3 +233,102 @@ def test_clifford_from_stabilizer(): assert tableau1 == tableau2 print(tableau1) print(tableau2) + + +def _mixed_pauli_column(): + # first column holds X, Y, Z and I, one per row + x_matrix = np.array([[1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]) + z_matrix = np.array([[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]]) + return x_matrix, z_matrix + + +@pytest.mark.parametrize( + "pauli_type, expected", + [("x", [0]), ("y", [1]), ("z", [2]), ("X", [0]), ("Y", [1]), ("Z", [2])], +) +def test_one_pauli_type_finder_selects_the_rows_of_that_type(pauli_type, expected): + x_matrix, z_matrix = _mixed_pauli_column() + + assert sfs.one_pauli_type_finder(x_matrix, z_matrix, [0, 0], pauli_type) == expected + + +@pytest.mark.parametrize("pauli_type", ["zz", "q", "i", "I", ""]) +def test_one_pauli_type_finder_rejects_a_non_pauli_type(pauli_type): + x_matrix, z_matrix = _mixed_pauli_column() + + with pytest.raises(ValueError, match=repr(pauli_type)): + sfs.one_pauli_type_finder(x_matrix, z_matrix, [0, 0], pauli_type) + + +def test_the_row_scanners_are_bounded_by_the_row_count(): + # Four rows, two columns: a column-count bound stops after two rows and + # never sees the Z in row 2. + tall_x = np.array([[1, 0], [1, 0], [0, 0], [0, 0]]) + tall_z = np.array([[0, 0], [1, 0], [1, 0], [0, 0]]) + + assert sfs.one_pauli_type_finder(tall_x, tall_z, [0, 0], "z") == [2] + assert sfs.pauli_type_finder(tall_x, tall_z, [0, 0]) == ([0], [1], [2]) + + # Two rows, four columns: a column-count bound indexes past the end. + wide_x = np.array([[1, 0, 0, 0], [0, 0, 0, 0]]) + wide_z = np.array([[0, 0, 0, 0], [1, 0, 0, 0]]) + + assert sfs.one_pauli_type_finder(wide_x, wide_z, [0, 0], "x") == [0] + assert sfs.pauli_type_finder(wide_x, wide_z, [0, 0]) == ([0], [], [1]) + + +@pytest.mark.parametrize( + "shape", [(4, 2), (2, 4), (2,), (2, 2, 2)], ids=["tall", "wide", "1d", "3d"] +) +def test_stabilizer_tableau_rejects_a_non_square_pair(shape): + with pytest.raises(ValueError, match="square 2-D"): + StabilizerTableau([np.zeros(shape, dtype=int), np.zeros(shape, dtype=int)]) + + +def test_stabilizer_to_density_rejects_too_few_generators(): + # two generators on three qubits used to give an (8, 8) matrix of trace 2 + with pytest.raises(ValueError, match="square 2-D"): + stabilizer_to_density(["XZI", "ZXI"]) + + +def test_stabilizer_tableau_accepts_a_square_pair(): + tableau = StabilizerTableau([np.eye(3, dtype=int), np.zeros((3, 3), dtype=int)]) + assert tableau.n_qubits == 3 + assert tableau.table.shape == (3, 6) + + +def test_is_symplectic_on_a_clifford_tableau(): + # a 2n x 2n Clifford table: P must be sized from the columns, not the rows + n = 3 + assert sfu.is_symplectic(np.eye(2 * n, dtype=int)) + table = np.eye(2 * n, dtype=int) + table[0, 1] = 1 # X_0 -> X_0 X_1, which anticommutes with Z_1: not symplectic + assert not sfu.is_symplectic(table) + + +def test_binary_symplectic_product_of_different_row_counts(): + stabilizer = np.hstack([np.zeros((2, 2), dtype=int), np.eye(2, dtype=int)]) # ZI, IZ + clifford = np.eye(4, dtype=int) # XI, IX, ZI, IZ + product = sfu.binary_symplectic_product(stabilizer, clifford) + assert product.shape == (2, 4) + # Z_i anticommutes with X_i only + assert np.array_equal(product, np.array([[1, 0, 0, 0], [0, 1, 0, 0]])) + assert sfu.is_symplectic_self_orthogonal(stabilizer) + + +@pytest.mark.parametrize( + "z_rows", + [ + # one identity generator: Z0, Z1, I + [[1, 0, 0], [0, 1, 0], [0, 0, 0]], + # dependent generators: Z0, Z1, Z0Z1 reduces to Z0, Z1, I + [[1, 0, 0], [0, 1, 0], [1, 1, 0]], + # the only generator on one qubit is the identity + [[0]], + ], +) +def test_rref_rejects_a_generator_that_reduces_to_the_identity(z_rows): + z_matrix = np.array(z_rows) + tableau = StabilizerTableau([np.zeros_like(z_matrix), z_matrix]) + with pytest.raises(AssertionError): + rref(tableau) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_state.py b/tests/test_state_representation/stabilizer/test_stabilizer_state.py index fd0b6e21..12571f08 100644 --- a/tests/test_state_representation/stabilizer/test_stabilizer_state.py +++ b/tests/test_state_representation/stabilizer/test_stabilizer_state.py @@ -12,13 +12,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import pytest + from graphiq.backends.stabilizer.state import * def test_state1(): state = Stabilizer(4) - print(state.tableau.destabilizer_to_labels()) - print(state.tableau.stabilizer_to_labels()) + assert state.tableau.destabilizer_to_labels() == ["XIII", "IXII", "IIXI", "IIIX"] + assert state.tableau.stabilizer_to_labels() == ["ZIII", "IZII", "IIZI", "IIIZ"] state.tableau.stabilizer_z = np.array( [[0, 1, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0]] ) @@ -26,5 +28,107 @@ def test_state1(): [[0, 1, 0, 0], [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0]] ) state.apply_hadamard(1) + # H on qubit 1 swaps its X and Z columns + assert state.tableau.stabilizer_to_labels() == ["IXII", "ZIZI", "IXIZ", "IIZI"] + assert state.tableau.destabilizer_to_labels() == ["XXII", "ZZZI", "IXXZ", "IIZX"] + assert not state.tableau.phase.any() state.tableau.destabilizer_from_labels(["XYXI", "ZYZI", "YIII", "ZZZZ"]) - print(state.tableau.destabilizer) + assert np.array_equal( + state.tableau.destabilizer, + [ + [1, 1, 1, 0, 0, 1, 0, 0], + [0, 1, 0, 0, 1, 1, 1, 0], + [1, 0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 1], + ], + ) + + +def _plus_on_qubit_1(n_qubits): + state = Stabilizer(n_qubits) + state.apply_hadamard(1) + return state + + +def test_trace_out_qubits_discards_the_given_qubits(): + # |0>|+>|0>: tracing out qubit 0 must leave |+>|0>, not keep qubit 0 alone + state = _plus_on_qubit_1(3) + state.trace_out_qubits([0], measurement_determinism=0) + + expected = Stabilizer(2) + expected.apply_hadamard(0) + assert state.n_qubits == 2 + assert state == expected + + +def test_mixed_trace_out_qubits_discards_the_given_qubits(): + state = MixedStabilizer( + [(0.5, _plus_on_qubit_1(3).tableau), (0.5, _plus_on_qubit_1(3).tableau)] + ) + state.trace_out_qubits([0], measurement_determinism=0) + + expected = Stabilizer(2) + expected.apply_hadamard(0) + assert state.n_qubits == 2 + for _, tableau in state.mixture: + assert Stabilizer(tableau) == expected + + +def test_mixed_stabilizer_tableau_raises(): + state = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) + with pytest.raises(TypeError): + state.tableau + + +def test_mixed_stabilizer_eq_does_not_reorder_its_operands(): + t_low = Stabilizer(2).tableau + t_high = _plus_on_qubit_1(2).tableau + a = MixedStabilizer([(0.25, t_low), (0.75, t_high)]) + b = MixedStabilizer([(0.25, t_low.copy()), (0.75, t_high.copy())]) + + assert a == b + assert [p_i for p_i, _ in a.mixture] == [0.25, 0.75] + assert [p_i for p_i, _ in b.mixture] == [0.25, 0.75] + + +@pytest.mark.parametrize("other", [3, "state", None]) +def test_stabilizer_eq_against_a_non_stabilizer(other): + assert (Stabilizer(2) == other) is False + assert (MixedStabilizer([(1.0, Stabilizer(2).tableau)]) == other) is False + + +def test_pure_and_mixed_stabilizers_compare_unequal_without_raising(): + pure = Stabilizer(2) + mixed = MixedStabilizer([(1.0, Stabilizer(2).tableau)]) + assert (pure == mixed) is False + assert (mixed == pure) is False + + +def _plus_state(): + state = Stabilizer(1) + state.apply_hadamard(0) + return state + + +def _cz_graph_state(): + state = Stabilizer(2) + state.apply_hadamard(0) + state.apply_hadamard(1) + state.apply_cz(0, 1) + return state + + +@pytest.mark.parametrize("make_state", [_plus_state, _cz_graph_state]) +@pytest.mark.parametrize("measurement_determinism", ["probabilstic", 2, None]) +def test_measurement_rejects_an_unrecognised_determinism( + make_state, measurement_determinism +): + # qubit 0 has a random Z outcome in both states, so the determinism setting is + # consulted; the rejected call must leave the tableau as it found it + state = make_state() + before = state.tableau.copy() + with pytest.raises(ValueError, match="measurement_determinism"): + state.apply_measurement(0, measurement_determinism=measurement_determinism) + assert np.array_equal(state.tableau.table, before.table) + assert np.array_equal(state.tableau.phase, before.phase) + assert np.array_equal(state.tableau.iphase, before.iphase) diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py b/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py new file mode 100644 index 00000000..64a53c11 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_to_clifford_conversion.py @@ -0,0 +1,74 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Building a ``CliffordTableau`` from a ``StabilizerTableau`` finds the +destabilizers by reducing the state to :math:`|0\\rangle^{\\otimes n}` and +replaying the reducing circuit backwards. The reduction runs in place, so the +conversion works on a copy: the caller's tableau is an input, and it still +holds the state it held before. Solvers hand their working tableau to this +conversion, so a conversion that reduced the caller's own generators would +leave them holding the all-zero state instead. + +The conversion also has to preserve the state, not just leave the argument +alone -- the Clifford tableau's stabilizer half generates the same group the +stabilizer tableau did, signs included. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.rep_conversion import ( + clifford_from_stabilizer, + stabilizer_from_clifford, +) +from graphiq.backends.stabilizer.functions.stabilizer import canonical_form +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +def _signed_ghz3(): + """A GHZ3 state whose generators carry signs, so a lost phase shows up.""" + tableau = StabilizerTableau(3) + tableau.from_labels(["XXX", "ZZI", "IZZ"]) + tableau.phase = np.array([1, 0, 1]) + return tableau + + +def test_the_clifford_tableau_constructor_leaves_the_stabilizer_tableau_alone(): + state = _signed_ghz3() + before = state.copy() + + CliffordTableau(state) + + assert state == before + + +def test_clifford_from_stabilizer_leaves_its_argument_alone(): + state = _signed_ghz3() + before = state.copy() + + clifford_from_stabilizer(state) + + assert state == before + + +def test_the_clifford_tableau_holds_the_state_it_was_built_from(): + state = _signed_ghz3() + # Taken before the conversion so that this holds the conversion to the + # state, independently of whether it leaves the argument alone. + expected = canonical_form(state.copy()) + + recovered = stabilizer_from_clifford(CliffordTableau(state)) + + assert canonical_form(recovered) == expected diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py new file mode 100644 index 00000000..32fce660 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_to_density_sign.py @@ -0,0 +1,102 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``state_rep_conversion.stabilizer_to_density`` reads the phase vector. + +Every expected density matrix below is written out by hand from the state it +describes. +""" + +import networkx as nx +import numpy as np +import pytest + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.state import QuantumState + + +def test_stabilizer_to_density_converts_ket_one_correctly(): + # ['-Z'], phase [1]: the state |1>. Sign-blind, this reads as ['+Z'] = |0>. + tableau = StabilizerTableau([np.array([[0]]), np.array([[1]])], np.array([1])) + + rho = rc.stabilizer_to_density(tableau) + + expected = np.array([[0, 0], [0, 1]], dtype=complex) + assert np.allclose(rho, expected) + + +def test_stabilizer_to_density_handles_a_two_qubit_signed_generator_set(): + # ['ZZ', '-XX']: (|00> - |11>) / sqrt(2). Sign-blind, the second generator's + # sign is dropped and this reads as (|00> + |11>) / sqrt(2) instead. + x_matrix = np.array([[0, 0], [1, 1]]) + z_matrix = np.array([[1, 1], [0, 0]]) + phase = np.array([0, 1]) + tableau = StabilizerTableau([x_matrix, z_matrix], phase) + + rho = rc.stabilizer_to_density(tableau) + + expected = np.zeros((4, 4), dtype=complex) + expected[0, 0] = 0.5 + expected[3, 3] = 0.5 + expected[0, 3] = -0.5 + expected[3, 0] = -0.5 + assert np.allclose(rho, expected) + + +def test_stabilizer_to_density_leaves_a_graph_state_unchanged(): + # Graph states carry an all-zero phase vector, so this is the case the + # fix must not disturb. + graph = nx.Graph([(0, 1), (1, 2), (2, 0)]) + tableau = rc.graph_to_stabilizer(graph)[0][1] + + rho = rc.stabilizer_to_density(tableau) + + assert np.allclose(rho, rc.graph_to_density(graph)) + + +def test_conversion_rejects_a_nonzero_i_phase(): + # StabilizerTableau has no iphase -- CliffordTableau.to_stabilizer() drops + # it -- so the check has to happen on the CliffordTableau. A generator + # carrying a factor of i cannot be read as a bare +1/-1 sign. + tableau = CliffordTableau(1) + tableau.iphase[tableau.n_qubits] = 1 + + state = QuantumState(tableau, rep_type="s") + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") + + +def test_conversion_rejects_a_nonzero_destabilizer_i_phase(): + tableau = CliffordTableau(1) + tableau.iphase[0] = 1 + + state = QuantumState(tableau, rep_type="s") + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") + + +def test_conversion_rejects_a_nonzero_i_phase_in_a_mixture(): + good = CliffordTableau(1) + bad = CliffordTableau(1) + bad.iphase[bad.n_qubits] = 1 + + state = QuantumState([(0.5, good), (0.5, bad)], rep_type="s", mixed=True) + + with pytest.raises(ValueError, match="i phase"): + state.convert_representation("dm") diff --git a/tests/test_state_representation/stabilizer/test_stabilizer_validation.py b/tests/test_state_representation/stabilizer/test_stabilizer_validation.py new file mode 100644 index 00000000..a2f84c56 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_stabilizer_validation.py @@ -0,0 +1,53 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for sfu.is_stabilizer and StabilizerTableau.validate. +""" + +import numpy as np +import pytest + +import graphiq.backends.stabilizer.functions.utils as sfu +from graphiq.backends.stabilizer.tableau import StabilizerTableau + + +@pytest.mark.parametrize( + "labels,expected", + [ + (["ZII", "IZI", "IIZ"], True), + (["XXX", "ZZI", "IZZ"], True), # GHZ + (["ZII", "ZII", "IIZ"], False), # duplicate row -> not independent + (["XII", "ZII", "IIZ"], False), # X and Z on qubit 0 anticommute + (["ZZI", "IZZ", "ZIZ"], False), # commuting, GF(2) rank 2, real rank 3 + (["III", "IZI", "IIZ"], False), # zero row + ], +) +def test_is_stabilizer(labels, expected): + x_matrix, z_matrix = sfu.string_to_symplectic(labels) + table = np.block([x_matrix, z_matrix]).astype(int) + assert sfu.is_stabilizer(table) is expected + + +def test_stabilizer_tableau_validate(): + tableau = StabilizerTableau(3) + tableau.from_labels(["ZII", "IZI", "IIZ"]) + assert tableau.validate() is True + + +def test_stabilizer_tableau_validate_false_for_dependent_rows(): + tableau = StabilizerTableau(3) + tableau.from_labels(["ZII", "ZII", "IIZ"]) + assert tableau.validate() is False + diff --git a/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py b/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py new file mode 100644 index 00000000..cef19313 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_swap_gate_sign_exactness.py @@ -0,0 +1,75 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Conjugating by SWAP permutes qubit labels within each generator, so the +symplectic columns move -- but a sign belongs to the row it is on, not to +either of the columns that row happens to touch, and a swap does not permute +rows. ``swap_gate`` therefore moves the two columns and leaves both phase +vectors exactly where they are. + +These tests build small hand-checkable states and check the swap against +the density matrix of the tableau's signed generators. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + swap_gate, +) +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, + x_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_swapping_ket_10_gives_ket_01(): + tableau = create_n_ket0_state(2) + tableau = x_gate(tableau, 0) + tableau = swap_gate(tableau, 0, 1) + assert np.allclose(tableau_to_density(tableau), _expect(0b01, 2)) + + +def test_swapping_two_qubits_of_an_entangled_three_qubit_state(): + # Swapping qubits 0 and 2 of a GHZ3 state carries a stabilizer + # generator, ZIZ, whose sign must land on the swapped row, not drift + # onto a different generator through a column-index-keyed permutation. + tableau = create_n_ket0_state(3) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + tableau = cnot_gate(tableau, 0, 2) + tableau = x_gate(tableau, 1) + tableau = swap_gate(tableau, 0, 2) + + # H(0), CNOT(0,1), CNOT(0,2), X(1) gives (|010> + |101>) / sqrt(2); swapping + # qubits 0 and 2 leaves it unchanged, since both terms are symmetric in them. + expected = np.zeros((8, 8)) + for i in (0b010, 0b101): + for j in (0b010, 0b101): + expected[i, j] = 0.5 + + assert np.allclose(tableau_to_density(tableau), expected) diff --git a/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py b/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py new file mode 100644 index 00000000..f21ea0fa --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_tableau_phase_shape_guards.py @@ -0,0 +1,221 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Shape guards on the phase and i-phase vectors of both tableau classes. + +The constructors used to replace a phase of the wrong length, or a plain list, +with zeros, so a requested -1 sign silently became +1. They accepted a column-shaped +phase as is, which later gate updates broadcast into an (n, n) array. A 0-d phase +raised a bare IndexError, and CliffordTableau dropped a phase passed alongside a +tableau to copy. The phase setters and ``_reset`` asserted only the length, and +``__eq__`` compared phases by broadcasting. These tests pin the ``ValueError`` each +path now raises, and the shape-strict comparison. +""" + +import warnings + +import numpy as np +import pytest + +from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau + + +def test_stabilizer_tableau_rejects_column_phase(): + with pytest.raises(ValueError): + StabilizerTableau(3, np.ones((3, 1))) + + +def test_clifford_tableau_rejects_column_phase(): + with pytest.raises(ValueError): + CliffordTableau(2, np.ones((4, 1))) + + +def test_stabilizer_tableau_phase_setter_rejects_column_phase(): + tableau = StabilizerTableau(3) + with pytest.raises(ValueError): + tableau.phase = np.ones((3, 1)) + + +def test_clifford_tableau_phase_setter_rejects_column_phase(): + tableau = CliffordTableau(2) + with pytest.raises(ValueError): + tableau.phase = np.ones((4, 1)) + + +def test_stabilizer_tableau_rejects_wrong_length_phase(): + with pytest.raises(ValueError): + StabilizerTableau(3, np.array([1, 1])) + + +def test_clifford_tableau_rejects_wrong_length_phase(): + with pytest.raises(ValueError): + CliffordTableau(2, np.array([1, 1])) + + +def test_stabilizer_tableau_rejects_scalar_phase_with_a_named_error(): + # A 0-d phase used to reach shape[0] and raise a bare IndexError; this + # pins that it is now the same named ValueError as any other bad shape. + with pytest.raises(ValueError): + StabilizerTableau(3, np.array(1)) + + +def test_phase_none_still_yields_zeros(): + assert np.array_equal(StabilizerTableau(3).phase, np.zeros(3)) + assert np.array_equal(StabilizerTableau(3, phase=None).phase, np.zeros(3)) + assert np.array_equal(CliffordTableau(2).phase, np.zeros(4)) + assert np.array_equal(CliffordTableau(2, phase=None).phase, np.zeros(4)) + + +def test_a_python_list_phase_is_accepted(): + assert np.array_equal(StabilizerTableau(3, [1, 0, 1]).phase, [1, 0, 1]) + assert np.array_equal(CliffordTableau(2, [1, 0, 1, 0]).phase, [1, 0, 1, 0]) + + +def test_clifford_tableau_from_clifford_tableau_rejects_a_phase_argument(): + src = CliffordTableau(2) + src.phase = np.array([1, 0, 1, 0]) + passed_phase = np.array([0, 1, 0, 1]) + + with pytest.raises(ValueError): + CliffordTableau(src, passed_phase) + with pytest.raises(ValueError): + CliffordTableau(src, phase=passed_phase) + + +def test_clifford_tableau_from_stabilizer_tableau_rejects_a_phase_argument(): + src = StabilizerTableau(2) + passed_phase = np.array([0, 1, 0, 1]) + + with pytest.raises(ValueError): + CliffordTableau(src, passed_phase) + with pytest.raises(ValueError): + CliffordTableau(src, phase=passed_phase) + + +def test_iphase_setter_rejects_a_column_vector(): + tableau = CliffordTableau(2) + with pytest.raises(ValueError): + tableau.iphase = np.ones((4, 1)) + + +def test_stabilizer_tableau_expand_rejects_a_column_new_phase(): + tableau = StabilizerTableau(2) + new_table = np.hstack([np.zeros((3, 3)), np.eye(3)]) + with pytest.raises(ValueError): + tableau.expand(new_table, np.ones((3, 1))) + + +def test_stabilizer_tableau_shrink_rejects_a_column_new_phase(): + tableau = StabilizerTableau(3) + new_table = np.hstack([np.zeros((2, 2)), np.eye(2)]) + with pytest.raises(ValueError): + tableau.shrink(new_table, np.ones((2, 1))) + + +def test_clifford_tableau_expand_rejects_a_column_new_phase(): + tableau = CliffordTableau(2) + new_table = np.eye(6) + with pytest.raises(ValueError): + tableau.expand(new_table, np.ones((6, 1)), np.zeros(6)) + + +def test_clifford_tableau_shrink_rejects_a_column_new_phase(): + tableau = CliffordTableau(3) + new_table = np.eye(4) + with pytest.raises(ValueError): + tableau.shrink(new_table, np.ones((4, 1)), np.zeros(4)) + + +def test_clifford_tableau_expand_rejects_a_wrong_length_new_iphase(): + tableau = CliffordTableau(2) + new_table = np.eye(6) + with pytest.raises(ValueError): + tableau.expand(new_table, np.zeros(6), np.zeros(3)) + + +def test_stabilizer_tableau_eq_reports_equal_phases_as_equal(): + a = StabilizerTableau(3, np.array([1, 0, 1])) + b = StabilizerTableau(3, np.array([1, 0, 1])) + + assert a == b + + +def test_stabilizer_tableau_eq_reports_unequal_phases_as_unequal(): + a = StabilizerTableau(3, np.array([1, 0, 1])) + b = StabilizerTableau(3, np.array([0, 0, 0])) + + assert a != b + + +def test_stabilizer_tableau_eq_does_not_broadcast_a_column_phase_into_a_match(): + # The constructor and setter now reject a column phase outright, but the + # phase getter returns _phase by identity, so `t.phase.shape = (n, 1)` + # reshapes one in place past both guards. A same-valued column used to + # compare equal to a row by broadcasting the comparison into an (n, n) + # matrix; the shape-strict comparison must report them as unequal. + row = StabilizerTableau(3, np.ones(3)) + column = StabilizerTableau(3, np.ones(3)) + column.phase.shape = (3, 1) + + assert row != column + + +def test_clifford_tableau_eq_reports_equal_phase_and_iphase_as_equal(): + a = CliffordTableau(2) + a.phase = np.array([1, 0, 1, 0]) + a.iphase = np.array([0, 1, 0, 1]) + + b = CliffordTableau(2) + b.phase = np.array([1, 0, 1, 0]) + b.iphase = np.array([0, 1, 0, 1]) + + assert a == b + + +def test_clifford_tableau_eq_reports_unequal_iphase_as_unequal(): + a = CliffordTableau(2) + a.iphase = np.array([1, 0, 0, 0]) + + b = CliffordTableau(2) + + assert a != b + + +def test_clifford_tableau_eq_does_not_broadcast_a_column_iphase_into_a_match(): + # Same in-place reshape as above, on the i-phase vector. + a = CliffordTableau(2) + a.iphase = np.ones(4) + + b = CliffordTableau(2) + b.iphase = np.ones(4) + b.iphase.shape = (4, 1) + + assert a != b + + +def test_stabilizer_tableau_eq_of_different_sizes_is_false_without_a_warning(): + # Comparing phases of different lengths with == is an elementwise comparison + # numpy cannot do; it warns and falls back to a scalar. The shape-strict + # comparison just reports the tableaux as unequal. + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert StabilizerTableau(2) != StabilizerTableau(3) + + +def test_clifford_tableau_eq_of_different_sizes_is_false_without_a_warning(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert CliffordTableau(2) != CliffordTableau(3) diff --git a/tests/test_state_representation/stabilizer/test_tensor_product.py b/tests/test_state_representation/stabilizer/test_tensor_product.py new file mode 100644 index 00000000..caf468d8 --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_tensor_product.py @@ -0,0 +1,110 @@ +# Copyright (c) 2022-2026 Quantum Bridge Technologies Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +``tensor`` returns a fresh tableau holding the block-diagonal product of its +arguments, leaving every argument untouched. The destabilizer blocks, the +stabilizer blocks and both phase vectors are assembled in factor order, with +all the destabilizer phases ahead of all the stabilizer phases. + +These tests check the product against the density matrix of the tableau's signed +generators, +confirm the inputs are read-only, and measure the result so that the +destabilizer half -- which that density matrix does not read -- is covered too. +""" + +import numpy as np + +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + create_n_ket1_state, + tensor, +) +from graphiq.backends.stabilizer.functions.transformation import ( + + z_gate, + cnot_gate, + hadamard_gate, +) + + +def tableau_to_density(tableau): + """The density matrix of a Clifford tableau's stabilizer half, signs included.""" + return rc.stabilizer_to_density(tableau.to_stabilizer()) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def _expect(index, n_qubits): + expected = np.zeros((2**n_qubits, 2**n_qubits)) + expected[index, index] = 1.0 + return expected + + +def test_tensor_of_ket_0_and_ket_1_is_ket_01(): + tableau = tensor([create_n_ket0_state(1), create_n_ket1_state(1)]) + assert np.allclose(tableau_to_density(tableau), _expect(0b01, 2)) + + +def test_tensor_carries_the_sign_of_each_factor(): + # ket1 carries phase [0, 1] -- the sign the aliasing-free rebuild has to + # place on the right qubit's row, not the left one's. + tableau = tensor([create_n_ket1_state(1), create_n_ket0_state(1)]) + assert np.allclose(tableau_to_density(tableau), _expect(0b10, 2)) + + +def test_tensor_of_three_factors_including_an_entangled_one(): + tableau = tensor([create_n_ket1_state(1), _bell_state(), create_n_ket0_state(1)]) + expected = np.kron( + np.kron(_expect(1, 1), tableau_to_density(_bell_state())), + _expect(0, 1), + ) + assert np.allclose(tableau_to_density(tableau), expected) + + +def test_tensor_lays_out_the_destabilizer_half_and_the_phases(): + # That density matrix reads the stabilizer half and the stabilizer phases only, so + # every test above is blind to the destabilizer blocks and to the + # destabilizer phases. This pins both against a hand-worked expectation. + # + # Z|0> is one qubit whose destabilizer is -X and whose stabilizer is +Z, so + # it is the smaller factor AND it carries the only non-zero phase, in the + # destabilizer half. The Bell pair's generators are ZI, IX (destabilizers) + # and XX, ZZ (stabilizers), all with a + sign. Tensored in that order the + # three qubits therefore read -XII, IZI, IIX / +ZII, IXX, IZZ. + tableau = tensor([z_gate(create_n_ket0_state(1), 0), _bell_state()]) + + assert np.array_equal(tableau.destabilizer_x, [[1, 0, 0], [0, 0, 0], [0, 0, 1]]) + assert np.array_equal(tableau.destabilizer_z, [[0, 0, 0], [0, 1, 0], [0, 0, 0]]) + assert np.array_equal(tableau.stabilizer_x, [[0, 0, 0], [0, 1, 1], [0, 0, 0]]) + assert np.array_equal(tableau.stabilizer_z, [[1, 0, 0], [0, 0, 0], [0, 1, 1]]) + # Every destabilizer phase ahead of every stabilizer phase, factor by + # factor -- not the two halves of each factor kept together. + assert np.array_equal(tableau.phase, [1, 0, 0, 0, 0, 0]) + assert not np.any(tableau.iphase) + + +def test_tensor_does_not_mutate_its_arguments(): + first, second = create_n_ket0_state(1), create_n_ket1_state(1) + first_before, second_before = first.copy(), second.copy() + + tensor([first, second]) + + assert first == first_before + assert second == second_before diff --git a/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py b/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py new file mode 100644 index 00000000..6daec09f --- /dev/null +++ b/tests/test_state_representation/stabilizer/test_z_measurement_destabilizer_row_copy.py @@ -0,0 +1,99 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +When a probabilistic Z-basis measurement replaces a stabilizer generator with +the fresh outcome, the generator it displaces has to move somewhere: it +becomes the new destabilizer partner for the measured qubit. Aaronson-Gottesman +say to set that whole destabilizer row equal to the displaced generator's row, +sign included, and the code does: the destabilizer row's phase and i-phase +always end up matching the displaced generator's own, never whatever those +entries happened to hold going into the measurement (typically leftover +arithmetic from combining that row with others). + +These tests build tableaux by hand so the displaced generator's phase and +i-phase are known quantities, and check that the destabilizer row ends up +carrying exactly them -- copying only the symplectic (x/z) bits and leaving +the sign behind would fail every one of them. +""" + +import numpy as np + +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.clifford import ( + create_n_ket0_state, + z_measurement_gate, +) +from graphiq.backends.stabilizer.functions.transformation import ( + cnot_gate, + hadamard_gate, +) + + +def _bell_state(): + tableau = create_n_ket0_state(2) + tableau = hadamard_gate(tableau, 0) + tableau = cnot_gate(tableau, 0, 1) + return tableau + + +def test_the_displaced_generators_sign_moves_to_the_destabilizer_row(): + # (|00> - |11>) / sqrt(2): stabilizers -XX, +ZZ. Measuring qubit 0 finds + # a single stabilizer row with x-support there (the XX row), so the + # row-sum loop above the copy never touches it -- its phase is exactly + # what it was before the measurement, a sign of 1. A destabilizer row + # that fails to inherit it stays at its initial 0. + tableau = _bell_state() + tableau.phase[2] = 1 # the XX stabilizer row becomes "-XX" + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.phase[x_p - measured.n_qubits] == 1 + + +def test_the_displaced_generators_i_phase_moves_to_the_destabilizer_row(): + # A hand-built Bell state in a basis where the destabilizer partner of + # the measured stabilizer row shares its x-support: x-column [1,0,1,0] + # for qubit 0 puts both the destabilizer row and the stabilizer row (XX) + # in the row-sum loop, which combines them and leaves the destabilizer + # row's i-phase at whatever that combination produced -- 1 here -- unless + # the copy overwrites it with the displaced generator's own i-phase, 0. + table = np.array( + [ + [1, 1, 1, 0], # destabilizer 0: YX + [0, 1, 0, 0], # destabilizer 1: IX + [1, 1, 0, 0], # stabilizer 0: XX + [0, 0, 1, 1], # stabilizer 1: ZZ + ] + ) + tableau = CliffordTableau(table) + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.iphase[x_p - measured.n_qubits] == 0 + + +def test_the_fresh_generator_row_carries_no_i_phase(): + # The measured row is overwritten with a fresh +/-Z_a generator, which has + # a bare +1/-1 sign and no factor of i. Its i-phase entry should read 0 + # regardless of what it held going into the measurement. + tableau = _bell_state() + tableau.iphase[2] = 1 # a value the row's own bookkeeping should not keep + + measured, outcome, x_p = z_measurement_gate(tableau, 0, measurement_determinism=0) + + assert x_p == 2 + assert measured.iphase[x_p] == 0 diff --git a/tests/test_state_representation/test_graph_LC_equivalence.py b/tests/test_state_representation/test_graph_LC_equivalence.py index 061ebf65..1d8f8b7f 100644 --- a/tests/test_state_representation/test_graph_LC_equivalence.py +++ b/tests/test_state_representation/test_graph_LC_equivalence.py @@ -12,11 +12,18 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import time +from itertools import combinations, permutations + import matplotlib.pyplot as plt import pytest from graphiq.benchmarks import graph_states as gs from graphiq.backends.lc_equivalence_check import * +import graphiq.backends.lc_equivalence_check as lcc +import graphiq.backends.stabilizer.functions.linalg as slinalg +from graphiq.backends.graph.state import Graph +from graphiq.backends.lc_equivalence_check import _is_valid_clifford from graphiq.backends.stabilizer.functions.local_cliff_equi_check import ( lc_check, state_converter_circuit, @@ -25,6 +32,8 @@ get_stabilizer_tableau_from_graph, ) from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.backends.state_rep_conversion import state_to_graph def _tester(n): @@ -136,7 +145,9 @@ def test_random_state_converter(seed): convert one to the other. """ - g = nx.random_tree(12, seed) + # nx.random_tree was removed in networkx 3.4 in favour of random_labeled_tree + random_tree = getattr(nx, "random_tree", None) or nx.random_labeled_tree + g = random_tree(12, seed=seed) # also use the parameter seed to determine which node to apply local complementation on gg = local_comp_graph(g, seed) tab1 = get_stabilizer_tableau_from_graph(g) @@ -166,3 +177,566 @@ def test_random_state_converter(seed): c2 = state_converter_circuit(random_tab1, random_tab2, validate=True) # check if output is not None assert c1 and c2 + + +def test_state_to_graph_matrix_input_matches_graph_input(): + # lc_check must give the same answer whether the states are passed as + # graphs or as their adjacency matrices; both go through state_to_graph. + star4 = nx.star_graph(3) + k4 = nx.complete_graph(4) + + result_from_graphs = lc_check(star4, k4) + result_from_matrices = lc_check(nx.to_numpy_array(star4), nx.to_numpy_array(k4)) + + assert result_from_graphs[0] is True + assert result_from_matrices == result_from_graphs + + graph, tableau, gate_list = state_to_graph(nx.to_numpy_array(star4)) + assert isinstance(tableau, StabilizerTableau) + assert gate_list == [] + + +@pytest.mark.parametrize("mode", ["deterministic", "random"]) +def test_is_lc_equivalent_star4_and_k4(mode): + # a 4-node star and the complete graph on 4 nodes are LC equivalent + star4 = nx.to_numpy_array(nx.star_graph(3)).astype(int) + k4 = nx.to_numpy_array(nx.complete_graph(4)).astype(int) + + equivalent, solution = is_lc_equivalent(star4, k4, mode=mode, seed=0) + + assert equivalent + n_nodes = star4.shape[0] + assert _is_valid_clifford(solution.reshape(4 * n_nodes, 1)) + + +def test_is_lc_equivalent_p4_and_its_complement_are_not_equivalent(): + # P4 and its complement are ruled out by the rank check in is_lc_equivalent + # (the coefficient matrix has rank 4*n_nodes) before either search mode is + # read, so this pair does not exercise the "deterministic"/"random" dispatch. + p4 = nx.to_numpy_array(nx.path_graph(4)).astype(int) + p4_complement = nx.to_numpy_array(nx.complement(nx.path_graph(4))).astype(int) + + equivalent, solution = is_lc_equivalent( + p4, p4_complement, mode="deterministic", seed=0 + ) + + assert not equivalent + assert solution is None + + +@pytest.mark.parametrize("mode", ["deterministic", "random"]) +def test_is_lc_equivalent_star5_and_star5_plus_edge_are_not_equivalent(mode): + # star5 and star5 with one extra edge among its leaves pass the rank check + # (rank 15 < 4*n_nodes = 20) with a solution-basis dimension of 5, so the + # "deterministic"/"random" dispatch in is_lc_equivalent is actually reached, + # and both searches correctly reject. + star5 = nx.to_numpy_array(nx.star_graph(4)).astype(int) + star5_plus_edge_graph = nx.star_graph(4) + star5_plus_edge_graph.add_edge(1, 2) + star5_plus_edge = nx.to_numpy_array(star5_plus_edge_graph).astype(int) + + equivalent, solution = is_lc_equivalent(star5, star5_plus_edge, mode=mode, seed=0) + + assert not equivalent + assert solution is None + + +def test_find_lc_operations_maps_first_graph_to_second(): + # These two 5-node graphs are LC equivalent via a multi-step local + # complementation sequence (not a single node, unlike star4/k4), which is + # needed to tell apart which of the two adjacency matrices the sequence + # is built from. + g1 = nx.Graph([(0, 1), (0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (2, 4)]) + g2 = nx.Graph([(0, 1), (0, 2), (1, 3), (2, 4), (3, 4)]) + adj_matrix1 = nx.to_numpy_array(g1).astype(int) + adj_matrix2 = nx.to_numpy_array(g2).astype(int) + + op_list = find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0) + + # find_lc_operations returns the local complementations that map the FIRST + # graph onto the second; applying them to the first graph yields the second. + g = nx.from_numpy_array(adj_matrix1) + for node in op_list: + g = local_comp_graph(g, node) + assert np.array_equal(nx.to_numpy_array(g).astype(int), adj_matrix2) + + +@pytest.mark.parametrize( + "edges1, edges2", + [ + ( + [(0, 1), (1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + [(0, 1), (1, 4), (2, 4), (3, 4)], + ), + ( + [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4)], + [(0, 3), (0, 4), (1, 2), (2, 3), (2, 4), (3, 4)], + ), + ], +) +def test_find_lc_operations_are_built_on_the_first_graph(edges1, edges2): + # For these pairs, building the sequence on the second graph's adjacency + # matrix fails outright, so the test fails if find_lc_operations reverts to + # it (the pair in the test above yields a valid sequence either way). + adj_matrix1 = nx.to_numpy_array(nx.Graph(edges1), nodelist=range(5)).astype(int) + adj_matrix2 = nx.to_numpy_array(nx.Graph(edges2), nodelist=range(5)).astype(int) + + op_list = find_lc_operations(adj_matrix1, adj_matrix2, mode="deterministic", seed=0) + + g = nx.from_numpy_array(adj_matrix1) + for node in op_list: + g = local_comp_graph(g, node) + assert np.array_equal( + nx.to_numpy_array(g, nodelist=range(5)).astype(int), adj_matrix2 + ) + + +def _adjacency(graph): + """ + Adjacency matrix of a networkx graph with a fixed, sorted node order, so both sides of a + comparison index their nodes the same way. + """ + return nx.to_numpy_array(graph, nodelist=sorted(graph.nodes)).astype(int) + + +def _solution_basis(z1, z2): + """ + Mirrors the row-reduction/basis-finding steps of ``is_lc_equivalent`` on the whole (not + per-component) system, so a test can enumerate every candidate solution independently of the + algorithm under test. + """ + coeff_matrix = lcc._coeff_maker(z1, z2) + reduced_coeff_matrix, _, last_nonzero_row_index = slinalg.row_reduction( + coeff_matrix, coeff_matrix * 0 + ) + n_nodes = np.shape(z1)[0] + rank = last_nonzero_row_index + 1 + if rank >= 4 * n_nodes: + return None + reduced_coeff_matrix = np.array([row for row in reduced_coeff_matrix if row.any()]) + col_list = lcc._col_finder(reduced_coeff_matrix) + return lcc._solution_basis_finder(reduced_coeff_matrix, col_list) + + +def _flat(sol): + """ + Reshapes an (n, 2, 2) Clifford-operator array into the (4n, 1) vector ``_is_valid_clifford`` + expects. + """ + return sol.reshape(-1, 1) + + +def test_disconnected_equivalent_pairs(): + """ + Minimal failing cases: the pair-sum search used to run on the whole graph instead of per + connected component, so valid vectors that need contributions from more than one component's + basis were missed. + """ + # (a) two isolated vertices vs itself: two single-node components, each trivially equivalent + # to itself via the identity. + z_two_isolated = np.zeros((2, 2)) + equivalent, solution = is_lc_equivalent(z_two_isolated, z_two_isolated) + assert equivalent + identity = np.array([[1, 0], [0, 1]]) + assert np.array_equal(solution, np.stack([identity, identity])) + + # (b) two disjoint K3 vs two disjoint 3-stars: LC equivalent component-wise, but the pair-sum + # search over the combined (6-node) system missed it. + k3 = nx.complete_graph(3) + star3 = nx.star_graph(2) + two_k3 = nx.disjoint_union(k3, k3) + two_star3 = nx.disjoint_union(star3, star3) + z1, z2 = _adjacency(two_k3), _adjacency(two_star3) + equivalent, solution = is_lc_equivalent(z1, z2) + assert equivalent + g_list = lc_graph_operations(z1, solution) + g_new = two_k3 + for i in g_list: + g_new = local_comp_graph(g_new, i) + assert nx.is_isomorphic(g_new, two_star3), "found graphs are not LC equivalent" + assert np.array_equal( + nx.to_numpy_array(g_new), z2 + ), "found graphs are not LC equivalent" + + # (c) the same pair with an added isolated vertex on both sides (three components). + two_k3_iso = nx.disjoint_union(two_k3, nx.empty_graph(1)) + two_star3_iso = nx.disjoint_union(two_star3, nx.empty_graph(1)) + z1_iso, z2_iso = _adjacency(two_k3_iso), _adjacency(two_star3_iso) + equivalent, solution = is_lc_equivalent(z1_iso, z2_iso) + assert equivalent + g_list = lc_graph_operations(z1_iso, solution) + g_new = two_k3_iso + for i in g_list: + g_new = local_comp_graph(g_new, i) + assert nx.is_isomorphic(g_new, two_star3_iso), "found graphs are not LC equivalent" + assert np.array_equal( + nx.to_numpy_array(g_new), z2_iso + ), "found graphs are not LC equivalent" + + # (d) the empty graph (0 nodes) vs itself. Only written after the fix: on unfixed code, + # slinalg.row_reduction never terminates on a 0x0 matrix, and there is no timeout binary here + # to cut a hung test. + z_empty = np.zeros((0, 0), dtype=int) + equivalent, solution = is_lc_equivalent(z_empty, z_empty) + assert equivalent + assert solution.shape == (0, 2, 2) + + +def test_component_partition_mismatch_is_false(): + """ + Two graphs whose adjacency matrices are the same size but partition into different connected + components can never be LC equivalent (local complementation preserves connected components as + vertex sets, Bouchet Section 6), and the check must reject them before running the linear-algebra + search at all. + """ + path4 = nx.path_graph(4) + two_edges = nx.disjoint_union(nx.path_graph(2), nx.path_graph(2)) + equivalent, solution = is_lc_equivalent(_adjacency(path4), _adjacency(two_edges)) + assert not equivalent + assert solution is None + + k3_plus_isolated = nx.disjoint_union(nx.complete_graph(3), nx.empty_graph(1)) + equivalent, solution = is_lc_equivalent( + _adjacency(k3_plus_isolated), _adjacency(path4) + ) + assert not equivalent + assert solution is None + + +@pytest.mark.parametrize("seed", [0, 1, 2, 3]) +def test_deterministic_seeded_sweep_keeps_disconnected_pairs(seed): + """ + Sweeps G(n, 0.5) draws (edgeless graphs, the canonical failing shape, are not skipped) against + an LC-equivalent partner reached by local complementation, and checks that the returned + Clifford operator actually solves the coefficient system, without redrawing on failure (the + redraw in the pre-existing ``_tester`` helper would hide exactly this bug). + """ + rng = np.random.default_rng(seed) + disconnected_draws = 0 + for n in range(4, 9): + for _ in range(10): + g = nx.gnp_random_graph(n, 0.5, seed=rng) + if not nx.is_connected(g): + disconnected_draws += 1 + lc_graphs, _ = gs.random_lc_equivalent(Graph(g.copy()), 1, 3, np_rng=rng) + partner = lc_graphs[0].data + z1 = _adjacency(g) + z2 = _adjacency(partner) + equivalent, sol = is_lc_equivalent(z1, z2, mode="deterministic") + assert ( + equivalent + ), f"seed={seed}, n={n}: partner via local complementation not found" + assert lcc._is_valid_clifford(_flat(sol)) + assert np.all((lcc._coeff_maker(z1, z2) @ _flat(sol)) % 2 == 0) + # Measured (this implementation): seed 0 -> 12, seed 1 -> 9, seed 2 -> 8, seed 3 -> 9 (of 50 + # draws each). G(n, 0.5) at n = 4 is disconnected about 40% of the time, so the sweep should + # not go vacuous; a count this low would mean the draws stopped exercising the failing shape. + assert ( + disconnected_draws >= 5 + ), f"seed {seed}: only {disconnected_draws} disconnected draws, sweep may be vacuous" + + +@pytest.mark.parametrize("seed", [0, 1]) +def test_deterministic_matches_exhaustive_oracle(seed): + """ + Compares ``is_lc_equivalent`` against a brute-force oracle (every candidate in the whole, + not per-component, solution space) on a mix of LC-equivalent and independently-drawn pairs, so + a regression that only breaks some shapes of input cannot hide behind a hand-picked example. + """ + + def oracle_equivalent(basis): + if basis is None: + return False + k = len(basis) + for bits in range(2**k): + combo = np.zeros_like(basis[0]) + for j in range(k): + if (bits >> j) & 1: + combo = (combo + basis[j]) % 2 + if lcc._is_valid_clifford(combo): + return True + return False + + rng = np.random.default_rng(seed) + start = time.time() + saw_nonequivalent_with_dim = False + saw_disconnected_equivalent = False + for n in range(4, 7): + for i in range(20): + g1 = nx.gnp_random_graph(n, 0.5, seed=rng) + if i < 12: # 60% LC partners + lc_graphs, _ = gs.random_lc_equivalent( + Graph(g1.copy()), 1, 3, np_rng=rng + ) + g2 = lc_graphs[0].data + else: # 40% independent draws + g2 = nx.gnp_random_graph(n, 0.5, seed=rng) + z1, z2 = _adjacency(g1), _adjacency(g2) + basis = _solution_basis(z1, z2) + dim = 0 if basis is None else len(basis) + if dim > 12: # keeps 2**dim well under 10 s + continue + equivalent, _ = is_lc_equivalent(z1, z2, mode="deterministic") + assert equivalent == oracle_equivalent(basis), (seed, n, i, dim) + if not equivalent and dim >= 1: + saw_nonequivalent_with_dim = True + if equivalent and not (nx.is_connected(g1) and nx.is_connected(g2)): + saw_disconnected_equivalent = True + wall_clock = time.time() - start + # Measured (this implementation): seed 0 -> 0.08 s, seed 1 -> 0.07 s. + assert wall_clock < 10 + assert saw_nonequivalent_with_dim + assert saw_disconnected_equivalent + + +def test_candidates_include_single_basis_vectors(monkeypatch): + """ + ``combinations(basis, 2)`` alone omits the k single basis vectors that Bouchet (4.4)'s "sum of + at most two vectors of B" also includes; this is the only test that can catch a regression + that removes the singles again (a differential sweep of 3000 pairs found no seed where the + singles change the boolean answer). + """ + star8 = nx.star_graph(7) # 8 nodes; dim(solution basis) = 9, measured, connected. + z = _adjacency(star8) + basis = _solution_basis(z, z) + k = len(basis) + + recorded = [] + + def fake_is_valid_clifford(vector): + recorded.append(vector.copy()) + return False # never stop early, so every candidate is recorded + + monkeypatch.setattr(lcc, "_is_valid_clifford", fake_is_valid_clifford) + equivalent, solution = is_lc_equivalent(z, z, mode="deterministic") + assert not equivalent + assert solution is None + + assert len(recorded) == k + k * (k - 1) // 2 + # The pairwise sums come first, in the order combinations gives them, so a graph that a + # pairwise sum solves gets the same solution it got before the singles were added. + n_pairs = k * (k - 1) // 2 + for candidate, (b1, b2) in zip(recorded[:n_pairs], combinations(basis, 2)): + assert np.array_equal(candidate, (b1 + b2) % 2) + singles = recorded[n_pairs:] + remaining_basis = list(basis) + for single in singles: + match = next( + (i for i, b in enumerate(remaining_basis) if np.array_equal(b, single)), + None, + ) + assert ( + match is not None + ), "recorded single basis-vector candidate not found in basis" + remaining_basis.pop(match) + assert remaining_basis == [] + + +def test_a_graph_a_pairwise_sum_solves_keeps_that_solution(): + # K4 against itself: its solution basis has 5 vectors, one single vector is valid and so are + # 3 pairwise sums. The first valid pairwise sum is the solution returned. + k4 = _adjacency(nx.complete_graph(4)) + + equivalent, solution = is_lc_equivalent(k4, k4, mode="deterministic") + + assert equivalent + assert solution.tolist() == [ + [[0, 1], [1, 0]], + [[1, 0], [0, 1]], + [[0, 1], [1, 0]], + [[1, 0], [0, 1]], + ] + + +def test_symmetric_connected_graphs(): + """ + Regression guard, already passing before this change: a handful of large, connected, + boundary-shaped comparisons (star, complete, complete-bipartite, path graphs) that should stay + fast and correct. + """ + start = time.time() + star30 = nx.star_graph(29) + k_15_15 = nx.complete_bipartite_graph(15, 15) + equivalent, _ = is_lc_equivalent(_adjacency(star30), _adjacency(star30)) + assert equivalent + equivalent, _ = is_lc_equivalent(_adjacency(k_15_15), _adjacency(k_15_15)) + assert equivalent + + star20 = nx.star_graph(19) + k20 = nx.complete_graph(20) + equivalent, _ = is_lc_equivalent(_adjacency(star20), _adjacency(k20)) + assert equivalent + + k_10_10 = nx.complete_bipartite_graph(10, 10) + equivalent, _ = is_lc_equivalent(_adjacency(star20), _adjacency(k_10_10)) + assert not equivalent + + star40 = nx.star_graph(39) + path40 = nx.path_graph(40) + equivalent, _ = is_lc_equivalent(_adjacency(star40), _adjacency(path40)) + assert not equivalent + + wall_clock = time.time() - start + # Measured (this implementation): 0.19 s. + assert wall_clock < 20 + + +def test_unknown_mode_raises_before_early_returns(): + """ + ``mode`` used to be validated only in the trailing ``else`` of the old single-function + implementation, after the early returns for full rank and for a small solution-space + dimension, so ``mode="bogus"`` silently returned a result on most inputs instead of raising. + """ + # (a) two seeded random 6-node graphs whose system is full rank (no solution space at all). + g1 = nx.gnp_random_graph(6, 0.5, seed=0) + g2 = nx.gnp_random_graph(6, 0.5, seed=1000) + z1, z2 = _adjacency(g1), _adjacency(g2) + assert _solution_basis(z1, z2) is None + with pytest.raises(ValueError): + is_lc_equivalent(z1, z2, mode="bogus") + + # (b) path_8 vs itself: dimension 4, hits the exhaustive (< 5) branch's early return. + path8 = nx.path_graph(8) + z_path8 = _adjacency(path8) + with pytest.raises(ValueError): + is_lc_equivalent(z_path8, z_path8, mode="bogus") + + # (c) star_8 vs itself: dimension 9, green today (guard). + star8 = nx.star_graph(7) + z_star8 = _adjacency(star8) + with pytest.raises(ValueError): + is_lc_equivalent(z_star8, z_star8, mode="bogus") + + # (d) two isolated vertices vs itself: dimension 6 as a whole graph before the split, now two + # one-node components of dimension 3 each; green today (guard). + z_two_isolated = np.zeros((2, 2)) + with pytest.raises(ValueError): + is_lc_equivalent(z_two_isolated, z_two_isolated, mode="bogus") + + +def test_state_converter_circuit_on_disconnected_pair(): + """ + Before this change, ``lc_check`` on a disconnected LC-equivalent pair returned ``(False, [])``: + ``converter_gate_list`` asserted ``is_lc_equivalent``'s (buggy) False result and the bare + ``except`` in ``lc_check`` swallowed the AssertionError. + """ + two_k3 = nx.disjoint_union(nx.complete_graph(3), nx.complete_graph(3)) + two_star3 = nx.disjoint_union(nx.star_graph(2), nx.star_graph(2)) + result, gate_list = lc_check(two_k3, two_star3) + assert result + circuit = state_converter_circuit(two_k3, two_star3, validate=True) + assert circuit is not None + + two_isolated = nx.empty_graph(2) + circuit2 = state_converter_circuit(two_isolated, two_isolated, validate=True) + assert circuit2 is not None + + +def _expected_iso_matrices(graph): + """ + Adjacency matrices iso_graph_finder should return for graph, built without calling it: + entry k is graph relabelled by the k-th permutation of its sorted labels, with each + label then replaced by its rank in sorted order. + """ + labels = sorted(graph) + rank = {label: i for i, label in enumerate(labels)} + n = len(labels) + expected = [] + for perm in permutations(labels): + relabel = dict(zip(labels, perm)) + matrix = np.zeros((n, n)) + for u, v in graph.edges: + matrix[rank[relabel[u]], rank[relabel[v]]] = 1 + matrix[rank[relabel[v]], rank[relabel[u]]] = 1 + expected.append(matrix) + return expected + + +@pytest.mark.parametrize( + "edges", + [ + [(1, 2), (2, 3)], # labels 1..n + [(3, 2), (2, 1), (1, 4)], # labels 1..n, inserted out of sorted order + [("c", "b"), ("b", "a"), ("a", "d")], # strings, inserted out of sorted order + [(0, 5), (5, 2), (2, 9)], # gaps, inserted out of sorted order + [ + (-2, -1), + (-1, 0), + (0, 3), + ], # negative labels, -1 and 3 on the same row if unmapped + [(-5, 0), (0, 1)], # a label below -n + ], +) +def test_iso_graph_finder_accepts_labels_other_than_0_to_n_minus_1(edges): + graph = nx.Graph(edges) + result = iso_graph_finder(graph) + expected = _expected_iso_matrices(graph) + assert len(result) == len(expected) + assert all(list(g.nodes) == list(range(len(graph))) for g in result) + assert all( + np.array_equal(nx.to_numpy_array(g), e) for g, e in zip(result, expected) + ) + + +def test_iso_equal_check_accepts_labels_other_than_0_to_n_minus_1(): + k3 = nx.Graph([(1, 2), (2, 3), (1, 3)]) + star = nx.Graph([(1, 2), (1, 3)]) + assert iso_equal_check(k3, star)[0] + + +def _path4_inserted_out_of_order(): + graph = nx.Graph() + graph.add_nodes_from([2, 0, 3, 1]) + graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + return graph + + +def _weighted_path4(): + graph = nx.Graph() + graph.add_edge(0, 1, weight=5.0) + graph.add_edge(1, 2, weight=2.0) + graph.add_edge(2, 3, weight=0.5) + return graph + + +@pytest.mark.parametrize( + "graph", [nx.path_graph(4), _path4_inserted_out_of_order(), _weighted_path4()] +) +def test_iso_graph_finder_output_for_labels_0_to_n_minus_1(graph): + # pins the output, its order and its 0/1 entries for graphs labelled 0..n-1 + spec = [ + nx.to_numpy_array( + nx.relabel_nodes(graph, dict(zip(range(4), perm))), + nodelist=range(4), + weight=None, + ) + for perm in permutations(range(4)) + ] + result = iso_graph_finder(graph) + assert len(result) == 24 + assert all(list(g.nodes) == [0, 1, 2, 3] for g in result) + assert all(np.array_equal(nx.to_numpy_array(g), s) for g, s in zip(result, spec)) + + +def test_local_comp_graph_selects_the_node_by_position(): + # the path 0-1-2, with its nodes added as 2, 0, 1 + graph = nx.Graph() + graph.add_nodes_from([2, 0, 1]) + graph.add_edges_from([(0, 1), (1, 2)]) + # position 2 holds label 1, the middle of the path + result = local_comp_graph(graph, 2) + assert list(result.nodes) == [0, 1, 2] + assert sorted(map(sorted, result.edges)) == [[0, 1], [0, 2], [1, 2]] + # position 0 holds label 2, an end of the path: the input relabelled by position + result = local_comp_graph(graph, 0) + assert list(result.nodes) == [0, 1, 2] + assert sorted(map(sorted, result.edges)) == [[0, 2], [1, 2]] + + +def test_local_comp_graph_reads_edges_not_weights(): + # the path 0-1-2 with a weight of 2.0 on edge 0-1 + graph = nx.path_graph(3) + graph[0][1]["weight"] = 2.0 + result = local_comp_graph(graph, 1) + # the unweighted path's result: complementing at the middle node joins its ends + assert sorted(map(sorted, result.edges)) == [[0, 1], [0, 2], [1, 2]] diff --git a/tests/test_state_representation/test_quantum_state.py b/tests/test_state_representation/test_quantum_state.py index 3a5fea73..852bd4fa 100644 --- a/tests/test_state_representation/test_quantum_state.py +++ b/tests/test_state_representation/test_quantum_state.py @@ -12,13 +12,21 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import matplotlib.axes +import matplotlib.figure +import matplotlib.pyplot as plt import networkx as nx import numpy as np import pytest from graphiq.backends.density_matrix import numpy as dmnp +import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.graph.state import Graph, MixedGraph from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau -from graphiq.state import QuantumState +from graphiq.backends.stabilizer.functions.clifford import create_n_ket0_state +from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.state import MixedStabilizer +from graphiq.state import DENSITY_MATRIX_QUBIT_THRESH, QuantumState @pytest.mark.parametrize("n", [1, 2, 3, 4, 5]) @@ -54,3 +62,491 @@ def test_initializing_stabilizer_2(): n = 130 state = QuantumState(CliffordTableau(n), rep_type="s") assert state.rep_data.n_qubits == n + + +def test_convert_representation_graph_stabilizer_graph_round_trip_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("s") + state.convert_representation("g") + + assert nx.is_isomorphic(state.rep_data.data, graph) + + +def test_convert_representation_stabilizer_to_graph_of_a_circuit_prepared_graph_state(): + # the star graph state (centre 0) as a circuit leaves it: generators XZZ, ZXI, IXX + tableau = run_circuit( + CliffordTableau(3), + [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)], + ) + state = QuantumState(tableau, rep_type="s") + + state.convert_representation("g") + + assert sorted(state.rep_data.data.edges) == [(0, 1), (0, 2)] + + +def _edge_set(graph): + return {frozenset(edge) for edge in graph.edges} + + +@pytest.mark.parametrize( + "edges", [[(0, 1), (1, 2)], [(0, 1)]], ids=["path3", "isolated_node"] +) +def test_convert_representation_density_matrix_to_graph_pure_arm(edges): + graph = nx.Graph(edges) + graph.add_nodes_from(range(3)) + rho = rc.graph_to_density(graph) + state = QuantumState(rho, rep_type="dm") + + state.convert_representation("g") + + assert isinstance(state.rep_data, Graph) + assert set(state.rep_data.data.nodes) == {0, 1, 2} + assert _edge_set(state.rep_data.data) == _edge_set(graph) + assert all(not data for _, _, data in state.rep_data.data.edges(data=True)) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_convert_representation_density_matrix_to_graph_pure_arm_marked_mixed(): + rho = rc.graph_to_density(nx.path_graph(3)) + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("g") + + assert isinstance(state.rep_data, MixedGraph) + assert len(state.rep_data.mixture) == 1 + weight, graph = state.rep_data.mixture[0] + assert weight == 1.0 + assert _edge_set(graph.data) == _edge_set(nx.path_graph(3)) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def _empty_plus_triangle_mixture(): + # two orthogonal graph states with distinct weights + return 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + + +def test_convert_representation_density_matrix_to_graph_mixed_arm(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("g") + + assert isinstance(state.rep_data, MixedGraph) + # the six zero-weight eigenvectors are not branches of the mixture + assert len(state.rep_data.mixture) == 2 + branches = sorted( + (round(float(p_i), 9), sorted(map(sorted, _edge_set(g_i.data)))) + for p_i, g_i in state.rep_data.mixture + ) + assert branches == [(0.3, [[0, 1], [0, 2], [1, 2]]), (0.7, [])] + for _, g_i in state.rep_data.mixture: + assert set(g_i.data.nodes) == {0, 1, 2} + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_convert_representation_density_matrix_to_graph_rejects_a_mixture_not_marked_mixed(): + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("g") + + +def test_convert_representation_stabilizer_to_graph_mixed_arm(): + path3 = nx.path_graph(3) + star3 = nx.star_graph(2) + mixture = [ + (0.7, CliffordTableau(rc.graph_to_stabilizer(path3)[0][1])), + (0.3, CliffordTableau(rc.graph_to_stabilizer(star3)[0][1])), + ] + state = QuantumState(mixture, rep_type="s", mixed=True) + + state.convert_representation("g") + + mixed_graph = state.rep_data + assert isinstance(mixed_graph, MixedGraph) + assert [p_i for p_i, _ in mixed_graph.mixture] == [0.7, 0.3] + assert nx.is_isomorphic(mixed_graph.mixture[0][1].data, path3) + assert nx.is_isomorphic(mixed_graph.mixture[1][1].data, star3) + + +def test_str_of_a_mixed_graph(): + # MixedGraph never sets _data, so the base class __str__ must go through data + mixed_graph = MixedGraph([(1.0, Graph(nx.path_graph(2)))]) + assert str(mixed_graph).startswith("MixedGraph") + + +def test_graph_to_density_mixed_arm_reached_through_the_private_helper(): + # This calls the private helper directly on an instance whose "mixed" flag is set, since + # the constructor rejects a MixedGraph. The same arm is also reached publicly by converting + # a mixed stabilizer state to "g" and then to "dm". + qs = QuantumState(nx.path_graph(3), rep_type="g", mixed=True) + graph_a = nx.path_graph(3) + graph_b = nx.cycle_graph(3) + mixed_graph = MixedGraph([(0.7, Graph(graph_a)), (0.3, Graph(graph_b))]) + + density = qs._graph_to_density(mixed_graph) + + expected = 0.7 * rc.graph_to_density(graph_a) + 0.3 * rc.graph_to_density(graph_b) + assert np.allclose(density.data, expected) + + +def test_convert_representation_graph_to_density_matrix_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("dm") + + assert np.allclose(state.rep_data.data, rc.graph_to_density(graph)) + + +def test_convert_representation_graph_marked_mixed_to_density_matrix(): + # a state built from a graph with mixed=True holds a plain Graph, not a MixedGraph + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g", mixed=True) + + state.convert_representation("dm") + + assert state.rep_type == "dm" + assert np.allclose(state.rep_data.data, rc.graph_to_density(graph)) + + +def test_convert_representation_graph_to_stabilizer_pure_arm(): + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g") + + state.convert_representation("s") + + expected = rc.graph_to_stabilizer(graph)[0][1] + assert state.rep_data.data.to_stabilizer() == expected + + +def _graph_tableau(graph): + return CliffordTableau(rc.graph_to_stabilizer(graph)[0][1]) + + +def test_convert_representation_mixed_stabilizer_to_graph_and_back(): + path3 = nx.path_graph(3) + star3 = nx.star_graph(2) + mixture = [(0.7, _graph_tableau(path3)), (0.3, _graph_tableau(star3))] + state = QuantumState(mixture, rep_type="s", mixed=True) + + state.convert_representation("g") + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert [p_i for p_i, _ in mixed_stabilizer.mixture] == [0.7, 0.3] + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(path3) + assert mixed_stabilizer.mixture[1][1] == _graph_tableau(star3) + # the two tableaux differ, so the comparisons above can fail + assert not mixed_stabilizer.mixture[0][1] == _graph_tableau(star3) + + +def test_convert_representation_density_matrix_marked_mixed_to_graph_to_stabilizer(): + path3 = nx.path_graph(3) + state = QuantumState(rc.graph_to_density(path3), rep_type="dm", mixed=True) + + state.convert_representation("g") + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert len(mixed_stabilizer.mixture) == 1 + assert np.isclose(mixed_stabilizer.mixture[0][0], 1.0) + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(path3) + + +def test_convert_representation_plain_graph_marked_mixed_to_stabilizer(): + # a state built from a graph with mixed=True holds a plain Graph, not a MixedGraph + graph = nx.path_graph(3) + state = QuantumState(graph, rep_type="g", mixed=True) + + state.convert_representation("s") + + mixed_stabilizer = state.rep_data + assert isinstance(mixed_stabilizer, MixedStabilizer) + assert [p_i for p_i, _ in mixed_stabilizer.mixture] == [1.0] + assert mixed_stabilizer.mixture[0][1] == _graph_tableau(graph) + + +def test_graph_to_stabilizer_rejects_a_mixed_graph_on_a_state_not_marked_mixed(): + # This calls the private helper directly, since the constructor rejects a MixedGraph. + qs = QuantumState(nx.path_graph(3), rep_type="g") + + with pytest.raises(TypeError): + qs._graph_to_stabilizer(MixedGraph(Graph(nx.path_graph(3)))) + + +@pytest.mark.parametrize("rep_type", ["g", None]) +def test_partial_trace_on_a_graph_state_says_it_is_not_implemented(rep_type): + # rep_type=None autodetects the graph representation; it is refused like an + # explicit one rather than failing with an AttributeError. + state = QuantumState(nx.path_graph(4), rep_type=rep_type) + + with pytest.raises(NotImplementedError, match="Graph representation"): + state.partial_trace(keep=[0], dims=4 * [2]) + + +@pytest.mark.parametrize( + "data, expected_rep_type", + [ + (np.eye(4) / 4, "dm"), + (2, "dm"), + (create_n_ket0_state(3), "s"), + (DENSITY_MATRIX_QUBIT_THRESH, "s"), + (nx.path_graph(3), "g"), + ], +) +def test_autodetected_representation_records_its_rep_type(data, expected_rep_type): + # Without an explicit rep_type, __init__ picks the representation from the data; + # the name must be recorded too, since everything downstream dispatches on it. + state = QuantumState(data) + + assert state.rep_type == expected_rep_type + + +def test_autodetected_state_converts_like_an_explicit_one(): + graph = nx.path_graph(3) + autodetected = QuantumState(graph) + explicit = QuantumState(graph, rep_type="g") + + autodetected.convert_representation("dm") + explicit.convert_representation("dm") + + assert autodetected.rep_type == "dm" + assert np.allclose(autodetected.rep_data.data, explicit.rep_data.data) + + +def test_show_on_a_graph_state_returns_the_figure_and_axes(): + # show() unpacks the (fig, ax) that Graph.draw returns. No ax is passed, so + # this covers the ax=None path: the current figure and its current axes. + state = QuantumState(nx.path_graph(3), rep_type="g") + fresh = plt.figure() + try: + fig, ax = state.show(show=False) + + assert isinstance(fig, matplotlib.figure.Figure) + assert isinstance(ax, matplotlib.axes.Axes) + assert fig is fresh + assert ax.collections # the nodes and edges nx.draw added + finally: + plt.close(fresh) + + +def test_show_on_a_graph_state_returns_the_axes_it_was_given(): + state = QuantumState(nx.path_graph(3), rep_type="g") + f1, (a1, a2) = plt.subplots(1, 2) + f2 = plt.figure() # the current figure and axes are now neither f1 nor a1 + try: + fig, ax = state.show(show=False, ax=a1) + + assert fig is f1 + assert ax is a1 + assert a1.collections + assert not a2.collections + finally: + plt.close(f1) + plt.close(f2) + + +def test_show_on_a_graph_state_in_a_subfigure_returns_the_root_figure(): + state = QuantumState(nx.path_graph(3), rep_type="g") + root = plt.figure() + sub_ax = root.subfigures(1, 2)[0].subplots() + other = plt.figure() + try: + fig, ax = state.show(show=False, ax=sub_ax) + + assert isinstance(fig, matplotlib.figure.Figure) + assert fig is root + assert ax is sub_ax + finally: + plt.close(root) + plt.close(other) + + +def test_show_on_a_mixed_graph_state_raises_not_implemented(): + t1 = QuantumState(nx.path_graph(3), rep_type="g") + t1.convert_representation("s") + t2 = QuantumState(nx.star_graph(2), rep_type="g") + t2.convert_representation("s") + state = QuantumState( + [(0.5, t1.rep_data.tableau), (0.5, t2.rep_data.tableau)], + rep_type="s", + mixed=True, + ) + state.convert_representation("g") + assert isinstance(state.rep_data, MixedGraph) + + with pytest.raises(NotImplementedError, match="mixed graph state"): + state.show(show=False) + + +def _nx_graph_mixture(): + return [(0.5, nx.path_graph(3)), (0.5, nx.star_graph(2))] + + +def test_validate_data_rejects_a_list_of_networkx_graphs(): + # No representation here can be built from a list of (p, nx.Graph), so + # validate_data reports it as unsupported rather than reading n_qubits, + # which an nx.Graph does not have. + assert QuantumState.validate_data(_nx_graph_mixture()) == (False, None) + + +def test_constructor_rejects_a_list_of_networkx_graphs_with_type_error(): + with pytest.raises(TypeError, match="Data's type is incorrect"): + QuantumState(_nx_graph_mixture(), rep_type="g", mixed=True) + + +def test_validate_data_accepts_a_list_of_clifford_tableaux(): + assert QuantumState.validate_data([(1.0, CliffordTableau(3))]) == (True, 3) + + +# QuantumState.n_qubits used to be stored once in __init__ and never updated, so a traced or +# otherwise resized state kept reporting its old width while the representation reported the +# real one. It is now derived from the representation, and the rep_data setter checks the +# incoming data against that live width. + + +def _traced_state(rep_type, mixed=False): + """A 4-qubit state traced down to its first 3 qubits.""" + state = QuantumState(4, rep_type=rep_type, mixed=mixed) + state.partial_trace(keep=[0, 1, 2], dims=4 * [2]) + return state + + +@pytest.mark.parametrize("rep_type", ["dm", "s"]) +def test_n_qubits_follows_a_partial_trace(rep_type): + assert _traced_state(rep_type).n_qubits == 3 + + +def test_n_qubits_follows_a_partial_trace_on_a_mixed_stabilizer(): + # Reaches the count through mixture[0][1], a different path from the pure case. + assert _traced_state("s", mixed=True).n_qubits == 3 + + +def test_n_qubits_follows_a_resize_made_through_the_representation(): + # rep_data hands out the live representation, so resizing it bypasses QuantumState. A count + # updated only in partial_trace would still go stale here. + state = QuantumState(4, rep_type="s") + + state.rep_data.remove_qubit(0) + + assert state.n_qubits == 3 + + +@pytest.mark.parametrize( + "rep_type, mixed", + [("dm", False), ("s", False), ("s", True)], + ids=["dm", "s", "mixed s"], +) +def test_a_traced_state_accepts_its_own_data_back(rep_type, mixed): + # Raw data, not the representation object, so the assignment is rebuilt and goes through + # the width check, which used to compare it against the pre-trace count. + state = _traced_state(rep_type, mixed=mixed) + rep = state.rep_data + + state.rep_data = rep.mixture if mixed else rep.data + + assert state.n_qubits == 3 + + +@pytest.mark.parametrize("rep_type", ["dm", "s"]) +def test_a_traced_state_rejects_data_of_its_old_width(rep_type): + state = _traced_state(rep_type) + + with pytest.raises(AssertionError): + state.rep_data = np.eye(16, dtype=complex) / 16 if rep_type == "dm" else 4 + + +@pytest.mark.parametrize( + "n_nodes, accepted", [(4, True), (3, False)], ids=["new width", "old width"] +) +def test_a_graph_state_grown_in_place_checks_against_its_new_width(n_nodes, accepted): + state = QuantumState(nx.path_graph(3), rep_type="g") + state.rep_data.add_node(3) + + if accepted: + state.rep_data = nx.path_graph(n_nodes) + else: + with pytest.raises(AssertionError): + state.rep_data = nx.path_graph(n_nodes) + + assert state.n_qubits == 4 + + +def test_assigning_a_wider_density_matrix_is_rejected(): + state = QuantumState(np.eye(8, dtype=complex) / 8, rep_type="dm") + + with pytest.raises(AssertionError): + state.rep_data = np.eye(32, dtype=complex) / 32 + + assert state.n_qubits == 3 + + +def test_n_qubits_is_read_only(): + state = QuantumState(3, rep_type="s") + + with pytest.raises(AttributeError): + state.n_qubits = 5 + + +@pytest.mark.parametrize( + "assign", [False, True], ids=["at construction", "through the setter"] +) +def test_a_non_power_of_two_density_matrix_is_rejected(assign): + bad = np.eye(9, dtype=complex) / 9 + + if assign: + state = QuantumState(np.eye(8, dtype=complex) / 8, rep_type="dm") + with pytest.raises(AssertionError): + state.rep_data = bad + else: + with pytest.raises(AssertionError): + QuantumState(bad, rep_type="dm") + + +def _emitter_circuit(n_emitter, hadamards): + from graphiq.circuit.circuit_dag import CircuitDAG + import graphiq.circuit.ops as ops + + circuit = CircuitDAG(n_emitter=n_emitter, n_photon=0, n_classical=0) + for register in hadamards: + circuit.add(ops.Hadamard(register=register, reg_type="e")) + return circuit + + +def test_a_traced_state_can_seed_a_circuit_of_its_new_width(): + # CompilerBase asserts initial_state.n_qubits == circuit.n_quantum, so a stale count rejected + # a state whose data was exactly the circuit's width. The Hadamard applied before the trace + # shows the compiled state really started from initial_state. + from graphiq.backends.stabilizer.compiler import StabilizerCompiler + + state = QuantumState(4, rep_type="s") + state.rep_data.apply_hadamard(0) + state.partial_trace(keep=[0, 1, 2], dims=4 * [2]) + + compiled = StabilizerCompiler().compile( + _emitter_circuit(3, [2]), initial_state=state + ) + expected = StabilizerCompiler().compile(_emitter_circuit(3, [0, 2])) + + assert compiled.n_qubits == 3 + assert compiled.rep_data.data == expected.rep_data.data + + +def test_a_traced_state_cannot_seed_a_circuit_of_its_old_width(): + from graphiq.backends.stabilizer.compiler import StabilizerCompiler + + with pytest.raises(AssertionError, match="must match the circuit"): + StabilizerCompiler().compile( + _emitter_circuit(4, []), initial_state=_traced_state("s") + ) diff --git a/tests/test_state_representation/test_state_rep_conversion.py b/tests/test_state_representation/test_state_rep_conversion.py index 854ff941..0ec1d306 100644 --- a/tests/test_state_representation/test_state_rep_conversion.py +++ b/tests/test_state_representation/test_state_rep_conversion.py @@ -16,10 +16,53 @@ import networkx as nx import numpy as np +import pytest import graphiq.backends.density_matrix.functions as dmf +import graphiq.backends.stabilizer.functions.linalg as sla import graphiq.backends.state_rep_conversion as rc +from graphiq.backends.stabilizer.clifford_tableau import CliffordTableau +from graphiq.backends.stabilizer.functions.transformation import run_circuit +from graphiq.backends.stabilizer.state import MixedStabilizer from graphiq.backends.stabilizer.tableau import StabilizerTableau +from graphiq.state import QuantumState + + +def test_position_finder_single_z_stabilized_qubit_needs_hadamard(): + # a Z-stabilized qubit (e.g. |0>) has an all-zero X column; _position_finder must + # report it as needing a Hadamard, or downstream _graph_finder can't invert X at all. + assert rc._position_finder(np.array([[0]])) == [0] + + +def test_position_finder_two_z_stabilized_qubits_both_need_hadamard(): + # both qubits of |00> are Z-stabilized; _position_finder must not silently drop the + # first pivot it starts on. + x, _z, _rank = sla.row_reduction( + np.array([[0, 0], [0, 0]]), np.array([[1, 0], [0, 1]]) + ) + assert rc._position_finder(x) == [0, 1] + + +def test_position_finder_only_the_pivotless_qubit_needs_hadamard(): + # qubit 0 is Z-stabilized (needs Hadamard); qubits 1, 2 are already X-stabilized. + x, _z, _rank = sla.row_reduction( + np.array([[0, 0, 0], [0, 1, 0], [0, 0, 1]]), + np.array([[1, 0, 0], [0, 0, 0], [0, 0, 0]]), + ) + assert rc._position_finder(x) == [0] + + +def test_state_to_graph_two_qubit_z_stabilized_tableau_no_longer_crashes(): + # |00>: both qubits Z-stabilized, x_matrix all-zero. Used to raise + # "AssertionError: Stabilizer generators are not independent." in _graph_finder. + x_matrix = np.zeros((2, 2)) + z_matrix = np.eye(2) + tableau = StabilizerTableau([x_matrix, z_matrix]) + + graph, tab, gate_list = rc.state_to_graph(tableau) + + assert graph.number_of_nodes() == 2 + assert gate_list == [("H", 0), ("H", 1)] def test_negativity(): @@ -70,6 +113,76 @@ def test_stabilizer_to_graph(): assert nx.is_isomorphic(graph1, graph2_list[0][1]) +# the 3-qubit star graph state (centre 0), prepared by a circuit: its generators are +# XZZ, ZXI, IXX rather than the rows of [I | A] +_STAR_CIRCUIT = [("H", 0), ("CNOT", 0, 1), ("CNOT", 1, 2), ("H", 1), ("H", 2)] + + +def _star_tableau(): + return run_circuit(CliffordTableau(3), _STAR_CIRCUIT).to_stabilizer() + + +def _row_swapped_path3_tableau(): + tableau = StabilizerTableau([np.eye(3), nx.to_numpy_array(nx.path_graph(3))]) + return StabilizerTableau(tableau.table[[1, 0, 2]]) + + +def test_stabilizer_to_graph_accepts_a_graph_state_not_in_standard_form(): + tableau = _star_tableau() + assert np.allclose( + rc.stabilizer_to_density(tableau), rc.graph_to_density(nx.star_graph(2)) + ) + + graph_list = rc.stabilizer_to_graph(tableau) + + assert len(graph_list) == 1 + assert sorted(graph_list[0][1].edges) == [(0, 1), (0, 2)] + + +def test_stabilizer_to_graph_accepts_row_permuted_generators(): + graph_list = rc.stabilizer_to_graph(_row_swapped_path3_tableau()) + + assert sorted(graph_list[0][1].edges) == [(0, 1), (1, 2)] + + +def test_stabilizer_to_graph_accepts_a_list_of_graph_states_not_in_standard_form(): + graph_list = rc.stabilizer_to_graph( + [(0.3, _star_tableau()), (0.7, _row_swapped_path3_tableau())] + ) + + assert [p_i for p_i, _ in graph_list] == [0.3, 0.7] + assert sorted(graph_list[0][1].edges) == [(0, 1), (0, 2)] + assert sorted(graph_list[1][1].edges) == [(0, 1), (1, 2)] + + +def test_stabilizer_to_graph_still_rejects_a_state_that_is_not_a_graph_state(): + # |0>|+>, and the star state with Z applied to qubit 1 + product = run_circuit(CliffordTableau(2), [("H", 1)]).to_stabilizer() + signed_star = run_circuit( + CliffordTableau(3), _STAR_CIRCUIT + [("Z", 1)] + ).to_stabilizer() + + for tableau in (product, signed_star): + with pytest.raises(AssertionError, match="not a graph state"): + rc.stabilizer_to_graph(tableau) + + +def test_stabilizer_to_graph_leaves_its_input_unchanged(): + # the validation compares canonical forms, which are computed in place + tableau = _star_tableau() + mixture = [(0.3, _star_tableau()), (0.7, _row_swapped_path3_tableau())] + expected = [ + (t.table.copy(), t.phase.copy()) for t in [tableau] + [t for _, t in mixture] + ] + + rc.stabilizer_to_graph(tableau) + rc.stabilizer_to_graph(mixture) + + for t, (table, phase) in zip([tableau] + [t for _, t in mixture], expected): + assert np.array_equal(t.table, table) + assert np.array_equal(t.phase, phase) + + def test_stabilizer_and_density_conversion(): x_matrix = np.eye(4) graph = nx.Graph([(1, 2), (2, 3), (3, 4)]) @@ -207,3 +320,148 @@ def test_graph_to_density_3_linear_cluster(): assert np.size(rho) == 8**2 # Test Hermiticity assert (np.conjugate(rho.transpose()) == rho).all() + + +def test_stabilizer_to_density_mixture(): + # A mixture of |0> (real density matrix) followed by |+i> (stabilized by Y, + # so complex) must return the weighted sum. + ket0 = StabilizerTableau([np.array([[0]]), np.array([[1]])]) + ket_plus_i = StabilizerTableau([np.array([[1]]), np.array([[1]])]) + rho = rc.stabilizer_to_density([(0.25, ket0), (0.75, ket_plus_i)]) + + rho_0 = np.array([[1, 0], [0, 0]]) + rho_plus_i = np.array([[1, -1j], [1j, 1]]) / 2 + assert rho is not None + assert np.allclose(rho, 0.25 * rho_0 + 0.75 * rho_plus_i) + + +def test_density_to_graph_mixed_state_decomposes_into_eigenstates(): + # 0.7 |+><+| + 0.3 |-><-|: each branch must be built from one eigenvector + # (a column of eigh's output) and carry its own eigenvalue as the weight. + plus = np.array([1, 1]) / np.sqrt(2) + minus = np.array([1, -1]) / np.sqrt(2) + rho = 0.7 * np.outer(plus, plus) + 0.3 * np.outer(minus, minus) + + graph_list = rc.density_to_graph(rho, validate=False) + + weights = sorted(float(p_i) for p_i, _ in graph_list) + assert np.allclose(weights, [0.3, 0.7]) + for _, adj in graph_list: + assert np.array_equal(adj, np.zeros((1, 1))) + + +def test_density_to_graph_reads_each_branch_from_an_eigenvector_column(): + # 0.7 |empty> + 0.3 |triangle> on 3 qubits: the two graph states are orthogonal, so they + # are the eigenvectors of rho with nonzero weight. The 1-qubit test above cannot see which + # vectors are read: every 1-qubit branch has the adjacency zeros((1, 1)). Here the branches + # differ, and eigh's eigenvector matrix is not symmetric, so reading a row instead of a + # column builds a different projector. + rho = 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + eigenvalues, eigenvectors = np.linalg.eigh(rho) + weighted = [i for i in range(len(eigenvalues)) if eigenvalues[i] > 1e-9] + assert not np.allclose(eigenvectors, eigenvectors.T) + for i in weighted: + column, row = eigenvectors[:, i], eigenvectors[i] + assert not np.allclose( + np.outer(column, column.conj()), np.outer(row, row.conj()) + ) + + graph_list = rc.density_to_graph(rho) + + # every eigenvalue gets an entry; the zero-weight ones come from an arbitrary basis of the + # null space, so only the weighted entries are checked + branches = sorted( + (round(float(p_i), 9), adj.tolist()) for p_i, adj in graph_list if p_i > 1e-9 + ) + assert branches == [ + (0.3, nx.to_numpy_array(nx.complete_graph(3)).tolist()), + (0.7, np.zeros((3, 3)).tolist()), + ] + + +def _empty_plus_triangle_mixture(): + # 0.7 |empty> + 0.3 |triangle> on 3 qubits: two orthogonal graph states with distinct + # weights, so they are the eigenvectors of rho with nonzero eigenvalue. The other six + # eigenvalues are zero, and their eigenvectors are not graph states. + return 0.7 * rc.graph_to_density(nx.empty_graph(3)) + 0.3 * rc.graph_to_density( + nx.complete_graph(3) + ) + + +def test_density_to_stabilizer_converts_an_orthogonal_graph_state_mixture(): + rho = _empty_plus_triangle_mixture() + + stabilizer = rc.density_to_stabilizer(rho) + + assert len(stabilizer) == 2 + assert sorted(float(p_i) for p_i, _ in stabilizer) == pytest.approx([0.3, 0.7]) + assert np.allclose(rc.stabilizer_to_density(stabilizer), rho) + + +def test_mixed_density_matrix_state_converts_to_a_mixed_stabilizer(): + rho = _empty_plus_triangle_mixture() + state = QuantumState(rho, rep_type="dm", mixed=True) + + state.convert_representation("s") + + assert isinstance(state.rep_data, MixedStabilizer) + state.convert_representation("dm") + assert np.allclose(state.rep_data.data, rho) + + +def test_mixed_density_matrix_state_not_marked_mixed_is_not_converted_to_one_branch(): + # With mixed=False a stabilizer conversion can hold one tableau only; keeping one branch + # of a mixture would silently change the state. + state = QuantumState(_empty_plus_triangle_mixture(), rep_type="dm") + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("s") + + +def _lossy_path3_state(mixed): + state = QuantumState( + rc.graph_to_density(nx.path_graph(3)), rep_type="dm", mixed=mixed + ) + # lose 0.1 in place; building the state from 0.9 * rho would renormalise it + state.rep_data.apply_channel([np.sqrt(0.9) * np.eye(8)]) + assert np.real(np.trace(state.rep_data.data)) == pytest.approx(0.9) + return state + + +def test_subnormalised_density_matrix_state_not_marked_mixed_is_not_converted(): + # A Stabilizer holds one tableau and no weight, so converting would drop the 0.9 and + # leave the lossless state. + state = _lossy_path3_state(mixed=False) + + with pytest.raises(ValueError, match="mixed=True"): + state.convert_representation("s") + + +def test_subnormalised_density_matrix_state_marked_mixed_keeps_its_weight(): + state = _lossy_path3_state(mixed=True) + + state.convert_representation("s") + + assert isinstance(state.rep_data, MixedStabilizer) + assert len(state.rep_data.mixture) == 1 + assert state.rep_data.mixture[0][0] == pytest.approx(0.9) + + +def test_density_to_stabilizer_rejects_a_mixture_of_non_orthogonal_graph_states(): + # The empty and edge graphs are the only 2-qubit graph states, and they are not + # orthogonal, so no eigenvector of their mixture is a graph state. + rho = 0.6 * rc.graph_to_density(nx.empty_graph(2)) + 0.4 * rc.graph_to_density( + nx.complete_graph(2) + ) + + with pytest.raises(AssertionError, match="not a graph state"): + rc.density_to_stabilizer(rho) + + +def test_state_to_graph_rejects_an_unrepresentable_input(): + # Neither an adjacency matrix, a graph, nor a stabilizer/Clifford tableau: falls through + # every isinstance check to the final else arm. + with pytest.raises(ValueError, match="input data should either be"): + rc.state_to_graph("x") diff --git a/tests/test_utils/test_circuit_comparison.py b/tests/test_utils/test_circuit_comparison.py index 2ffc271b..2074486c 100644 --- a/tests/test_utils/test_circuit_comparison.py +++ b/tests/test_utils/test_circuit_comparison.py @@ -212,6 +212,54 @@ def test_circuit_is_isomorphic_8(): assert not circuit_is_isomorphic(circuit_1, circuit_2) +def _classical_pair_circuit(op_class, control, target): + # e0 gets a Hadamard first, so e0 and e1 are distinguishable up to relabelling + circuit = CircuitDAG(n_emitter=2, n_photon=0, n_classical=1) + circuit.add(Hadamard(register=0, reg_type="e")) + circuit.add( + op_class( + control=control, + control_type="e", + target=target, + target_type="e", + c_register=0, + ) + ) + return circuit + + +@pytest.mark.parametrize( + "op_class", [ClassicalCNOT, ClassicalCZ, MeasurementCNOTandReset] +) +def test_circuit_is_isomorphic_distinguishes_classical_pair_op_roles(op_class): + # the control is measured and the target is acted on, so swapping them + # gives a different circuit + circuit_1 = _classical_pair_circuit(op_class, control=0, target=1) + circuit_2 = _classical_pair_circuit(op_class, control=1, target=0) + + assert not circuit_is_isomorphic(circuit_1, circuit_2) + + +@pytest.mark.parametrize( + "op_class", [ClassicalCNOT, ClassicalCZ, MeasurementCNOTandReset] +) +def test_circuit_is_isomorphic_classical_pair_op_under_register_relabelling( + op_class, +): + circuit = _classical_pair_circuit(op_class, control=0, target=1) + assert circuit_is_isomorphic( + circuit, _classical_pair_circuit(op_class, control=0, target=1) + ) + + # the same circuit with e0 and e1 exchanged + relabelled = CircuitDAG(n_emitter=2, n_photon=0, n_classical=1) + relabelled.add(Hadamard(register=1, reg_type="e")) + relabelled.add( + op_class(control=1, control_type="e", target=0, target_type="e", c_register=0) + ) + assert circuit_is_isomorphic(circuit, relabelled) + + # Test remove_redundant_circuits() def test_remove_redundant_circuits_1(): circuit_1 = CircuitDAG(n_emitter=2, n_photon=1, n_classical=0) @@ -235,6 +283,15 @@ def test_remove_redundant_circuits_1(): assert len(new_list) == 1 +def test_remove_redundant_circuits_keeps_classical_pair_ops_with_swapped_roles(): + circuit_list = [ + _classical_pair_circuit(ClassicalCNOT, control=0, target=1), + _classical_pair_circuit(ClassicalCNOT, control=1, target=0), + ] + + assert len(remove_redundant_circuits(circuit_list)) == 2 + + # Test outside the pipeline def test_circuit_equivalency_3(): # Same circuit emitter depth, but different in operations diff --git a/tests/test_utils/test_density_matrix_visualizer.py b/tests/test_utils/test_density_matrix_visualizer.py new file mode 100644 index 00000000..942b70de --- /dev/null +++ b/tests/test_utils/test_density_matrix_visualizer.py @@ -0,0 +1,138 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import matplotlib.pyplot as plt +import numpy as np +import pytest +from mpl_toolkits.mplot3d import Axes3D + +from graphiq.visualizers.density_matrix import ( + cmap_div, + density_matrix_bars, + density_matrix_heatmap, +) + +# Row-major entries 0.4, -0.1, -0.4, 0. The largest magnitude is 0.4, so the real +# panel's range is [-0.5, 0.5] and entry x sits at 0.5 + x on the colour map. +# In floats 0.5 + 0.5 * -0.4 / 0.5 is 0.09999999999999998, not 0.1; the exact +# equalities below hold because the colour map is a 256-entry lookup table and +# both land on entry 25. +RHO = np.array([[0.4, -0.1], [-0.4, 0.0]], dtype=complex) + + +@pytest.fixture +def bar_colours(monkeypatch): + """The color= array each bar3d call receives, one entry per panel.""" + recorded = [] + bar3d = Axes3D.bar3d + + def spy(self, *args, color=None, **kwargs): + recorded.append(np.asarray(color)) + return bar3d(self, *args, color=color, **kwargs) + + monkeypatch.setattr(Axes3D, "bar3d", spy) + fig, _ = density_matrix_bars(RHO) + plt.close(fig) + return recorded + + +def rgb(position): + return np.asarray(cmap_div(position))[:3] + + +def test_bar_colour_of_a_zero_entry_is_the_neutral_centre(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[3, :3], rgb(0.5)) + + +def test_negative_entries_of_different_size_get_different_colours(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[1, :3], rgb(0.4)) # -0.1 + np.testing.assert_array_equal(real[2, :3], rgb(0.1)) # -0.4 + assert not np.array_equal(real[1, :3], real[2, :3]) + + +def test_bar_colours_are_symmetric_about_zero(bar_colours): + real = bar_colours[0] + np.testing.assert_array_equal(real[0, :3], rgb(0.9)) # +0.4 + np.testing.assert_array_equal(real[2, :3], rgb(0.1)) # -0.4 + + +def test_an_all_zero_panel_is_drawn_in_the_neutral_colour(bar_colours): + # the imaginary part of a real matrix: every bar is 0 + imag = bar_colours[1] + for colour in imag: + np.testing.assert_array_equal(colour[:3], rgb(0.5)) + + +def test_bar_alpha_is_unchanged(bar_colours): + real, imag = bar_colours + np.testing.assert_allclose( + real[:, 3], 1 - 0.5 * np.abs([0.4, -0.1, -0.4, 0.0]) / 0.5 + ) + np.testing.assert_array_equal(imag[:, 3], np.ones(4)) + + +def test_heatmap_returns_the_figure_of_the_axes_it_is_given(): + fig, axs = plt.subplots(nrows=1, ncols=2) + try: + returned_fig, returned_axs = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + assert returned_axs is axs + finally: + plt.close(fig) + + +def test_heatmap_given_a_tuple_of_axes_returns_their_figure_and_the_tuple(): + fig, axs = plt.subplots(nrows=1, ncols=2) + axs = tuple(axs) + try: + returned_fig, returned_axs = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + assert returned_axs is axs + finally: + plt.close(fig) + + +def test_heatmap_given_axes_in_a_subfigure_returns_the_root_figure(): + fig = plt.figure() + axs = fig.subfigures(1, 1).subplots(1, 2) + try: + returned_fig, _ = density_matrix_heatmap(RHO, axs=axs) + assert returned_fig is fig + finally: + plt.close(fig) + + +def test_heatmap_given_axes_from_two_figures_returns_the_first_axes_figure(): + fig_real, ax_real = plt.subplots() + fig_imag, ax_imag = plt.subplots() + try: + returned_fig, _ = density_matrix_heatmap(RHO, axs=[ax_real, ax_imag]) + assert returned_fig is fig_real + # the imaginary heatmap is still drawn there + assert len(ax_imag.collections) == 1 + finally: + plt.close(fig_real) + plt.close(fig_imag) + + +def test_heatmap_without_axes_still_opens_a_new_two_panel_figure(): + before = set(plt.get_fignums()) + fig, axs = density_matrix_heatmap(RHO) + try: + assert fig.number not in before + assert [ax.figure for ax in axs] == [fig, fig] + finally: + plt.close(fig) diff --git a/tests/test_utils/test_draw.py b/tests/test_utils/test_draw.py index d390a23c..4a116114 100644 --- a/tests/test_utils/test_draw.py +++ b/tests/test_utils/test_draw.py @@ -390,3 +390,23 @@ def test_load_openqasm_1(): {"type": "measure", "col": 3, "qreg": "p[2]", "creg": "c[4]", "cbit": 2}, {"type": "measure", "col": 4, "qreg": "p[3]", "creg": "c[4]", "cbit": 3}, ] + + +def test_load_openqasm_draws_an_indexed_measure_and_reset_on_one_qubit(): + openqasm_str = """ + OPENQASM 2.0; + + qreg q[3]; + creg c[3]; + + reset q[1]; + measure q[2] -> c[0]; + """ + + painter = Painter() + painter.load_openqasm_str(openqasm_str) + + assert painter.ops == [ + {"type": "reset", "col": 0, "qreg": "q[1]"}, + {"type": "measure", "col": 0, "qreg": "q[2]", "creg": "c[3]", "cbit": 0}, + ] diff --git a/tests/test_utils/test_io.py b/tests/test_utils/test_io.py new file mode 100644 index 00000000..95c8e712 --- /dev/null +++ b/tests/test_utils/test_io.py @@ -0,0 +1,127 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import builtins +import datetime + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import pytest + +import graphiq.io +from graphiq.io import IO + + +def test_io_init_no_path_uses_default_path(): + io = IO(verbose=False) + assert io.path == IO.default_path + + +def test_io_init_str_path_is_converted_to_path(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + assert io.path == tmp_path + + +def test_new_directory_does_not_touch_filesystem(tmp_path): + io = IO.new_directory(path=tmp_path, folder="my_folder", verbose=False) + assert not io.path.exists() + + +def test_new_directory_warns_only_when_folder_is_falsy(tmp_path, recwarn): + IO.new_directory(path=tmp_path, folder="my_folder", verbose=False) + assert len(recwarn.list) == 0 + + IO.new_directory(path=tmp_path, folder="", verbose=False) + assert len(recwarn.list) == 1 + assert "No folder entered" in str(recwarn.list[0].message) + + +def test_save_and_load_json_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + data = {"a": 1, "b": [1, 2, 3]} + io.save_json(data, "data.json") + assert io.load_json("data.json") == data + + +def test_save_and_load_txt_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + io.save_txt("hello world", "data.txt") + assert io.load_txt("data.txt") == "hello world" + + +def test_load_txt_closes_its_file(tmp_path, monkeypatch): + io = IO(path=str(tmp_path), verbose=False) + io.save_txt("hello world", "data.txt") + opened = [] + + def recording_open(*args, **kwargs): + file = builtins.open(*args, **kwargs) + opened.append(file) + return file + + # a module global shadows the builtin inside graphiq/io.py only; the list + # keeps each file alive, so only an explicit close can close it + monkeypatch.setattr(graphiq.io, "open", recording_open, raising=False) + assert io.load_txt("data.txt") == "hello world" + assert len(opened) == 1 + assert opened[0].closed + + +def test_save_and_load_dataframe_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + df = pd.DataFrame({"x": [1, 2], "y": [3, 4]}) + io.save_dataframe(df, "data.csv") + loaded = io.load_dataframe("data.csv") + pd.testing.assert_frame_equal(loaded, df) + + +def test_save_and_load_np_array_round_trip(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + arr = np.array([1.0, 2.0, 3.0]) + io.save_np_array(arr, "data.txt") + loaded = io.load_np_array("data.txt") + assert np.allclose(loaded, arr) + + +def test_save_figure_writes_file(tmp_path): + io = IO(path=str(tmp_path), verbose=False) + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + io.save_figure(fig, "figure.png") + assert (tmp_path / "figure.png").exists() + plt.close(fig) + + +def test_verbose_save_and_load_prints_to_terminal(tmp_path, capsys): + io = IO(path=str(tmp_path), verbose=True) + data = {"a": 1} + io.save_json(data, "data.json") + saved_output = capsys.readouterr().out + assert f"Saved to {tmp_path / 'data.json'}" in saved_output + + io.load_json("data.json") + loaded_output = capsys.readouterr().out + assert f"Loaded from {tmp_path / 'data.json'}" in loaded_output + + +def test_new_directory_with_empty_folder_warns_and_dates_the_path(tmp_path): + # An empty folder name should fall back to a unique, dated folder name + # and warn the caller that it did so. new_directory only builds a path + # object (join, no filesystem write), so there is nothing to clean up. + with pytest.warns(UserWarning): + io = IO.new_directory(path=tmp_path, folder="") + + today = datetime.date.today().isoformat() + assert io.path.name.startswith(today) diff --git a/tests/test_utils/test_openqasm_parser.py b/tests/test_utils/test_openqasm_parser.py index 0f50de77..06860fa9 100644 --- a/tests/test_utils/test_openqasm_parser.py +++ b/tests/test_utils/test_openqasm_parser.py @@ -136,6 +136,44 @@ def test_parser_parse_measure(): assert parse_measure(measure_node) == expected_output +@pytest.mark.parametrize( + "measure, q_index, c_index", + [ + ("measure q[2] -> c[0];", 2, 0), + ("measure q[0] -> c[2];", 0, 2), + ("measure q[1] -> c[1];", 1, 1), + ], +) +def test_parser_parse_measure_keeps_the_creg_index(measure, q_index, c_index): + parser = Qasm(data=f"qreg q[3];\ncreg c[3];\n{measure}\n") + measure_node = parser.parse().children[-1] + + expected_output = { + "type": "measure", + "qreg": {"name": "q", "index": q_index}, + "creg": {"name": "c", "index": c_index}, + } + + myparser = OpenQASMParser(openqasm_str="") + parse_measure = myparser.use_parse("measure") + assert parse_measure(measure_node) == expected_output + + +def test_parser_parse_measure_whole_register(): + parser = Qasm(data="qreg q[3];\ncreg c[3];\nmeasure q -> c;\n") + measure_node = parser.parse().children[-1] + + expected_output = { + "type": "measure", + "qreg": {"name": "q"}, + "creg": {"name": "c"}, + } + + myparser = OpenQASMParser(openqasm_str="") + parse_measure = myparser.use_parse("measure") + assert parse_measure(measure_node) == expected_output + + def test_parser_parse_reset(): openqasm = """ qreg q[3]; diff --git a/tests/test_utils/test_photon_loss.py b/tests/test_utils/test_photon_loss.py new file mode 100644 index 00000000..a738fc84 --- /dev/null +++ b/tests/test_utils/test_photon_loss.py @@ -0,0 +1,126 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +import graphiq.circuit.ops as ops +from graphiq.circuit.circuit_dag import CircuitDAG +from graphiq.noise.noise_models import NoNoise, PhotonLoss +from graphiq.utils.photon_loss import photon_survival_rate + + +def test_photon_survival_rate_reads_the_control_leg_for_a_photon_control(): + circuit = CircuitDAG(n_emitter=0, n_photon=2) + circuit.add( + ops.CNOT( + control=0, + control_type="p", + target=1, + target_type="p", + noise=[PhotonLoss(0.5), PhotonLoss(0.1)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.5, 0.9]) + + +def test_photon_survival_rate_reads_the_control_leg_of_a_classically_controlled_gate(): + circuit = CircuitDAG(n_emitter=0, n_photon=2, n_classical=1) + circuit.add( + ops.MeasurementCNOTandReset( + control=0, + control_type="p", + target=1, + target_type="p", + c_register=0, + noise=[PhotonLoss(0.5), PhotonLoss(0.1)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.5, 0.9]) + + +def test_photon_survival_rate_photon_control_with_lossless_target(): + circuit = CircuitDAG(n_emitter=0, n_photon=2) + circuit.add( + ops.CZ( + control=0, + control_type="p", + target=1, + target_type="p", + noise=[PhotonLoss(0.3), NoNoise()], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.7, 1]) + + +def test_photon_survival_rate_reads_the_control_leg_when_the_target_is_an_emitter(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="p", + target=0, + target_type="e", + noise=[PhotonLoss(0.2), PhotonLoss(0.4)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.8]) + + +def test_photon_survival_rate_reads_the_target_leg_for_a_photon_target(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=0, + target_type="p", + noise=[PhotonLoss(0.2), PhotonLoss(0.4)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.6]) + + +def test_photon_survival_rate_multiplies_losses_along_the_register(): + circuit = CircuitDAG(n_emitter=1, n_photon=1) + circuit.add( + ops.CNOT( + control=0, + control_type="e", + target=0, + target_type="p", + noise=PhotonLoss(0.25), + ) + ) + circuit.add(ops.Hadamard(register=0, reg_type="p", noise=PhotonLoss(0.5))) + + assert photon_survival_rate(circuit) == pytest.approx([0.375]) + + +def test_photon_survival_rate_counts_each_leg_of_an_unwrapped_wrapper(): + circuit = CircuitDAG(n_emitter=0, n_photon=1) + circuit.add( + ops.OneQubitGateWrapper( + [ops.Hadamard, ops.Phase], + register=0, + reg_type="p", + noise=[PhotonLoss(0.5), PhotonLoss(0.5)], + ) + ) + + assert photon_survival_rate(circuit) == pytest.approx([0.25]) diff --git a/tests/test_utils/test_preprocessing.py b/tests/test_utils/test_preprocessing.py new file mode 100644 index 00000000..5586b89a --- /dev/null +++ b/tests/test_utils/test_preprocessing.py @@ -0,0 +1,70 @@ +# Copyright (c) 2022-2024 Quantum Bridge Technologies Inc. +# Copyright (c) 2022-2024 Ki3 Photonics Technologies +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import networkx as nx + +from graphiq.utils.preprocessing import _select_graphs + + +def _make_graph(label): + graph = nx.Graph() + graph.add_node(label) + return graph + + +def test_select_graphs_keeps_the_true_lowest_entries(): + # With limit=2, feeding metric values 5.0, then 3.0, then 4.0 must keep the two lowest + # (3.0 and 4.0), not evict 3.0 -- the best score seen -- once the list is at capacity. + graph_5 = _make_graph("five") + graph_3 = _make_graph("three") + graph_4 = _make_graph("four") + + candidate_graphs = [] + candidate_graphs = _select_graphs(candidate_graphs, graph_5, 2, 5.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_3, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_4, 2, 4.0) + + assert candidate_graphs == [(3.0, graph_3), (4.0, graph_4)] + + +def test_select_graphs_tolerates_tied_metric_values(): + # Equal metric values are common (integer-valued edge/eccentricity counts); comparing the + # graphs themselves as a tiebreak must never happen, since nx.Graph has no ordering. + graph_a = _make_graph("a") + graph_b = _make_graph("b") + graph_c = _make_graph("c") + + candidate_graphs = [] + candidate_graphs = _select_graphs(candidate_graphs, graph_a, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_b, 2, 3.0) + candidate_graphs = _select_graphs(candidate_graphs, graph_c, 2, 3.0) + + assert len(candidate_graphs) == 2 + assert all(value == 3.0 for value, _ in candidate_graphs) + + +def test_select_graphs_keeps_starting_graph_when_it_is_best(): + # _select_graphs is seeded with the starting graph; when its metric is the lowest seen, + # it must still be present in the result. + starting_graph = _make_graph("start") + other_graph_1 = _make_graph("other1") + other_graph_2 = _make_graph("other2") + + candidate_graphs = [(1.0, starting_graph)] + candidate_graphs = _select_graphs(candidate_graphs, other_graph_1, 2, 5.0) + candidate_graphs = _select_graphs(candidate_graphs, other_graph_2, 2, 4.0) + + assert candidate_graphs[0] == (1.0, starting_graph) + assert starting_graph in [graph for _, graph in candidate_graphs] diff --git a/tests/test_utils/test_relabel_module.py b/tests/test_utils/test_relabel_module.py index dc4a949f..45fc7971 100644 --- a/tests/test_utils/test_relabel_module.py +++ b/tests/test_utils/test_relabel_module.py @@ -12,11 +12,22 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import math + +import matplotlib.pyplot as plt import networkx as nx import numpy as np import pytest +from graphiq.backends.lc_equivalence_check import local_comp_graph +from graphiq.backends.stabilizer.functions.height import height_max +from graphiq.benchmarks.graph_states import linear_cluster_state from graphiq.utils.relabel_module import * +from graphiq.utils.relabel_module import ( + _compare_graphs_visual, + _label_finder, + _partial_orbit, +) def random_connected_graph(n, p, seed=None): @@ -31,7 +42,7 @@ def random_connected_graph(n, p, seed=None): @pytest.mark.parametrize("n_node", [5, 6, 7, 8]) def test_random_graph(n_node): - n_iso = np.math.factorial(n_node - 2) + n_iso = math.factorial(n_node - 2) for p in range(4, 7): found = [] for i in range(3): @@ -75,3 +86,228 @@ def test_emitter_sort(): for i in range(len(n_emit_list) - 1) ) assert isinstance(sorted_list[0][0], np.ndarray) + + +def _linear_cluster_adj(n): + return nx.to_numpy_array(linear_cluster_state(n).data) + + +def _returns_on_first_batch(adj, n_iso, seed): + # iso_finder returns straight after its first batch of labellings when that + # batch already holds n_iso distinct graphs + labels = _label_finder(n_iso, adj.shape[0], seed=seed) + return len(automorph_check(adj, labels)) >= n_iso + + +def _emitter_counts(adj_arr): + return [height_max(graph=nx.from_numpy_array(adj)) for adj in adj_arr] + + +def _assert_maps_pair_with_matrices(adj, found): + assert isinstance(found, tuple) + adj_arr, maps = found + assert len(maps) == len(adj_arr) + g = nx.from_numpy_array(adj) + for new_adj, mapping in zip(adj_arr, maps): + new_g = nx.from_numpy_array(new_adj) + mapped_edges = {frozenset((mapping[u], mapping[v])) for u, v in g.edges} + assert mapped_edges == {frozenset(e) for e in new_g.edges} + + +def test_sort_emit_and_label_map_apply_when_the_first_batch_is_enough(): + adj = _linear_cluster_adj(6) + kwargs = dict(rel_inc_thresh=0.1, allow_exhaustive=False, sort_emit=True, seed=1) + assert _returns_on_first_batch(adj, 10, seed=1) + + adj_arr = iso_finder(adj, 10, **kwargs) + n_emit = _emitter_counts(adj_arr) + assert n_emit == sorted(n_emit) + assert n_emit != _emitter_counts( + iso_finder(adj, 10, **{**kwargs, "sort_emit": False}) + ) + + # the maps must pair with the sorted matrices, not the order they were found in + _assert_maps_pair_with_matrices(adj, iso_finder(adj, 10, label_map=True, **kwargs)) + + +def test_label_map_applies_when_the_first_batch_is_enough(): + adj = _linear_cluster_adj(4) + assert _returns_on_first_batch(adj, 3, seed=1) + + found = iso_finder(adj, 3, label_map=True, seed=1) + + _assert_maps_pair_with_matrices(adj, found) + assert len(found[0]) == 3 + + +def test_label_map_has_one_map_per_returned_matrix_after_a_longer_search(): + adj = _linear_cluster_adj(4) + assert not _returns_on_first_batch(adj, 5, seed=0) + + found = iso_finder(adj, 5, sort_emit=False, label_map=True, seed=0) + + _assert_maps_pair_with_matrices(adj, found) + assert len(found[0]) == 5 + + +def test_sort_emit_and_label_map_apply_at_the_high_symmetry_stop(): + adj = nx.to_numpy_array(nx.cycle_graph(4)) + with pytest.warns(UserWarning, match="high symmetry"): + found = iso_finder( + adj, 5, allow_exhaustive=False, sort_emit=True, label_map=True, seed=1 + ) + + _assert_maps_pair_with_matrices(adj, found) + n_emit = _emitter_counts(found[0]) + assert n_emit == sorted(n_emit) + assert len(set(n_emit)) > 1 # mixed counts, so the sort is actually exercised + + +def test_compare_graphs_visual_draws_the_three_panels(monkeypatch): + monkeypatch.setattr(plt, "show", lambda: None) + g = nx.path_graph(4) + new_labels = [2, 0, 3, 1] + new_g = nx.relabel_nodes(g, dict(enumerate(new_labels))) + plt.close("all") + try: + _compare_graphs_visual(g, new_g, new_labels) + panels = [ + plt.figure(n) for n in plt.get_fignums() if len(plt.figure(n).axes) == 3 + ] + assert len(panels) == 1 + assert all(ax.collections for ax in panels[0].axes) + finally: + plt.close("all") + + +def test_compare_graphs_visual_rejects_repeated_labels(monkeypatch): + # relabel_nodes merges nodes that share a label, so the relabeled graph + # does not have exactly new_labels as its nodes + monkeypatch.setattr(plt, "show", lambda: None) + g = nx.path_graph(4) + new_labels = [0, 0, 1, 2] + new_g = nx.relabel_nodes(g, dict(enumerate(new_labels))) + try: + with pytest.raises(AssertionError): + _compare_graphs_visual(g, new_g, new_labels) + finally: + plt.close("all") + + +def _edge_set(g): + return {frozenset(e) for e in g.edges} + + +def _path_in_order(order, nodes=None): + # nodes are inserted in sorted order (or as given), then the edges run along order + g = nx.Graph() + g.add_nodes_from(sorted(order) if nodes is None else nodes) + g.add_edges_from(zip(order, order[1:])) + return g + + +@pytest.mark.parametrize( + "order", [[1, 0, 2, 3], [0, 2, 1, 4, 3], [2, 0, 4, 1, 5, 3], [3, 6, 0, 5, 1, 4, 2]] +) +def test_linear_partial_orbit_follows_a_path_not_in_iteration_order(order): + g = _path_in_order(order) + orbit = linear_partial_orbit(g) + assert len(orbit) == len(_partial_orbit(len(g))) + assert _edge_set(orbit[0]) == _edge_set(g) + assert len({frozenset(_edge_set(h)) for h in orbit}) == len(orbit) + for i, a in enumerate(orbit): + for b in orbit[:i]: + assert not nx.is_isomorphic(a, b) + + +@pytest.mark.parametrize( + "order", [[1, 0, 2, 3], [2, 0, 4, 1, 5, 3], [3, 6, 0, 5, 1, 4, 2]] +) +def test_linear_partial_orbit_matches_the_in_order_orbit_carried_along_the_path(order): + # the reference applies _partial_orbit's sequences to the path 0-1-...-(n-1) and + # carries the result onto the path walked from the end that comes first in + # iteration order + n = len(order) + reference = [] + for lc_ops in _partial_orbit(n): + r = nx.path_graph(n) + for x in lc_ops: + r = local_comp_graph(r, x) + reference.append(r) + walk = order if order[0] < order[-1] else order[::-1] + orbit = linear_partial_orbit(_path_in_order(order)) + assert [_edge_set(h) for h in orbit] == [ + _edge_set(nx.relabel_nodes(r, dict(enumerate(walk)))) for r in reference + ] + + +def test_linear_partial_orbit_labels_by_position_for_other_labels(): + nodes = ["c", "a", "d", "b"] + # the path a-c-d-b runs through positions 1-0-2-3 + g = _path_in_order(["a", "c", "d", "b"], nodes) + orbit = linear_partial_orbit(g) + assert list(orbit[0].nodes) == [0, 1, 2, 3] + assert _edge_set(orbit[0]) == _edge_set( + nx.relabel_nodes(g, {v: i for i, v in enumerate(nodes)}) + ) + + +def test_linear_partial_orbit_in_order_path_is_unchanged(): + orbit = linear_partial_orbit(nx.path_graph(5)) + assert [sorted(tuple(sorted(e)) for e in h.edges) for h in orbit] == [ + [(0, 1), (1, 2), (2, 3), (3, 4)], + [(0, 1), (0, 2), (1, 2), (2, 3), (3, 4)], + [(0, 2), (0, 3), (1, 2), (1, 3), (2, 3), (3, 4)], + [(0, 2), (0, 3), (1, 2), (1, 3), (3, 4)], + [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)], + [(0, 1), (0, 2), (0, 3), (2, 3), (3, 4)], + ] + + +def test_linear_partial_orbit_rejects_a_path_plus_a_cycle(): + # n - 1 edges and maximum degree 2, but not connected + g = nx.Graph([(0, 1), (1, 2), (2, 3), (4, 5), (5, 6), (6, 4)]) + with pytest.raises(AssertionError, match="not a linear graph"): + linear_partial_orbit(g) + + +@pytest.mark.parametrize("n_node", [3, 4, 5]) +@pytest.mark.parametrize("seed", range(5)) +def test_label_finder_returns_distinct_permutations_for_small_graphs(n_node, seed): + # below 8 nodes the permutations are drawn from all n_node! of them; asking for + # every one must return every one, with the identity first + n_label = math.factorial(n_node) + labels = _label_finder(n_label, n_node, seed=seed) + assert labels.shape == (n_label, n_node) + assert {tuple(label) for label in labels} == set(permutations(range(n_node))) + assert tuple(labels[0]) == tuple(range(n_node)) + + +@pytest.mark.parametrize("seed", range(10)) +def test_iso_finder_finds_every_labelling_of_a_four_node_path(seed): + # a path on 4 nodes has 4!/2 = 12 distinct labellings + adj = nx.to_numpy_array(nx.path_graph(4)) + found = iso_finder(adj, 12, seed=seed) + assert len({tuple(a.astype(int).flatten()) for a in found}) == 12 + + +@pytest.mark.parametrize("n_node, n_label, seed", [(4, 5, 0), (5, 10, 4), (6, 20, 1)]) +def test_label_finder_keeps_a_draw_that_has_no_repeat(n_node, n_label, seed): + # a seeded draw without repeats gives the same permutations, in the same order, as + # drawing n_label - 1 of the non-identity permutations with replacement + perm = list(permutations(range(n_node))) + drawn = np.random.default_rng(seed).choice(perm[1:], n_label - 1) + assert len({tuple(d) for d in drawn}) == n_label - 1 + expected = np.concatenate((np.array([perm[0]]), drawn), axis=0) + assert np.array_equal(_label_finder(n_label, n_node, seed=seed), expected) + + +def test_label_finder_on_one_node_returns_the_identity(): + assert _label_finder(1, 1, seed=0).tolist() == [[0]] + + +def test_iso_finder_on_one_node_returns_the_graph(): + adj = np.zeros((1, 1)) + found = iso_finder(adj, 1, seed=0) + assert len(found) == 1 + assert np.array_equal(found[0], adj) diff --git a/tests/test_utils/test_solver_result.py b/tests/test_utils/test_solver_result.py index 13692a71..ac2e0b93 100644 --- a/tests/test_utils/test_solver_result.py +++ b/tests/test_utils/test_solver_result.py @@ -12,6 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import networkx as nx +import pytest + from graphiq.solvers.solver_result import * from graphiq.benchmarks.circuits import ( ghz3_state_circuit, @@ -85,3 +88,73 @@ def test_get_index_with_column_value(): assert result.get_index_with_column_value("test_property", 1) == [0, 2] assert result.get_index_with_column_value("test_property", 2) == [1] + + +def test_set_item_rejects_non_list_non_array_value(): + circuit_1, _ = ghz3_state_circuit() + circuit_2, _ = linear_cluster_3qubit_circuit() + result = SolverResult([circuit_1, circuit_2]) + + # a string of the right length (2) must still be rejected + with pytest.raises(TypeError): + result["x"] = "ab" + + +def test_load_json_round_trips_saved_columns(tmp_path): + circuit, _ = ghz3_state_circuit() + + saved = SolverResult([circuit], properties=["score"]) + saved["score"] = [0.5] + saved["g"] = [nx.path_graph(2)] + saved.save2json(tmp_path, "res") + + loaded = SolverResult([circuit]) + with pytest.warns(UserWarning, match="openqasm") as rec: + loaded.load_json(tmp_path, "res") + + assert len(rec) == 1 + assert loaded["score"] == [0.5] + assert nx.utils.graphs_equal(loaded["g"][0], nx.path_graph(2)) + + # a declared property missing from the file also warns + loaded_missing = SolverResult([circuit], properties=["missing"]) + with pytest.warns(UserWarning) as rec: + loaded_missing.load_json(tmp_path, "res") + + assert len(rec) == 2 + + +def test_sort_by(): + circuit_1, _ = ghz3_state_circuit() + circuit_2, _ = linear_cluster_3qubit_circuit() + circuit_3, _ = ghz4_state_circuit() + circuit_list = [circuit_1, circuit_2, circuit_3] + + result = SolverResult(circuit_list) + result["score"] = [3, 1, 2] + result.sort_by("score") + + assert result["score"] == [1, 2, 3] + assert result["circuit"] == [circuit_2, circuit_3, circuit_1] + + +def test_add_properties_rejects_non_string(): + circuit_1, _ = ghz3_state_circuit() + result = SolverResult([circuit_1]) + + with pytest.raises(AssertionError): + result.add_properties(123) + + +def test_str_contains_circuit_id_and_property_value_but_no_column_names(): + circuit_1, _ = ghz3_state_circuit() + result = SolverResult([circuit_1]) + result.add_properties("my_test_property") + result["my_test_property"] = ["some_value_xyz"] + + printed = str(result) + + assert "c0" in printed + assert "some_value_xyz" in printed + assert "my_test_property" not in printed + assert "circuit_id" not in printed